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",
|
"bundler": "metro",
|
||||||
"output": "static"
|
"output": "static"
|
||||||
},
|
},
|
||||||
"splash": {
|
|
||||||
"image": "./assets/splash-icon.png",
|
|
||||||
"resizeMode": "contain",
|
|
||||||
"backgroundColor": "#121212"
|
|
||||||
},
|
|
||||||
"plugins": [
|
"plugins": [
|
||||||
"expo-router",
|
"expo-router",
|
||||||
"expo-status-bar",
|
"expo-status-bar",
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
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 { useRouter } from 'expo-router'
|
||||||
import { useSafeAreaInsets } from 'react-native-safe-area-context'
|
import { useSafeAreaInsets } from 'react-native-safe-area-context'
|
||||||
import { api, imageUrl } from '../../src/lib/api'
|
import { api, imageUrl } from '../../src/lib/api'
|
||||||
|
import { getContinueWatching, ContinueWatchingItem } from '../../src/lib/continueWatching'
|
||||||
import type { Release, ScheduleDay } from '../../src/lib/types'
|
import type { Release, ScheduleDay } from '../../src/lib/types'
|
||||||
import { colors, radius, spacing } from '../../src/lib/theme'
|
import { colors, radius, spacing } from '../../src/lib/theme'
|
||||||
import Row from '../../src/components/Row'
|
import Row from '../../src/components/Row'
|
||||||
|
|
@ -15,20 +16,23 @@ export default function HomeScreen() {
|
||||||
const [latest, setLatest] = useState<Release[] | null>(null)
|
const [latest, setLatest] = useState<Release[] | null>(null)
|
||||||
const [recommended, setRecommended] = useState<Release[]>([])
|
const [recommended, setRecommended] = useState<Release[]>([])
|
||||||
const [today, setToday] = useState<ScheduleDay[]>([])
|
const [today, setToday] = useState<ScheduleDay[]>([])
|
||||||
|
const [continueWatching, setContinueWatching] = useState<ContinueWatchingItem[]>([])
|
||||||
const [refreshing, setRefreshing] = useState(false)
|
const [refreshing, setRefreshing] = useState(false)
|
||||||
const heroOpacity = useRef(new Animated.Value(0)).current
|
const heroOpacity = useRef(new Animated.Value(0)).current
|
||||||
const heroTranslate = useRef(new Animated.Value(16)).current
|
const heroTranslate = useRef(new Animated.Value(16)).current
|
||||||
const buttonScale = useRef(new Animated.Value(1)).current
|
const buttonScale = useRef(new Animated.Value(1)).current
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
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.latest(21).catch(() => [] as Release[]),
|
||||||
api.recommended(14).catch(() => [] as Release[]),
|
api.recommended(14).catch(() => [] as Release[]),
|
||||||
api.scheduleNow().catch(() => null),
|
api.scheduleNow().catch(() => null),
|
||||||
|
getContinueWatching().catch(() => [] as ContinueWatchingItem[]),
|
||||||
])
|
])
|
||||||
setLatest(latestRes)
|
setLatest(latestRes)
|
||||||
setRecommended(recommendedRes)
|
setRecommended(recommendedRes)
|
||||||
setToday(scheduleRes?.today?.filter((s) => s.release) ?? [])
|
setToday(scheduleRes?.today?.filter((s) => s.release) ?? [])
|
||||||
|
setContinueWatching(continueRes)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -114,6 +118,46 @@ export default function HomeScreen() {
|
||||||
</Animated.View>
|
</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={today.map((s) => s.release)} />
|
||||||
<Row title="Последние обновления" releases={latest} />
|
<Row title="Последние обновления" releases={latest} />
|
||||||
<Row title="Рекомендуем" releases={recommended} />
|
<Row title="Рекомендуем" releases={recommended} />
|
||||||
|
|
@ -128,6 +172,48 @@ const styles = StyleSheet.create({
|
||||||
flex: 1,
|
flex: 1,
|
||||||
backgroundColor: colors.background,
|
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: {
|
hero: {
|
||||||
width: '100%',
|
width: '100%',
|
||||||
aspectRatio: 3 / 4,
|
aspectRatio: 3 / 4,
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,18 @@ export default function RootLayout() {
|
||||||
<StatusBar style="light" />
|
<StatusBar style="light" />
|
||||||
<Stack screenOptions={{ headerShown: false, contentStyle: { backgroundColor: colors.background } }}>
|
<Stack screenOptions={{ headerShown: false, contentStyle: { backgroundColor: colors.background } }}>
|
||||||
<Stack.Screen name="(tabs)" />
|
<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.Screen name="login" options={{ presentation: 'modal' }} />
|
||||||
</Stack>
|
</Stack>
|
||||||
</AuthProvider>
|
</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",
|
"name": "anisav-mobile",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@expo/metro-runtime": "~57.0.6",
|
||||||
|
"@expo/vector-icons": "^15.0.2",
|
||||||
"@react-native-async-storage/async-storage": "2.2.0",
|
"@react-native-async-storage/async-storage": "2.2.0",
|
||||||
"expo": "~57.0.7",
|
"expo": "~57.0.7",
|
||||||
"expo-constants": "~57.0.6",
|
"expo-constants": "~57.0.6",
|
||||||
|
|
@ -20,11 +22,14 @@
|
||||||
"expo-status-bar": "~57.0.1",
|
"expo-status-bar": "~57.0.1",
|
||||||
"expo-video": "~57.0.1",
|
"expo-video": "~57.0.1",
|
||||||
"react": "19.2.3",
|
"react": "19.2.3",
|
||||||
|
"react-dom": "19.2.3",
|
||||||
"react-native": "0.86.0",
|
"react-native": "0.86.0",
|
||||||
"react-native-gesture-handler": "~2.32.0",
|
"react-native-gesture-handler": "~2.32.0",
|
||||||
"react-native-reanimated": "4.5.0",
|
"react-native-reanimated": "4.5.0",
|
||||||
"react-native-safe-area-context": "~5.7.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": {
|
"devDependencies": {
|
||||||
"@types/react": "~19.2.2",
|
"@types/react": "~19.2.2",
|
||||||
|
|
@ -556,6 +561,21 @@
|
||||||
"@babel/core": "^7.0.0-0"
|
"@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": {
|
"node_modules/@babel/plugin-transform-async-generator-functions": {
|
||||||
"version": "7.29.7",
|
"version": "7.29.7",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-generator-functions/-/plugin-transform-async-generator-functions-7.29.7.tgz",
|
"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"
|
"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": {
|
"node_modules/@babel/plugin-transform-typescript": {
|
||||||
"version": "7.29.7",
|
"version": "7.29.7",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.29.7.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.29.7.tgz",
|
||||||
|
|
@ -1344,6 +1394,31 @@
|
||||||
"walker": "^1.0.8"
|
"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": {
|
"node_modules/@expo/osascript": {
|
||||||
"version": "2.7.1",
|
"version": "2.7.1",
|
||||||
"resolved": "https://registry.npmjs.org/@expo/osascript/-/osascript-2.7.1.tgz",
|
"resolved": "https://registry.npmjs.org/@expo/osascript/-/osascript-2.7.1.tgz",
|
||||||
|
|
@ -1448,6 +1523,17 @@
|
||||||
"integrity": "sha512-HHQigo3rQWKMDzYDLkubN5WQOYXJJE2eNqIQC2axC2iO3mHdwnIR7FgZVvHWtBwAdzBgAP0ECp8KqS8TiMKvgw==",
|
"integrity": "sha512-HHQigo3rQWKMDzYDLkubN5WQOYXJJE2eNqIQC2axC2iO3mHdwnIR7FgZVvHWtBwAdzBgAP0ECp8KqS8TiMKvgw==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@expo/xcpretty": {
|
||||||
"version": "4.4.4",
|
"version": "4.4.4",
|
||||||
"resolved": "https://registry.npmjs.org/@expo/xcpretty/-/xcpretty-4.4.4.tgz",
|
"resolved": "https://registry.npmjs.org/@expo/xcpretty/-/xcpretty-4.4.4.tgz",
|
||||||
|
|
@ -2983,6 +3069,15 @@
|
||||||
"url": "https://opencollective.com/core-js"
|
"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": {
|
"node_modules/cross-spawn": {
|
||||||
"version": "7.0.6",
|
"version": "7.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||||
|
|
@ -2997,6 +3092,15 @@
|
||||||
"node": ">= 8"
|
"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": {
|
"node_modules/css.escape": {
|
||||||
"version": "1.5.1",
|
"version": "1.5.1",
|
||||||
"resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz",
|
"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": {
|
"node_modules/expo-router/node_modules/@expo/ui": {
|
||||||
"version": "57.0.7",
|
"version": "57.0.7",
|
||||||
"resolved": "https://registry.npmjs.org/@expo/ui/-/ui-57.0.7.tgz",
|
"resolved": "https://registry.npmjs.org/@expo/ui/-/ui-57.0.7.tgz",
|
||||||
|
|
@ -4002,6 +4081,36 @@
|
||||||
"bser": "2.1.1"
|
"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": {
|
"node_modules/fetch-nodeshim": {
|
||||||
"version": "0.4.10",
|
"version": "0.4.10",
|
||||||
"resolved": "https://registry.npmjs.org/fetch-nodeshim/-/fetch-nodeshim-0.4.10.tgz",
|
"resolved": "https://registry.npmjs.org/fetch-nodeshim/-/fetch-nodeshim-0.4.10.tgz",
|
||||||
|
|
@ -4268,6 +4377,12 @@
|
||||||
"node": ">= 14"
|
"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": {
|
"node_modules/ignore": {
|
||||||
"version": "5.3.2",
|
"version": "5.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
|
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
|
||||||
|
|
@ -4307,6 +4422,15 @@
|
||||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||||
"license": "ISC"
|
"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": {
|
"node_modules/invariant": {
|
||||||
"version": "2.2.4",
|
"version": "2.2.4",
|
||||||
"resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz",
|
||||||
|
|
@ -5416,6 +5540,26 @@
|
||||||
"node": ">= 0.6"
|
"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": {
|
"node_modules/node-forge": {
|
||||||
"version": "1.4.0",
|
"version": "1.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.4.0.tgz",
|
"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": "^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": {
|
"node_modules/on-finished": {
|
||||||
"version": "2.3.0",
|
"version": "2.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz",
|
||||||
|
|
@ -5770,6 +5923,12 @@
|
||||||
"node": "^10 || ^12 || >=14"
|
"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": {
|
"node_modules/pretty-format": {
|
||||||
"version": "29.7.0",
|
"version": "29.7.0",
|
||||||
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz",
|
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz",
|
||||||
|
|
@ -5891,6 +6050,18 @@
|
||||||
"ws": "^7"
|
"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": {
|
"node_modules/react-fast-compare": {
|
||||||
"version": "3.2.2",
|
"version": "3.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-3.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-3.2.2.tgz",
|
||||||
|
|
@ -6055,6 +6226,64 @@
|
||||||
"react-native": ">=0.82.0"
|
"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": {
|
"node_modules/react-native/node_modules/commander": {
|
||||||
"version": "12.1.0",
|
"version": "12.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz",
|
||||||
|
|
@ -6427,6 +6656,12 @@
|
||||||
"integrity": "sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA==",
|
"integrity": "sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/setprototypeof": {
|
||||||
"version": "1.2.0",
|
"version": "1.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
|
||||||
|
|
@ -6663,6 +6898,12 @@
|
||||||
"integrity": "sha512-0MP/Cxx5SzeeZ10p/bZI0S6MpgD+yxAhi1BOQ34jgnMXsCq3j1t6tQnZu+KdlL7dvJTLT3g9xN8tl10TqgFMcg==",
|
"integrity": "sha512-0MP/Cxx5SzeeZ10p/bZI0S6MpgD+yxAhi1BOQ34jgnMXsCq3j1t6tQnZu+KdlL7dvJTLT3g9xN8tl10TqgFMcg==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/supports-color": {
|
||||||
"version": "7.2.0",
|
"version": "7.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
|
"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==",
|
"integrity": "sha512-FWAPzCIHZHnrE/5/w9MPk0kK25hSQSH2IKhYh9PyjS3SG/+IEMvlwIHbhz+oF7xl54I+ueZlVnMjyzdSwLmAwA==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/tslib": {
|
||||||
"version": "2.8.1",
|
"version": "2.8.1",
|
||||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||||
|
|
@ -6853,6 +7100,32 @@
|
||||||
"node": ">=14.17"
|
"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": {
|
"node_modules/undici-types": {
|
||||||
"version": "8.3.0",
|
"version": "8.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz",
|
||||||
|
|
@ -7070,12 +7343,28 @@
|
||||||
"defaults": "^1.0.3"
|
"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": {
|
"node_modules/whatwg-fetch": {
|
||||||
"version": "3.6.20",
|
"version": "3.6.20",
|
||||||
"resolved": "https://registry.npmjs.org/whatwg-fetch/-/whatwg-fetch-3.6.20.tgz",
|
"resolved": "https://registry.npmjs.org/whatwg-fetch/-/whatwg-fetch-3.6.20.tgz",
|
||||||
"integrity": "sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg==",
|
"integrity": "sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/whatwg-url-minimum": {
|
||||||
"version": "0.1.2",
|
"version": "0.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/whatwg-url-minimum/-/whatwg-url-minimum-0.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/whatwg-url-minimum/-/whatwg-url-minimum-0.1.2.tgz",
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,8 @@
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"main": "expo-router/entry",
|
"main": "expo-router/entry",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@expo/metro-runtime": "~57.0.6",
|
||||||
|
"@expo/vector-icons": "^15.0.2",
|
||||||
"@react-native-async-storage/async-storage": "2.2.0",
|
"@react-native-async-storage/async-storage": "2.2.0",
|
||||||
"expo": "~57.0.7",
|
"expo": "~57.0.7",
|
||||||
"expo-constants": "~57.0.6",
|
"expo-constants": "~57.0.6",
|
||||||
|
|
@ -15,11 +17,14 @@
|
||||||
"expo-status-bar": "~57.0.1",
|
"expo-status-bar": "~57.0.1",
|
||||||
"expo-video": "~57.0.1",
|
"expo-video": "~57.0.1",
|
||||||
"react": "19.2.3",
|
"react": "19.2.3",
|
||||||
|
"react-dom": "19.2.3",
|
||||||
"react-native": "0.86.0",
|
"react-native": "0.86.0",
|
||||||
"react-native-gesture-handler": "~2.32.0",
|
"react-native-gesture-handler": "~2.32.0",
|
||||||
"react-native-reanimated": "4.5.0",
|
"react-native-reanimated": "4.5.0",
|
||||||
"react-native-safe-area-context": "~5.7.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": {
|
"devDependencies": {
|
||||||
"@types/react": "~19.2.2",
|
"@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() {
|
async function logout() {
|
||||||
try {
|
try {
|
||||||
await api.logout()
|
await api.logout()
|
||||||
} catch {
|
} catch {}
|
||||||
// best-effort
|
|
||||||
}
|
|
||||||
await clearToken()
|
await clearToken()
|
||||||
setUser(null)
|
setUser(null)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import type {
|
||||||
CatalogFilters,
|
CatalogFilters,
|
||||||
CatalogResponse,
|
CatalogResponse,
|
||||||
CollectionType,
|
CollectionType,
|
||||||
|
Episode,
|
||||||
Genre,
|
Genre,
|
||||||
HistoryItem,
|
HistoryItem,
|
||||||
Release,
|
Release,
|
||||||
|
|
@ -58,6 +59,9 @@ export const api = {
|
||||||
|
|
||||||
release: (idOrAlias: string | number) => req<Release>(`/anime/releases/${idOrAlias}`),
|
release: (idOrAlias: string | number) => req<Release>(`/anime/releases/${idOrAlias}`),
|
||||||
|
|
||||||
|
episode: (releaseEpisodeId: string) =>
|
||||||
|
req<Episode & { release: Release }>(`/anime/releases/episodes/${releaseEpisodeId}`),
|
||||||
|
|
||||||
search: (query: string) =>
|
search: (query: string) =>
|
||||||
req<Release[]>(`/app/search/releases?query=${encodeURIComponent(query)}`),
|
req<Release[]>(`/app/search/releases?query=${encodeURIComponent(query)}`),
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,25 @@
|
||||||
|
import { Platform } from 'react-native'
|
||||||
import * as SecureStore from 'expo-secure-store'
|
import * as SecureStore from 'expo-secure-store'
|
||||||
|
|
||||||
const KEY = 'anisav_token'
|
const KEY = 'anisav_token'
|
||||||
|
|
||||||
export async function getToken(): Promise<string | null> {
|
export async function getToken(): Promise<string | null> {
|
||||||
|
if (Platform.OS === 'web') return localStorage.getItem(KEY)
|
||||||
return SecureStore.getItemAsync(KEY)
|
return SecureStore.getItemAsync(KEY)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function setToken(token: string): Promise<void> {
|
export async function setToken(token: string): Promise<void> {
|
||||||
|
if (Platform.OS === 'web') {
|
||||||
|
localStorage.setItem(KEY, token)
|
||||||
|
return
|
||||||
|
}
|
||||||
await SecureStore.setItemAsync(KEY, token)
|
await SecureStore.setItemAsync(KEY, token)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function clearToken(): Promise<void> {
|
export async function clearToken(): Promise<void> {
|
||||||
|
if (Platform.OS === 'web') {
|
||||||
|
localStorage.removeItem(KEY)
|
||||||
|
return
|
||||||
|
}
|
||||||
await SecureStore.deleteItemAsync(KEY)
|
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