feat: continue watching row, fix secure-store web crash, expo-doctor fixes

This commit is contained in:
ImSavsis 2026-07-19 06:25:02 +05:00
parent 144a2cd227
commit f68195c25c
11 changed files with 1390 additions and 38 deletions

View file

@ -26,11 +26,6 @@
"bundler": "metro",
"output": "static"
},
"splash": {
"image": "./assets/splash-icon.png",
"resizeMode": "contain",
"backgroundColor": "#121212"
},
"plugins": [
"expo-router",
"expo-status-bar",

View file

@ -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,

View file

@ -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
View 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
View file

@ -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",

View file

@ -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
View 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,
},
})

View file

@ -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)
}

View file

@ -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)}`),

View file

@ -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)
}

View 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)
}