feat: continue watching row, fix secure-store web crash, expo-doctor fixes
This commit is contained in:
parent
144a2cd227
commit
f68195c25c
11 changed files with 1390 additions and 38 deletions
5
app.json
5
app.json
|
|
@ -26,11 +26,6 @@
|
|||
"bundler": "metro",
|
||||
"output": "static"
|
||||
},
|
||||
"splash": {
|
||||
"image": "./assets/splash-icon.png",
|
||||
"resizeMode": "contain",
|
||||
"backgroundColor": "#121212"
|
||||
},
|
||||
"plugins": [
|
||||
"expo-router",
|
||||
"expo-status-bar",
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { Animated, Image, Pressable, RefreshControl, ScrollView, StyleSheet, Text, View } from 'react-native'
|
||||
import { Animated, FlatList, Image, Pressable, RefreshControl, ScrollView, StyleSheet, Text, View } from 'react-native'
|
||||
import { useRouter } from 'expo-router'
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context'
|
||||
import { api, imageUrl } from '../../src/lib/api'
|
||||
import { getContinueWatching, ContinueWatchingItem } from '../../src/lib/continueWatching'
|
||||
import type { Release, ScheduleDay } from '../../src/lib/types'
|
||||
import { colors, radius, spacing } from '../../src/lib/theme'
|
||||
import Row from '../../src/components/Row'
|
||||
|
|
@ -15,20 +16,23 @@ export default function HomeScreen() {
|
|||
const [latest, setLatest] = useState<Release[] | null>(null)
|
||||
const [recommended, setRecommended] = useState<Release[]>([])
|
||||
const [today, setToday] = useState<ScheduleDay[]>([])
|
||||
const [continueWatching, setContinueWatching] = useState<ContinueWatchingItem[]>([])
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
const heroOpacity = useRef(new Animated.Value(0)).current
|
||||
const heroTranslate = useRef(new Animated.Value(16)).current
|
||||
const buttonScale = useRef(new Animated.Value(1)).current
|
||||
|
||||
const load = useCallback(async () => {
|
||||
const [latestRes, recommendedRes, scheduleRes] = await Promise.all([
|
||||
const [latestRes, recommendedRes, scheduleRes, continueRes] = await Promise.all([
|
||||
api.latest(21).catch(() => [] as Release[]),
|
||||
api.recommended(14).catch(() => [] as Release[]),
|
||||
api.scheduleNow().catch(() => null),
|
||||
getContinueWatching().catch(() => [] as ContinueWatchingItem[]),
|
||||
])
|
||||
setLatest(latestRes)
|
||||
setRecommended(recommendedRes)
|
||||
setToday(scheduleRes?.today?.filter((s) => s.release) ?? [])
|
||||
setContinueWatching(continueRes)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -114,6 +118,46 @@ export default function HomeScreen() {
|
|||
</Animated.View>
|
||||
)}
|
||||
|
||||
{continueWatching.length > 0 && (
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>Продолжить просмотр</Text>
|
||||
<FlatList
|
||||
data={continueWatching}
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
keyExtractor={(item) => item.episode.id}
|
||||
contentContainerStyle={{ paddingHorizontal: spacing(4), gap: spacing(3) }}
|
||||
renderItem={({ item }) => (
|
||||
<Pressable
|
||||
style={styles.continueCard}
|
||||
onPress={() => router.push(`/title/${item.release.alias || item.release.id}`)}
|
||||
>
|
||||
<Image
|
||||
source={{
|
||||
uri: imageUrl(item.release.poster?.optimized?.preview || item.release.poster?.preview),
|
||||
}}
|
||||
style={styles.continueImage}
|
||||
resizeMode="cover"
|
||||
/>
|
||||
<View style={styles.continueOverlay}>
|
||||
<Text style={styles.continueEpisode}>Серия {item.episode.ordinal}</Text>
|
||||
{!!item.episode.duration && (
|
||||
<View style={styles.continueBarTrack}>
|
||||
<View
|
||||
style={[
|
||||
styles.continueBarFill,
|
||||
{ width: `${Math.min(100, (item.time / item.episode.duration) * 100)}%` },
|
||||
]}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</Pressable>
|
||||
)}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<Row title="Онгоинги сегодня" releases={today.map((s) => s.release)} />
|
||||
<Row title="Последние обновления" releases={latest} />
|
||||
<Row title="Рекомендуем" releases={recommended} />
|
||||
|
|
@ -128,6 +172,48 @@ const styles = StyleSheet.create({
|
|||
flex: 1,
|
||||
backgroundColor: colors.background,
|
||||
},
|
||||
section: {
|
||||
marginTop: spacing(5),
|
||||
},
|
||||
sectionTitle: {
|
||||
color: colors.text,
|
||||
fontSize: 17,
|
||||
fontWeight: '700',
|
||||
marginBottom: spacing(3),
|
||||
paddingHorizontal: spacing(4),
|
||||
},
|
||||
continueCard: {
|
||||
width: 110,
|
||||
},
|
||||
continueImage: {
|
||||
width: 110,
|
||||
height: 165,
|
||||
borderRadius: radius.md,
|
||||
backgroundColor: colors.surfaceCard,
|
||||
},
|
||||
continueOverlay: {
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
padding: spacing(2),
|
||||
},
|
||||
continueEpisode: {
|
||||
color: colors.text,
|
||||
fontSize: 11,
|
||||
fontWeight: '600',
|
||||
marginBottom: spacing(1),
|
||||
},
|
||||
continueBarTrack: {
|
||||
height: 3,
|
||||
borderRadius: 2,
|
||||
backgroundColor: 'rgba(0,0,0,0.4)',
|
||||
overflow: 'hidden',
|
||||
},
|
||||
continueBarFill: {
|
||||
height: '100%',
|
||||
backgroundColor: colors.accent,
|
||||
},
|
||||
hero: {
|
||||
width: '100%',
|
||||
aspectRatio: 3 / 4,
|
||||
|
|
|
|||
|
|
@ -13,7 +13,18 @@ export default function RootLayout() {
|
|||
<StatusBar style="light" />
|
||||
<Stack screenOptions={{ headerShown: false, contentStyle: { backgroundColor: colors.background } }}>
|
||||
<Stack.Screen name="(tabs)" />
|
||||
<Stack.Screen name="title/[id]" options={{ presentation: 'card' }} />
|
||||
<Stack.Screen
|
||||
name="title/[id]"
|
||||
options={{
|
||||
presentation: 'card',
|
||||
headerShown: true,
|
||||
headerTitle: '',
|
||||
headerBackTitle: '',
|
||||
headerStyle: { backgroundColor: colors.background },
|
||||
headerTintColor: colors.text,
|
||||
headerShadowVisible: false,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen name="login" options={{ presentation: 'modal' }} />
|
||||
</Stack>
|
||||
</AuthProvider>
|
||||
|
|
|
|||
609
app/title/[id].tsx
Normal file
609
app/title/[id].tsx
Normal file
|
|
@ -0,0 +1,609 @@
|
|||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import {
|
||||
FlatList,
|
||||
Image,
|
||||
Linking,
|
||||
Modal,
|
||||
Pressable,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Text,
|
||||
View,
|
||||
} from 'react-native'
|
||||
import { useLocalSearchParams } from 'expo-router'
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context'
|
||||
import Ionicons from '@expo/vector-icons/Ionicons'
|
||||
import { api, imageUrl } from '../../src/lib/api'
|
||||
import type { CollectionType, Episode, Release, TimecodeItem } from '../../src/lib/types'
|
||||
import { useAuth } from '../../src/lib/AuthContext'
|
||||
import { getAllLocalProgress, saveLocalProgress } from '../../src/lib/localProgress'
|
||||
import { isInWishlist, toggleWishlist } from '../../src/lib/wishlist'
|
||||
import { colors, radius, spacing } from '../../src/lib/theme'
|
||||
import Loader from '../../src/components/Loader'
|
||||
import Chip from '../../src/components/Chip'
|
||||
import Player from '../../src/components/Player'
|
||||
|
||||
const COLLECTION_LABELS: Record<CollectionType, string> = {
|
||||
WATCHING: 'Смотрю',
|
||||
PLANNED: 'Запланировано',
|
||||
WATCHED: 'Просмотрено',
|
||||
POSTPONED: 'Отложено',
|
||||
ABANDONED: 'Брошено',
|
||||
}
|
||||
|
||||
function formatBytes(n: number): string {
|
||||
const units = ['Б', 'КБ', 'МБ', 'ГБ']
|
||||
let i = 0
|
||||
let v = n
|
||||
while (v >= 1024 && i < units.length - 1) {
|
||||
v /= 1024
|
||||
i++
|
||||
}
|
||||
return `${v.toFixed(1)} ${units[i]}`
|
||||
}
|
||||
|
||||
function extractCollectionType(items: unknown, releaseId: number): CollectionType | null {
|
||||
if (!Array.isArray(items)) return null
|
||||
for (const item of items) {
|
||||
if (item && typeof item === 'object' && 'release_id' in item && 'type_of_collection' in item) {
|
||||
const o = item as { release_id: number; type_of_collection: CollectionType }
|
||||
if (o.release_id === releaseId) return o.type_of_collection
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function extractIsFavorite(items: unknown, releaseId: number): boolean {
|
||||
if (!Array.isArray(items)) return false
|
||||
return items.some((item) => {
|
||||
if (typeof item === 'number') return item === releaseId
|
||||
if (item && typeof item === 'object' && 'release_id' in item) {
|
||||
return (item as { release_id: number }).release_id === releaseId
|
||||
}
|
||||
return false
|
||||
})
|
||||
}
|
||||
|
||||
export default function TitleDetailScreen() {
|
||||
const { id } = useLocalSearchParams<{ id: string }>()
|
||||
const { user } = useAuth()
|
||||
const insets = useSafeAreaInsets()
|
||||
|
||||
const [release, setRelease] = useState<Release | null>(null)
|
||||
const [error, setError] = useState(false)
|
||||
const [selectedEp, setSelectedEp] = useState<Episode | null>(null)
|
||||
const [timecodes, setTimecodes] = useState<Map<string, TimecodeItem>>(new Map())
|
||||
const [saved, setSaved] = useState(false)
|
||||
const [listOpen, setListOpen] = useState(false)
|
||||
const [collectionType, setCollectionType] = useState<CollectionType | null>(null)
|
||||
const [isFavorite, setIsFavorite] = useState(false)
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return
|
||||
setRelease(null)
|
||||
setError(false)
|
||||
setSelectedEp(null)
|
||||
api
|
||||
.release(id)
|
||||
.then((r) => {
|
||||
setRelease(r)
|
||||
setSelectedEp(r.episodes?.[0] ?? null)
|
||||
})
|
||||
.catch(() => setError(true))
|
||||
}, [id])
|
||||
|
||||
useEffect(() => {
|
||||
if (!release) return
|
||||
isInWishlist(release.id).then(setSaved)
|
||||
}, [release])
|
||||
|
||||
useEffect(() => {
|
||||
getAllLocalProgress().then((local) => {
|
||||
const merged = new Map<string, TimecodeItem>(
|
||||
Object.entries(local).map(([epId, p]) => [
|
||||
epId,
|
||||
{ release_episode_id: epId, time: p.time, is_watched: p.is_watched },
|
||||
])
|
||||
)
|
||||
setTimecodes(merged)
|
||||
})
|
||||
|
||||
if (!user) return
|
||||
api
|
||||
.timecodes()
|
||||
.then((items) => {
|
||||
setTimecodes((prev) => {
|
||||
const next = new Map(prev)
|
||||
for (const i of items) next.set(i.release_episode_id, i)
|
||||
return next
|
||||
})
|
||||
})
|
||||
.catch(() => {})
|
||||
}, [user, id])
|
||||
|
||||
useEffect(() => {
|
||||
if (!user || !release) return
|
||||
api
|
||||
.collectionIds()
|
||||
.then((ids) => setCollectionType(extractCollectionType(ids, release.id)))
|
||||
.catch(() => {})
|
||||
api
|
||||
.favoriteIds()
|
||||
.then((ids) => setIsFavorite(extractIsFavorite(ids, release.id)))
|
||||
.catch(() => {})
|
||||
}, [user, release])
|
||||
|
||||
const poster = useMemo(
|
||||
() => imageUrl(release?.poster?.optimized?.preview || release?.poster?.preview),
|
||||
[release]
|
||||
)
|
||||
|
||||
const saveTimecode = useCallback(
|
||||
(episodeId: string, time: number, isWatched: boolean) => {
|
||||
saveLocalProgress(episodeId, time, isWatched)
|
||||
if (user) api.timecodeSave(episodeId, time, isWatched).catch(() => {})
|
||||
setTimecodes((prev) => {
|
||||
const next = new Map(prev)
|
||||
next.set(episodeId, { release_episode_id: episodeId, time, is_watched: isWatched })
|
||||
return next
|
||||
})
|
||||
},
|
||||
[user]
|
||||
)
|
||||
|
||||
function goToNextEpisode() {
|
||||
if (!release?.episodes || !selectedEp) return
|
||||
const idx = release.episodes.findIndex((e) => e.id === selectedEp.id)
|
||||
const next = release.episodes[idx + 1]
|
||||
if (next) setSelectedEp(next)
|
||||
}
|
||||
|
||||
async function handleToggleWishlist() {
|
||||
if (!release) return
|
||||
const nowSaved = await toggleWishlist(release)
|
||||
setSaved(nowSaved)
|
||||
}
|
||||
|
||||
async function pickCollection(type: CollectionType) {
|
||||
if (!release) return
|
||||
setBusy(true)
|
||||
try {
|
||||
await api.collectionAdd(release.id, type)
|
||||
setCollectionType(type)
|
||||
} finally {
|
||||
setBusy(false)
|
||||
setListOpen(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function removeFromList() {
|
||||
if (!release) return
|
||||
setBusy(true)
|
||||
try {
|
||||
await api.collectionRemove(release.id)
|
||||
setCollectionType(null)
|
||||
} finally {
|
||||
setBusy(false)
|
||||
setListOpen(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleFavorite() {
|
||||
if (!release) return
|
||||
setBusy(true)
|
||||
try {
|
||||
if (isFavorite) await api.favoriteRemove(release.id)
|
||||
else await api.favoriteAdd(release.id)
|
||||
setIsFavorite(!isFavorite)
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<View style={styles.centered}>
|
||||
<Text style={styles.errorText}>Тайтл не найден</Text>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
if (!release) return <Loader label="Загружаем..." />
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
style={styles.screen}
|
||||
contentContainerStyle={{ paddingBottom: insets.bottom + spacing(10) }}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
<View style={styles.header}>
|
||||
{poster && <Image source={{ uri: poster }} style={styles.poster} resizeMode="cover" />}
|
||||
<View style={styles.headerInfo}>
|
||||
<Text style={styles.title}>{release.name.main}</Text>
|
||||
{release.name.english && <Text style={styles.englishTitle}>{release.name.english}</Text>}
|
||||
|
||||
<View style={styles.chipRow}>
|
||||
<Chip>{release.type?.description}</Chip>
|
||||
<Chip>{release.year}</Chip>
|
||||
<Chip>{release.season?.description}</Chip>
|
||||
<Chip>{release.age_rating?.label}</Chip>
|
||||
{release.is_ongoing && <Chip accent>Онгоинг</Chip>}
|
||||
{release.episodes_total != null && <Chip>{release.episodes_total} эп.</Chip>}
|
||||
</View>
|
||||
|
||||
<View style={styles.actionsRow}>
|
||||
{user && (
|
||||
<Pressable
|
||||
onPress={() => setListOpen(true)}
|
||||
disabled={busy}
|
||||
style={[styles.listButton, collectionType && styles.listButtonActive]}
|
||||
>
|
||||
<Text style={styles.listButtonText}>
|
||||
{collectionType ? COLLECTION_LABELS[collectionType] : '+ В список'}
|
||||
</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
{user && (
|
||||
<Pressable onPress={toggleFavorite} disabled={busy} style={styles.iconButton}>
|
||||
<Ionicons
|
||||
name={isFavorite ? 'heart' : 'heart-outline'}
|
||||
size={18}
|
||||
color={isFavorite ? colors.accent : colors.textDim}
|
||||
/>
|
||||
</Pressable>
|
||||
)}
|
||||
<Pressable onPress={handleToggleWishlist} style={styles.iconButton}>
|
||||
<Ionicons
|
||||
name={saved ? 'star' : 'star-outline'}
|
||||
size={18}
|
||||
color={saved ? colors.accent : colors.textDim}
|
||||
/>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{!!release.genres?.length && (
|
||||
<View style={styles.genreRow}>
|
||||
{release.genres.map((g) => (
|
||||
<Chip key={g.id}>{g.name}</Chip>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{release.description && <Text style={styles.description}>{release.description}</Text>}
|
||||
|
||||
{selectedEp && (
|
||||
<View style={styles.playerWrap}>
|
||||
<Player
|
||||
key={selectedEp.id}
|
||||
episode={selectedEp}
|
||||
resumeAt={timecodes.get(selectedEp.id)?.time}
|
||||
onProgress={(t) => saveTimecode(selectedEp.id, t, false)}
|
||||
onEnded={() => {
|
||||
saveTimecode(selectedEp.id, 0, true)
|
||||
goToNextEpisode()
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{!!release.episodes?.length && (
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>Серии</Text>
|
||||
<FlatList
|
||||
horizontal
|
||||
data={release.episodes}
|
||||
keyExtractor={(ep) => ep.id}
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={styles.episodeList}
|
||||
renderItem={({ item: ep }) => {
|
||||
const tc = timecodes.get(ep.id)
|
||||
const isSelected = selectedEp?.id === ep.id
|
||||
return (
|
||||
<Pressable
|
||||
onPress={() => setSelectedEp(ep)}
|
||||
style={[styles.episodeButton, isSelected && styles.episodeButtonActive]}
|
||||
>
|
||||
<View style={styles.episodeRow}>
|
||||
<Text style={styles.episodeLabel}>Серия {ep.ordinal}</Text>
|
||||
{tc?.is_watched && <Text style={styles.episodeCheck}>✓</Text>}
|
||||
</View>
|
||||
{ep.name && (
|
||||
<Text numberOfLines={1} style={styles.episodeName}>
|
||||
{ep.name}
|
||||
</Text>
|
||||
)}
|
||||
{tc && !tc.is_watched && ep.duration ? (
|
||||
<View style={styles.progressTrack}>
|
||||
<View
|
||||
style={[
|
||||
styles.progressFill,
|
||||
{ width: `${Math.min(100, (tc.time / ep.duration) * 100)}%` },
|
||||
]}
|
||||
/>
|
||||
</View>
|
||||
) : null}
|
||||
</Pressable>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{!!release.torrents?.length && (
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>Торренты</Text>
|
||||
{release.torrents.map((t) => (
|
||||
<View key={t.id} style={styles.torrentRow}>
|
||||
<View style={styles.torrentInfo}>
|
||||
<Text numberOfLines={1} style={styles.torrentLabel}>
|
||||
{t.label}
|
||||
</Text>
|
||||
<Text style={styles.torrentMeta}>
|
||||
{formatBytes(t.size)} · {t.quality?.description} · {t.seeders} сидов
|
||||
</Text>
|
||||
</View>
|
||||
<Pressable onPress={() => Linking.openURL(t.magnet)} style={styles.magnetButton}>
|
||||
<Text style={styles.magnetButtonText}>Magnet</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
|
||||
<Modal
|
||||
visible={listOpen}
|
||||
transparent
|
||||
animationType="fade"
|
||||
onRequestClose={() => setListOpen(false)}
|
||||
>
|
||||
<Pressable style={styles.modalBackdrop} onPress={() => setListOpen(false)}>
|
||||
<View style={[styles.actionSheet, { paddingBottom: insets.bottom + spacing(4) }]}>
|
||||
{(Object.keys(COLLECTION_LABELS) as CollectionType[]).map((type) => (
|
||||
<Pressable key={type} onPress={() => pickCollection(type)} style={styles.actionSheetRow}>
|
||||
<Text
|
||||
style={[
|
||||
styles.actionSheetText,
|
||||
collectionType === type && styles.actionSheetTextActive,
|
||||
]}
|
||||
>
|
||||
{COLLECTION_LABELS[type]}
|
||||
</Text>
|
||||
</Pressable>
|
||||
))}
|
||||
{collectionType && (
|
||||
<Pressable onPress={removeFromList} style={styles.actionSheetRow}>
|
||||
<Text style={styles.actionSheetRemove}>Убрать из списка</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
<Pressable onPress={() => setListOpen(false)} style={styles.actionSheetRow}>
|
||||
<Text style={styles.actionSheetCancel}>Отмена</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</Pressable>
|
||||
</Modal>
|
||||
</ScrollView>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
screen: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.background,
|
||||
},
|
||||
centered: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: colors.background,
|
||||
},
|
||||
errorText: {
|
||||
color: colors.textFaint,
|
||||
fontSize: 14,
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
gap: spacing(3),
|
||||
paddingHorizontal: spacing(4),
|
||||
paddingTop: spacing(2),
|
||||
},
|
||||
poster: {
|
||||
width: 110,
|
||||
aspectRatio: 2 / 3,
|
||||
borderRadius: radius.md,
|
||||
backgroundColor: colors.surfaceRaised,
|
||||
},
|
||||
headerInfo: {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
},
|
||||
title: {
|
||||
color: colors.text,
|
||||
fontSize: 20,
|
||||
fontWeight: '800',
|
||||
},
|
||||
englishTitle: {
|
||||
color: colors.textFaint,
|
||||
fontSize: 12,
|
||||
marginTop: spacing(0.5),
|
||||
},
|
||||
chipRow: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
gap: spacing(1.5),
|
||||
marginTop: spacing(2.5),
|
||||
},
|
||||
actionsRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing(2),
|
||||
marginTop: spacing(3),
|
||||
},
|
||||
listButton: {
|
||||
backgroundColor: colors.accent,
|
||||
paddingHorizontal: spacing(3),
|
||||
paddingVertical: spacing(1.5),
|
||||
borderRadius: radius.full,
|
||||
},
|
||||
listButtonActive: {
|
||||
backgroundColor: colors.surfaceRaised,
|
||||
},
|
||||
listButtonText: {
|
||||
color: colors.text,
|
||||
fontSize: 12,
|
||||
fontWeight: '700',
|
||||
},
|
||||
iconButton: {
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: radius.full,
|
||||
backgroundColor: colors.surfaceRaised,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
genreRow: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
gap: spacing(1.5),
|
||||
paddingHorizontal: spacing(4),
|
||||
marginTop: spacing(3.5),
|
||||
},
|
||||
description: {
|
||||
color: colors.textDim,
|
||||
fontSize: 13,
|
||||
lineHeight: 19,
|
||||
paddingHorizontal: spacing(4),
|
||||
marginTop: spacing(3.5),
|
||||
},
|
||||
playerWrap: {
|
||||
paddingHorizontal: spacing(4),
|
||||
marginTop: spacing(5),
|
||||
},
|
||||
section: {
|
||||
marginTop: spacing(5),
|
||||
},
|
||||
sectionTitle: {
|
||||
color: colors.text,
|
||||
fontSize: 17,
|
||||
fontWeight: '700',
|
||||
marginBottom: spacing(2),
|
||||
paddingHorizontal: spacing(4),
|
||||
},
|
||||
episodeList: {
|
||||
paddingHorizontal: spacing(4),
|
||||
gap: spacing(2),
|
||||
},
|
||||
episodeButton: {
|
||||
width: 130,
|
||||
backgroundColor: colors.surfaceRaised,
|
||||
borderRadius: radius.md,
|
||||
padding: spacing(3),
|
||||
},
|
||||
episodeButtonActive: {
|
||||
backgroundColor: colors.accent,
|
||||
},
|
||||
episodeRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
episodeLabel: {
|
||||
color: colors.text,
|
||||
fontSize: 13,
|
||||
fontWeight: '700',
|
||||
},
|
||||
episodeCheck: {
|
||||
color: colors.text,
|
||||
fontSize: 11,
|
||||
},
|
||||
episodeName: {
|
||||
color: colors.textFaint,
|
||||
fontSize: 11,
|
||||
marginTop: spacing(1),
|
||||
},
|
||||
progressTrack: {
|
||||
height: 3,
|
||||
backgroundColor: 'rgba(0,0,0,0.3)',
|
||||
borderRadius: radius.full,
|
||||
marginTop: spacing(2),
|
||||
overflow: 'hidden',
|
||||
},
|
||||
progressFill: {
|
||||
height: '100%',
|
||||
backgroundColor: 'rgba(255,255,255,0.7)',
|
||||
},
|
||||
torrentRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
backgroundColor: colors.surfaceRaised,
|
||||
borderRadius: radius.md,
|
||||
paddingHorizontal: spacing(4),
|
||||
paddingVertical: spacing(3),
|
||||
marginHorizontal: spacing(4),
|
||||
marginBottom: spacing(2),
|
||||
},
|
||||
torrentInfo: {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
marginRight: spacing(3),
|
||||
},
|
||||
torrentLabel: {
|
||||
color: colors.text,
|
||||
fontSize: 13,
|
||||
fontWeight: '600',
|
||||
},
|
||||
torrentMeta: {
|
||||
color: colors.textFaint,
|
||||
fontSize: 11,
|
||||
marginTop: spacing(1),
|
||||
},
|
||||
magnetButton: {
|
||||
backgroundColor: colors.accent,
|
||||
paddingHorizontal: spacing(3),
|
||||
paddingVertical: spacing(1.5),
|
||||
borderRadius: radius.full,
|
||||
},
|
||||
magnetButtonText: {
|
||||
color: colors.text,
|
||||
fontSize: 11,
|
||||
fontWeight: '700',
|
||||
},
|
||||
modalBackdrop: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.overlay,
|
||||
justifyContent: 'flex-end',
|
||||
},
|
||||
actionSheet: {
|
||||
backgroundColor: colors.surfaceRaised,
|
||||
borderTopLeftRadius: radius.lg,
|
||||
borderTopRightRadius: radius.lg,
|
||||
paddingTop: spacing(3),
|
||||
paddingHorizontal: spacing(4),
|
||||
},
|
||||
actionSheetRow: {
|
||||
paddingVertical: spacing(3),
|
||||
borderBottomWidth: StyleSheet.hairlineWidth,
|
||||
borderBottomColor: colors.border,
|
||||
},
|
||||
actionSheetText: {
|
||||
color: colors.text,
|
||||
fontSize: 15,
|
||||
},
|
||||
actionSheetTextActive: {
|
||||
color: colors.accent,
|
||||
fontWeight: '700',
|
||||
},
|
||||
actionSheetRemove: {
|
||||
color: '#ff6b6b',
|
||||
fontSize: 15,
|
||||
},
|
||||
actionSheetCancel: {
|
||||
color: colors.textFaint,
|
||||
fontSize: 15,
|
||||
fontWeight: '600',
|
||||
textAlign: 'center',
|
||||
},
|
||||
})
|
||||
341
package-lock.json
generated
341
package-lock.json
generated
|
|
@ -8,6 +8,8 @@
|
|||
"name": "anisav-mobile",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"@expo/metro-runtime": "~57.0.6",
|
||||
"@expo/vector-icons": "^15.0.2",
|
||||
"@react-native-async-storage/async-storage": "2.2.0",
|
||||
"expo": "~57.0.7",
|
||||
"expo-constants": "~57.0.6",
|
||||
|
|
@ -20,11 +22,14 @@
|
|||
"expo-status-bar": "~57.0.1",
|
||||
"expo-video": "~57.0.1",
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3",
|
||||
"react-native": "0.86.0",
|
||||
"react-native-gesture-handler": "~2.32.0",
|
||||
"react-native-reanimated": "4.5.0",
|
||||
"react-native-safe-area-context": "~5.7.0",
|
||||
"react-native-screens": "4.25.2"
|
||||
"react-native-screens": "4.25.2",
|
||||
"react-native-web": "^0.21.2",
|
||||
"react-native-worklets": "0.10.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "~19.2.2",
|
||||
|
|
@ -556,6 +561,21 @@
|
|||
"@babel/core": "^7.0.0-0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/plugin-transform-arrow-functions": {
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.29.7.tgz",
|
||||
"integrity": "sha512-N7zArUXWzAMzm+/N0uPBeVB3Fam5lMxtUwMmDK5f/IBBS7a7p1qeUoxd/6CckXoxUdgsntq1Dh8xNW06maZbDQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/helper-plugin-utils": "^7.29.7"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@babel/core": "^7.0.0-0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/plugin-transform-async-generator-functions": {
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-generator-functions/-/plugin-transform-async-generator-functions-7.29.7.tgz",
|
||||
|
|
@ -974,6 +994,36 @@
|
|||
"semver": "bin/semver.js"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/plugin-transform-shorthand-properties": {
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.29.7.tgz",
|
||||
"integrity": "sha512-I+WYbGBAiCn7nA6xBrlgPH+MB7HWb4u8pv5S0Pv7OtwNvIFvCCb24YlttKEeUFVurfBCEaOTnuhlqsb7f0Z5Dg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/helper-plugin-utils": "^7.29.7"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@babel/core": "^7.0.0-0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/plugin-transform-template-literals": {
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.29.7.tgz",
|
||||
"integrity": "sha512-NCSEJ4sLFU2gqAub45HYh4fus2yQ36rr6ei6vpU7NdoJqCpxvEG8E6eJpscGyXP3VHD2Ny+fSXr04k1hoUrFqA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/helper-plugin-utils": "^7.29.7"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@babel/core": "^7.0.0-0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/plugin-transform-typescript": {
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.29.7.tgz",
|
||||
|
|
@ -1344,6 +1394,31 @@
|
|||
"walker": "^1.0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/@expo/metro-runtime": {
|
||||
"version": "57.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@expo/metro-runtime/-/metro-runtime-57.0.6.tgz",
|
||||
"integrity": "sha512-x/lAtmPBkZGI3YrGirKQ77Ybmd2N4wI6zHE5v1Top08xiYPH28aUTNAu4vf3MBrf4MYnq7327Nj20hzIgzlRmw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@expo/log-box": "^57.0.1",
|
||||
"anser": "^1.4.9",
|
||||
"pretty-format": "^29.7.0",
|
||||
"stacktrace-parser": "^0.1.10",
|
||||
"whatwg-fetch": "^3.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@expo/log-box": "^57.0.1",
|
||||
"expo": "*",
|
||||
"react": "*",
|
||||
"react-dom": "*",
|
||||
"react-native": "*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@expo/osascript": {
|
||||
"version": "2.7.1",
|
||||
"resolved": "https://registry.npmjs.org/@expo/osascript/-/osascript-2.7.1.tgz",
|
||||
|
|
@ -1448,6 +1523,17 @@
|
|||
"integrity": "sha512-HHQigo3rQWKMDzYDLkubN5WQOYXJJE2eNqIQC2axC2iO3mHdwnIR7FgZVvHWtBwAdzBgAP0ECp8KqS8TiMKvgw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@expo/vector-icons": {
|
||||
"version": "15.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@expo/vector-icons/-/vector-icons-15.1.1.tgz",
|
||||
"integrity": "sha512-Iu2VkcoI5vygbtYngm7jb4ifxElNVXQYdDrYkT7UCEIiKLeWnQY0wf2ZhHZ+Wro6Sc5TaumpKUOqDRpLi5rkvw==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"expo-font": ">=14.0.4",
|
||||
"react": "*",
|
||||
"react-native": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@expo/xcpretty": {
|
||||
"version": "4.4.4",
|
||||
"resolved": "https://registry.npmjs.org/@expo/xcpretty/-/xcpretty-4.4.4.tgz",
|
||||
|
|
@ -2983,6 +3069,15 @@
|
|||
"url": "https://opencollective.com/core-js"
|
||||
}
|
||||
},
|
||||
"node_modules/cross-fetch": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-3.2.0.tgz",
|
||||
"integrity": "sha512-Q+xVJLoGOeIMXZmbUK4HYk+69cQH6LudR0Vu/pRm2YlU/hDV9CiS0gKUMaWY5f2NeUH9C1nV3bsTlCo0FsTV1Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"node-fetch": "^2.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/cross-spawn": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||
|
|
@ -2997,6 +3092,15 @@
|
|||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/css-in-js-utils": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/css-in-js-utils/-/css-in-js-utils-3.1.0.tgz",
|
||||
"integrity": "sha512-fJAcud6B3rRu+KHYk+Bwf+WFL2MDCJJ1XG9x137tJQ0xYxor7XziQtuGFbWNdqrvF4Tk26O3H73nfVqXt/fW1A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"hyphenate-style-name": "^1.0.3"
|
||||
}
|
||||
},
|
||||
"node_modules/css.escape": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz",
|
||||
|
|
@ -3437,31 +3541,6 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"node_modules/expo-router/node_modules/@expo/metro-runtime": {
|
||||
"version": "57.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@expo/metro-runtime/-/metro-runtime-57.0.6.tgz",
|
||||
"integrity": "sha512-x/lAtmPBkZGI3YrGirKQ77Ybmd2N4wI6zHE5v1Top08xiYPH28aUTNAu4vf3MBrf4MYnq7327Nj20hzIgzlRmw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@expo/log-box": "^57.0.1",
|
||||
"anser": "^1.4.9",
|
||||
"pretty-format": "^29.7.0",
|
||||
"stacktrace-parser": "^0.1.10",
|
||||
"whatwg-fetch": "^3.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@expo/log-box": "^57.0.1",
|
||||
"expo": "*",
|
||||
"react": "*",
|
||||
"react-dom": "*",
|
||||
"react-native": "*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/expo-router/node_modules/@expo/ui": {
|
||||
"version": "57.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@expo/ui/-/ui-57.0.7.tgz",
|
||||
|
|
@ -4002,6 +4081,36 @@
|
|||
"bser": "2.1.1"
|
||||
}
|
||||
},
|
||||
"node_modules/fbjs": {
|
||||
"version": "3.0.5",
|
||||
"resolved": "https://registry.npmjs.org/fbjs/-/fbjs-3.0.5.tgz",
|
||||
"integrity": "sha512-ztsSx77JBtkuMrEypfhgc3cI0+0h+svqeie7xHbh1k/IKdcydnvadp/mUaGgjAOXQmQSxsqgaRhS3q9fy+1kxg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cross-fetch": "^3.1.5",
|
||||
"fbjs-css-vars": "^1.0.0",
|
||||
"loose-envify": "^1.0.0",
|
||||
"object-assign": "^4.1.0",
|
||||
"promise": "^7.1.1",
|
||||
"setimmediate": "^1.0.5",
|
||||
"ua-parser-js": "^1.0.35"
|
||||
}
|
||||
},
|
||||
"node_modules/fbjs-css-vars": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/fbjs-css-vars/-/fbjs-css-vars-1.0.2.tgz",
|
||||
"integrity": "sha512-b2XGFAFdWZWg0phtAWLHCk836A1Xann+I+Dgd3Gk64MHKZO44FfoD1KxyvbSh0qZsIoXQGGlVztIY+oitJPpRQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fbjs/node_modules/promise": {
|
||||
"version": "7.3.1",
|
||||
"resolved": "https://registry.npmjs.org/promise/-/promise-7.3.1.tgz",
|
||||
"integrity": "sha512-nolQXZ/4L+bP/UGlkfaIujX9BKxGwmQ9OT4mOt5yvy8iK1h3wqTEJCijzGANTCCl9nWjY41juyAn2K3Q1hLLTg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"asap": "~2.0.3"
|
||||
}
|
||||
},
|
||||
"node_modules/fetch-nodeshim": {
|
||||
"version": "0.4.10",
|
||||
"resolved": "https://registry.npmjs.org/fetch-nodeshim/-/fetch-nodeshim-0.4.10.tgz",
|
||||
|
|
@ -4268,6 +4377,12 @@
|
|||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/hyphenate-style-name": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/hyphenate-style-name/-/hyphenate-style-name-1.1.0.tgz",
|
||||
"integrity": "sha512-WDC/ui2VVRrz3jOVi+XtjqkDjiVjTtFaAGiW37k6b+ohyQ5wYDOGkvCZa8+H0nx3gyvv0+BST9xuOgIyGQ00gw==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/ignore": {
|
||||
"version": "5.3.2",
|
||||
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
|
||||
|
|
@ -4307,6 +4422,15 @@
|
|||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/inline-style-prefixer": {
|
||||
"version": "7.0.1",
|
||||
"resolved": "https://registry.npmjs.org/inline-style-prefixer/-/inline-style-prefixer-7.0.1.tgz",
|
||||
"integrity": "sha512-lhYo5qNTQp3EvSSp3sRvXMbVQTLrvGV6DycRMJ5dm2BLMiJ30wpXKdDdgX+GmJZ5uQMucwRKHamXSst3Sj/Giw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"css-in-js-utils": "^3.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/invariant": {
|
||||
"version": "2.2.4",
|
||||
"resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz",
|
||||
|
|
@ -5416,6 +5540,26 @@
|
|||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/node-fetch": {
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
|
||||
"integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"whatwg-url": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "4.x || >=6.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"encoding": "^0.1.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"encoding": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/node-forge": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.4.0.tgz",
|
||||
|
|
@ -5473,6 +5617,15 @@
|
|||
"node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/object-assign": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/on-finished": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz",
|
||||
|
|
@ -5770,6 +5923,12 @@
|
|||
"node": "^10 || ^12 || >=14"
|
||||
}
|
||||
},
|
||||
"node_modules/postcss-value-parser": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz",
|
||||
"integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pretty-format": {
|
||||
"version": "29.7.0",
|
||||
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz",
|
||||
|
|
@ -5891,6 +6050,18 @@
|
|||
"ws": "^7"
|
||||
}
|
||||
},
|
||||
"node_modules/react-dom": {
|
||||
"version": "19.2.3",
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz",
|
||||
"integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"scheduler": "^0.27.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^19.2.3"
|
||||
}
|
||||
},
|
||||
"node_modules/react-fast-compare": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-3.2.2.tgz",
|
||||
|
|
@ -6055,6 +6226,64 @@
|
|||
"react-native": ">=0.82.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-native-web": {
|
||||
"version": "0.21.2",
|
||||
"resolved": "https://registry.npmjs.org/react-native-web/-/react-native-web-0.21.2.tgz",
|
||||
"integrity": "sha512-SO2t9/17zM4iEnFvlu2DA9jqNbzNhoUP+AItkoCOyFmDMOhUnBBznBDCYN92fGdfAkfQlWzPoez6+zLxFNsZEg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.18.6",
|
||||
"@react-native/normalize-colors": "^0.74.1",
|
||||
"fbjs": "^3.0.4",
|
||||
"inline-style-prefixer": "^7.0.1",
|
||||
"memoize-one": "^6.0.0",
|
||||
"nullthrows": "^1.1.1",
|
||||
"postcss-value-parser": "^4.2.0",
|
||||
"styleq": "^0.1.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-native-web/node_modules/@react-native/normalize-colors": {
|
||||
"version": "0.74.89",
|
||||
"resolved": "https://registry.npmjs.org/@react-native/normalize-colors/-/normalize-colors-0.74.89.tgz",
|
||||
"integrity": "sha512-qoMMXddVKVhZ8PA1AbUCk83trpd6N+1nF2A6k1i6LsQObyS92fELuk8kU/lQs6M7BsMHwqyLCpQJ1uFgNvIQXg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/react-native-web/node_modules/memoize-one": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz",
|
||||
"integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/react-native-worklets": {
|
||||
"version": "0.10.0",
|
||||
"resolved": "https://registry.npmjs.org/react-native-worklets/-/react-native-worklets-0.10.0.tgz",
|
||||
"integrity": "sha512-JhE6IxDf6iabC0qu3+TAKA4v9RlluXmoIngPQX7/QUByf75lfrsHZ6/dQhyjEWnp1EEQiwzz8Cpew140ZcewDw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/plugin-transform-arrow-functions": "^7.27.1",
|
||||
"@babel/plugin-transform-class-properties": "^7.28.6",
|
||||
"@babel/plugin-transform-classes": "^7.28.6",
|
||||
"@babel/plugin-transform-nullish-coalescing-operator": "^7.28.6",
|
||||
"@babel/plugin-transform-optional-chaining": "^7.28.6",
|
||||
"@babel/plugin-transform-shorthand-properties": "^7.27.1",
|
||||
"@babel/plugin-transform-template-literals": "^7.27.1",
|
||||
"@babel/plugin-transform-unicode-regex": "^7.27.1",
|
||||
"@babel/preset-typescript": "^7.28.5",
|
||||
"@babel/types": "^7.27.1",
|
||||
"convert-source-map": "^2.0.0",
|
||||
"semver": "^7.7.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@babel/core": "*",
|
||||
"@react-native/metro-config": "*",
|
||||
"react": "*",
|
||||
"react-native": "0.83 - 0.86"
|
||||
}
|
||||
},
|
||||
"node_modules/react-native/node_modules/commander": {
|
||||
"version": "12.1.0",
|
||||
"resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz",
|
||||
|
|
@ -6427,6 +6656,12 @@
|
|||
"integrity": "sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/setimmediate": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
|
||||
"integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/setprototypeof": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
|
||||
|
|
@ -6663,6 +6898,12 @@
|
|||
"integrity": "sha512-0MP/Cxx5SzeeZ10p/bZI0S6MpgD+yxAhi1BOQ34jgnMXsCq3j1t6tQnZu+KdlL7dvJTLT3g9xN8tl10TqgFMcg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/styleq": {
|
||||
"version": "0.1.3",
|
||||
"resolved": "https://registry.npmjs.org/styleq/-/styleq-0.1.3.tgz",
|
||||
"integrity": "sha512-3ZUifmCDCQanjeej1f6kyl/BeP/Vae5EYkQ9iJfUm/QwZvlgnZzyflqAsAWYURdtea8Vkvswu2GrC57h3qffcA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/supports-color": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
|
||||
|
|
@ -6824,6 +7065,12 @@
|
|||
"integrity": "sha512-FWAPzCIHZHnrE/5/w9MPk0kK25hSQSH2IKhYh9PyjS3SG/+IEMvlwIHbhz+oF7xl54I+ueZlVnMjyzdSwLmAwA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tr46": {
|
||||
"version": "0.0.3",
|
||||
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
|
||||
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tslib": {
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
|
|
@ -6853,6 +7100,32 @@
|
|||
"node": ">=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/ua-parser-js": {
|
||||
"version": "1.0.41",
|
||||
"resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-1.0.41.tgz",
|
||||
"integrity": "sha512-LbBDqdIC5s8iROCUjMbW1f5dJQTEFB1+KO9ogbvlb3nm9n4YHa5p4KTvFPWvh2Hs8gZMBuiB1/8+pdfe/tDPug==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/ua-parser-js"
|
||||
},
|
||||
{
|
||||
"type": "paypal",
|
||||
"url": "https://paypal.me/faisalman"
|
||||
},
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/faisalman"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"ua-parser-js": "script/cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "8.3.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz",
|
||||
|
|
@ -7070,12 +7343,28 @@
|
|||
"defaults": "^1.0.3"
|
||||
}
|
||||
},
|
||||
"node_modules/webidl-conversions": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
|
||||
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/whatwg-fetch": {
|
||||
"version": "3.6.20",
|
||||
"resolved": "https://registry.npmjs.org/whatwg-fetch/-/whatwg-fetch-3.6.20.tgz",
|
||||
"integrity": "sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/whatwg-url": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
|
||||
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tr46": "~0.0.3",
|
||||
"webidl-conversions": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/whatwg-url-minimum": {
|
||||
"version": "0.1.2",
|
||||
"resolved": "https://registry.npmjs.org/whatwg-url-minimum/-/whatwg-url-minimum-0.1.2.tgz",
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@
|
|||
"version": "1.0.0",
|
||||
"main": "expo-router/entry",
|
||||
"dependencies": {
|
||||
"@expo/metro-runtime": "~57.0.6",
|
||||
"@expo/vector-icons": "^15.0.2",
|
||||
"@react-native-async-storage/async-storage": "2.2.0",
|
||||
"expo": "~57.0.7",
|
||||
"expo-constants": "~57.0.6",
|
||||
|
|
@ -15,11 +17,14 @@
|
|||
"expo-status-bar": "~57.0.1",
|
||||
"expo-video": "~57.0.1",
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3",
|
||||
"react-native": "0.86.0",
|
||||
"react-native-gesture-handler": "~2.32.0",
|
||||
"react-native-reanimated": "4.5.0",
|
||||
"react-native-safe-area-context": "~5.7.0",
|
||||
"react-native-screens": "4.25.2"
|
||||
"react-native-screens": "4.25.2",
|
||||
"react-native-web": "^0.21.2",
|
||||
"react-native-worklets": "0.10.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "~19.2.2",
|
||||
|
|
|
|||
304
src/components/Player.tsx
Normal file
304
src/components/Player.tsx
Normal file
|
|
@ -0,0 +1,304 @@
|
|||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { ActivityIndicator, Pressable, StyleSheet, Switch, Text, View } from 'react-native'
|
||||
import { useEvent, useEventListener } from 'expo'
|
||||
import { VideoView, useVideoPlayer } from 'expo-video'
|
||||
import Ionicons from '@expo/vector-icons/Ionicons'
|
||||
import type { Episode, EpisodeSkip } from '../lib/types'
|
||||
import { colors, radius, spacing } from '../lib/theme'
|
||||
import { loadPlayerPrefs, savePlayerPrefs, PlayerPrefs } from '../lib/playerPrefs'
|
||||
|
||||
const QUALITIES = [
|
||||
{ key: 'hls_1080', label: '1080p' },
|
||||
{ key: 'hls_720', label: '720p' },
|
||||
{ key: 'hls_480', label: '480p' },
|
||||
] as const
|
||||
|
||||
type QualityKey = (typeof QUALITIES)[number]['key']
|
||||
|
||||
interface PlayerProps {
|
||||
episode: Episode
|
||||
resumeAt?: number
|
||||
onProgress?: (time: number) => void
|
||||
onEnded?: () => void
|
||||
}
|
||||
|
||||
export default function Player({ episode, resumeAt, onProgress, onEnded }: PlayerProps) {
|
||||
const available = QUALITIES.filter((q) => episode[q.key])
|
||||
const [quality, setQuality] = useState<QualityKey | undefined>(available[0]?.key)
|
||||
const [prefs, setPrefs] = useState<PlayerPrefs>({
|
||||
autoSkipOpening: false,
|
||||
autoSkipEnding: false,
|
||||
autoPlayNext: true,
|
||||
})
|
||||
const [showSettings, setShowSettings] = useState(false)
|
||||
const [showSkip, setShowSkip] = useState<'opening' | 'ending' | null>(null)
|
||||
const skippedRanges = useRef<Set<string>>(new Set())
|
||||
const resumeRef = useRef(resumeAt && resumeAt > 5 ? resumeAt : 0)
|
||||
|
||||
useEffect(() => {
|
||||
loadPlayerPrefs().then(setPrefs)
|
||||
}, [])
|
||||
|
||||
function updatePrefs(patch: Partial<PlayerPrefs>) {
|
||||
setPrefs((p) => {
|
||||
const next = { ...p, ...patch }
|
||||
savePlayerPrefs(next)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const source =
|
||||
quality && episode[quality] ? { uri: episode[quality] as string, contentType: 'hls' as const } : null
|
||||
|
||||
const player = useVideoPlayer(source, (p) => {
|
||||
p.timeUpdateEventInterval = 0.5
|
||||
p.play()
|
||||
})
|
||||
|
||||
useEventListener(player, 'sourceLoad', () => {
|
||||
if (resumeRef.current > 5) player.currentTime = resumeRef.current
|
||||
resumeRef.current = 0
|
||||
})
|
||||
|
||||
useEventListener(player, 'playToEnd', () => {
|
||||
reportProgress()
|
||||
onEnded?.()
|
||||
})
|
||||
|
||||
const { status } = useEvent(player, 'statusChange', { status: player.status })
|
||||
const { currentTime } = useEvent(player, 'timeUpdate', {
|
||||
currentTime: 0,
|
||||
currentLiveTimestamp: null,
|
||||
currentOffsetFromLive: null,
|
||||
bufferedPosition: 0,
|
||||
})
|
||||
|
||||
const reportProgress = useCallback(() => {
|
||||
if (player.currentTime > 3) onProgress?.(player.currentTime)
|
||||
}, [player, onProgress])
|
||||
|
||||
useEffect(() => {
|
||||
const id = setInterval(reportProgress, 10000)
|
||||
return () => clearInterval(id)
|
||||
}, [reportProgress])
|
||||
|
||||
useEffect(() => {
|
||||
function checkRange(range: EpisodeSkip | null | undefined, key: string, auto: boolean): boolean {
|
||||
if (!range || currentTime < range.start || currentTime >= range.stop) return false
|
||||
if (auto) {
|
||||
if (!skippedRanges.current.has(key)) {
|
||||
skippedRanges.current.add(key)
|
||||
player.currentTime = range.stop
|
||||
}
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
if (checkRange(episode.opening, 'opening', prefs.autoSkipOpening)) setShowSkip('opening')
|
||||
else if (checkRange(episode.ending, 'ending', prefs.autoSkipEnding)) setShowSkip('ending')
|
||||
else setShowSkip(null)
|
||||
}, [currentTime, episode.opening, episode.ending, prefs.autoSkipOpening, prefs.autoSkipEnding, player])
|
||||
|
||||
function skip() {
|
||||
if (showSkip === 'opening' && episode.opening) player.currentTime = episode.opening.stop
|
||||
if (showSkip === 'ending' && episode.ending) player.currentTime = episode.ending.stop
|
||||
setShowSkip(null)
|
||||
}
|
||||
|
||||
function selectQuality(key: QualityKey) {
|
||||
resumeRef.current = player.currentTime
|
||||
setQuality(key)
|
||||
setShowSettings(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.wrapper}>
|
||||
<VideoView
|
||||
style={styles.video}
|
||||
player={player}
|
||||
nativeControls
|
||||
contentFit="contain"
|
||||
fullscreenOptions={{ enable: true }}
|
||||
allowsPictureInPicture
|
||||
/>
|
||||
|
||||
{status === 'loading' && (
|
||||
<View style={styles.loading} pointerEvents="none">
|
||||
<ActivityIndicator size="large" color={colors.accent} />
|
||||
</View>
|
||||
)}
|
||||
|
||||
{showSkip && (
|
||||
<Pressable onPress={skip} style={styles.skipButton}>
|
||||
<Ionicons name="play-skip-forward" size={14} color={colors.text} />
|
||||
<Text style={styles.skipText}>
|
||||
Пропустить {showSkip === 'opening' ? 'опенинг' : 'эндинг'}
|
||||
</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
|
||||
<Pressable
|
||||
hitSlop={10}
|
||||
onPress={() => setShowSettings((v) => !v)}
|
||||
style={styles.gearButton}
|
||||
>
|
||||
<Ionicons name="settings-sharp" size={16} color={colors.text} />
|
||||
</Pressable>
|
||||
|
||||
{showSettings && (
|
||||
<>
|
||||
<Pressable style={StyleSheet.absoluteFill} onPress={() => setShowSettings(false)} />
|
||||
<View style={styles.settingsPanel}>
|
||||
{available.length > 1 && (
|
||||
<View style={styles.settingsRow}>
|
||||
<Text style={styles.settingsLabel}>Качество</Text>
|
||||
<View style={styles.qualityRow}>
|
||||
{available.map((q) => (
|
||||
<Pressable
|
||||
key={q.key}
|
||||
onPress={() => selectQuality(q.key)}
|
||||
style={[styles.qualityChip, quality === q.key && styles.qualityChipActive]}
|
||||
>
|
||||
<Text style={styles.qualityChipText}>{q.label}</Text>
|
||||
</Pressable>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View style={styles.divider} />
|
||||
|
||||
<ToggleRow
|
||||
label="Пропускать опенинг"
|
||||
value={prefs.autoSkipOpening}
|
||||
onChange={(v) => updatePrefs({ autoSkipOpening: v })}
|
||||
/>
|
||||
<ToggleRow
|
||||
label="Пропускать эндинг"
|
||||
value={prefs.autoSkipEnding}
|
||||
onChange={(v) => updatePrefs({ autoSkipEnding: v })}
|
||||
/>
|
||||
<ToggleRow
|
||||
label="Авто-следующая серия"
|
||||
value={prefs.autoPlayNext}
|
||||
onChange={(v) => updatePrefs({ autoPlayNext: v })}
|
||||
/>
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
function ToggleRow({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
label: string
|
||||
value: boolean
|
||||
onChange: (v: boolean) => void
|
||||
}) {
|
||||
return (
|
||||
<View style={styles.settingsRow}>
|
||||
<Text style={styles.settingsLabel}>{label}</Text>
|
||||
<Switch
|
||||
value={value}
|
||||
onValueChange={onChange}
|
||||
trackColor={{ false: 'rgba(255,255,255,0.15)', true: colors.accent }}
|
||||
thumbColor={colors.text}
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
wrapper: {
|
||||
width: '100%',
|
||||
aspectRatio: 16 / 9,
|
||||
backgroundColor: '#000',
|
||||
borderRadius: radius.md,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
video: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
},
|
||||
loading: {
|
||||
...StyleSheet.absoluteFill,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
skipButton: {
|
||||
position: 'absolute',
|
||||
bottom: spacing(16),
|
||||
right: spacing(3),
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing(1.5),
|
||||
backgroundColor: colors.overlay,
|
||||
paddingHorizontal: spacing(3),
|
||||
paddingVertical: spacing(2),
|
||||
borderRadius: radius.sm,
|
||||
},
|
||||
skipText: {
|
||||
color: colors.text,
|
||||
fontSize: 12,
|
||||
fontWeight: '600',
|
||||
},
|
||||
gearButton: {
|
||||
position: 'absolute',
|
||||
top: spacing(2),
|
||||
right: spacing(2),
|
||||
width: 30,
|
||||
height: 30,
|
||||
borderRadius: radius.full,
|
||||
backgroundColor: colors.overlay,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
settingsPanel: {
|
||||
position: 'absolute',
|
||||
top: spacing(9),
|
||||
right: spacing(2),
|
||||
width: 220,
|
||||
backgroundColor: colors.surfaceRaised,
|
||||
borderRadius: radius.md,
|
||||
padding: spacing(3),
|
||||
gap: spacing(2),
|
||||
},
|
||||
settingsRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: spacing(2),
|
||||
},
|
||||
settingsLabel: {
|
||||
color: colors.textDim,
|
||||
fontSize: 12,
|
||||
flexShrink: 1,
|
||||
},
|
||||
qualityRow: {
|
||||
flexDirection: 'row',
|
||||
gap: spacing(1),
|
||||
},
|
||||
qualityChip: {
|
||||
paddingHorizontal: spacing(2),
|
||||
paddingVertical: spacing(1),
|
||||
borderRadius: radius.sm,
|
||||
backgroundColor: 'rgba(255,255,255,0.1)',
|
||||
},
|
||||
qualityChipActive: {
|
||||
backgroundColor: colors.accent,
|
||||
},
|
||||
qualityChipText: {
|
||||
color: colors.text,
|
||||
fontSize: 11,
|
||||
fontWeight: '600',
|
||||
},
|
||||
divider: {
|
||||
height: StyleSheet.hairlineWidth,
|
||||
backgroundColor: colors.border,
|
||||
},
|
||||
})
|
||||
|
|
@ -50,9 +50,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||
async function logout() {
|
||||
try {
|
||||
await api.logout()
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
} catch {}
|
||||
await clearToken()
|
||||
setUser(null)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import type {
|
|||
CatalogFilters,
|
||||
CatalogResponse,
|
||||
CollectionType,
|
||||
Episode,
|
||||
Genre,
|
||||
HistoryItem,
|
||||
Release,
|
||||
|
|
@ -58,6 +59,9 @@ export const api = {
|
|||
|
||||
release: (idOrAlias: string | number) => req<Release>(`/anime/releases/${idOrAlias}`),
|
||||
|
||||
episode: (releaseEpisodeId: string) =>
|
||||
req<Episode & { release: Release }>(`/anime/releases/episodes/${releaseEpisodeId}`),
|
||||
|
||||
search: (query: string) =>
|
||||
req<Release[]>(`/app/search/releases?query=${encodeURIComponent(query)}`),
|
||||
|
||||
|
|
|
|||
|
|
@ -1,15 +1,25 @@
|
|||
import { Platform } from 'react-native'
|
||||
import * as SecureStore from 'expo-secure-store'
|
||||
|
||||
const KEY = 'anisav_token'
|
||||
|
||||
export async function getToken(): Promise<string | null> {
|
||||
if (Platform.OS === 'web') return localStorage.getItem(KEY)
|
||||
return SecureStore.getItemAsync(KEY)
|
||||
}
|
||||
|
||||
export async function setToken(token: string): Promise<void> {
|
||||
if (Platform.OS === 'web') {
|
||||
localStorage.setItem(KEY, token)
|
||||
return
|
||||
}
|
||||
await SecureStore.setItemAsync(KEY, token)
|
||||
}
|
||||
|
||||
export async function clearToken(): Promise<void> {
|
||||
if (Platform.OS === 'web') {
|
||||
localStorage.removeItem(KEY)
|
||||
return
|
||||
}
|
||||
await SecureStore.deleteItemAsync(KEY)
|
||||
}
|
||||
|
|
|
|||
41
src/lib/continueWatching.ts
Normal file
41
src/lib/continueWatching.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import { api } from './api'
|
||||
import { getAllLocalProgress } from './localProgress'
|
||||
import { getToken } from './auth'
|
||||
import type { Episode, Release } from './types'
|
||||
|
||||
export interface ContinueWatchingItem {
|
||||
release: Release
|
||||
episode: Episode
|
||||
time: number
|
||||
}
|
||||
|
||||
export async function getContinueWatching(limit = 12): Promise<ContinueWatchingItem[]> {
|
||||
const merged = new Map<string, { time: number; is_watched: boolean }>()
|
||||
|
||||
for (const [id, p] of Object.entries(await getAllLocalProgress())) merged.set(id, p)
|
||||
|
||||
const token = await getToken()
|
||||
if (token) {
|
||||
try {
|
||||
const remote = await api.timecodes()
|
||||
for (const t of remote) merged.set(t.release_episode_id, { time: t.time, is_watched: t.is_watched })
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const candidates = [...merged.entries()]
|
||||
.filter(([, p]) => !p.is_watched && p.time > 10)
|
||||
.slice(0, limit)
|
||||
|
||||
const resolved = await Promise.all(
|
||||
candidates.map(async ([id, p]) => {
|
||||
try {
|
||||
const { release, ...episode } = await api.episode(id)
|
||||
return { release, episode, time: p.time }
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
return resolved.filter((x): x is ContinueWatchingItem => x !== null)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue