From f68195c25c2a2954486dda2f368a2b3ce16a4c96 Mon Sep 17 00:00:00 2001 From: ImSavsis Date: Sun, 19 Jul 2026 06:25:02 +0500 Subject: [PATCH] feat: continue watching row, fix secure-store web crash, expo-doctor fixes --- app.json | 5 - app/(tabs)/index.tsx | 90 +++++- app/_layout.tsx | 13 +- app/title/[id].tsx | 609 ++++++++++++++++++++++++++++++++++++ package-lock.json | 341 ++++++++++++++++++-- package.json | 7 +- src/components/Player.tsx | 304 ++++++++++++++++++ src/lib/AuthContext.tsx | 4 +- src/lib/api.ts | 4 + src/lib/auth.ts | 10 + src/lib/continueWatching.ts | 41 +++ 11 files changed, 1390 insertions(+), 38 deletions(-) create mode 100644 app/title/[id].tsx create mode 100644 src/components/Player.tsx create mode 100644 src/lib/continueWatching.ts diff --git a/app.json b/app.json index f02eef7..14cac72 100644 --- a/app.json +++ b/app.json @@ -26,11 +26,6 @@ "bundler": "metro", "output": "static" }, - "splash": { - "image": "./assets/splash-icon.png", - "resizeMode": "contain", - "backgroundColor": "#121212" - }, "plugins": [ "expo-router", "expo-status-bar", diff --git a/app/(tabs)/index.tsx b/app/(tabs)/index.tsx index 58b7cc7..69f571d 100644 --- a/app/(tabs)/index.tsx +++ b/app/(tabs)/index.tsx @@ -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(null) const [recommended, setRecommended] = useState([]) const [today, setToday] = useState([]) + const [continueWatching, setContinueWatching] = useState([]) 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() { )} + {continueWatching.length > 0 && ( + + Продолжить просмотр + item.episode.id} + contentContainerStyle={{ paddingHorizontal: spacing(4), gap: spacing(3) }} + renderItem={({ item }) => ( + router.push(`/title/${item.release.alias || item.release.id}`)} + > + + + Серия {item.episode.ordinal} + {!!item.episode.duration && ( + + + + )} + + + )} + /> + + )} + s.release)} /> @@ -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, diff --git a/app/_layout.tsx b/app/_layout.tsx index 3ccc7d8..a876c1e 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -13,7 +13,18 @@ export default function RootLayout() { - + diff --git a/app/title/[id].tsx b/app/title/[id].tsx new file mode 100644 index 0000000..3753cf5 --- /dev/null +++ b/app/title/[id].tsx @@ -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 = { + 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(null) + const [error, setError] = useState(false) + const [selectedEp, setSelectedEp] = useState(null) + const [timecodes, setTimecodes] = useState>(new Map()) + const [saved, setSaved] = useState(false) + const [listOpen, setListOpen] = useState(false) + const [collectionType, setCollectionType] = useState(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( + 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 ( + + Тайтл не найден + + ) + } + + if (!release) return + + return ( + + + {poster && } + + {release.name.main} + {release.name.english && {release.name.english}} + + + {release.type?.description} + {release.year} + {release.season?.description} + {release.age_rating?.label} + {release.is_ongoing && Онгоинг} + {release.episodes_total != null && {release.episodes_total} эп.} + + + + {user && ( + setListOpen(true)} + disabled={busy} + style={[styles.listButton, collectionType && styles.listButtonActive]} + > + + {collectionType ? COLLECTION_LABELS[collectionType] : '+ В список'} + + + )} + {user && ( + + + + )} + + + + + + + + {!!release.genres?.length && ( + + {release.genres.map((g) => ( + {g.name} + ))} + + )} + + {release.description && {release.description}} + + {selectedEp && ( + + saveTimecode(selectedEp.id, t, false)} + onEnded={() => { + saveTimecode(selectedEp.id, 0, true) + goToNextEpisode() + }} + /> + + )} + + {!!release.episodes?.length && ( + + Серии + ep.id} + showsHorizontalScrollIndicator={false} + contentContainerStyle={styles.episodeList} + renderItem={({ item: ep }) => { + const tc = timecodes.get(ep.id) + const isSelected = selectedEp?.id === ep.id + return ( + setSelectedEp(ep)} + style={[styles.episodeButton, isSelected && styles.episodeButtonActive]} + > + + Серия {ep.ordinal} + {tc?.is_watched && ✓} + + {ep.name && ( + + {ep.name} + + )} + {tc && !tc.is_watched && ep.duration ? ( + + + + ) : null} + + ) + }} + /> + + )} + + {!!release.torrents?.length && ( + + Торренты + {release.torrents.map((t) => ( + + + + {t.label} + + + {formatBytes(t.size)} · {t.quality?.description} · {t.seeders} сидов + + + Linking.openURL(t.magnet)} style={styles.magnetButton}> + Magnet + + + ))} + + )} + + setListOpen(false)} + > + setListOpen(false)}> + + {(Object.keys(COLLECTION_LABELS) as CollectionType[]).map((type) => ( + pickCollection(type)} style={styles.actionSheetRow}> + + {COLLECTION_LABELS[type]} + + + ))} + {collectionType && ( + + Убрать из списка + + )} + setListOpen(false)} style={styles.actionSheetRow}> + Отмена + + + + + + ) +} + +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', + }, +}) diff --git a/package-lock.json b/package-lock.json index dce7d10..f22deb3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 1404145..3d24886 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/components/Player.tsx b/src/components/Player.tsx new file mode 100644 index 0000000..0fec1b5 --- /dev/null +++ b/src/components/Player.tsx @@ -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(available[0]?.key) + const [prefs, setPrefs] = useState({ + autoSkipOpening: false, + autoSkipEnding: false, + autoPlayNext: true, + }) + const [showSettings, setShowSettings] = useState(false) + const [showSkip, setShowSkip] = useState<'opening' | 'ending' | null>(null) + const skippedRanges = useRef>(new Set()) + const resumeRef = useRef(resumeAt && resumeAt > 5 ? resumeAt : 0) + + useEffect(() => { + loadPlayerPrefs().then(setPrefs) + }, []) + + function updatePrefs(patch: Partial) { + 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 ( + + + + {status === 'loading' && ( + + + + )} + + {showSkip && ( + + + + Пропустить {showSkip === 'opening' ? 'опенинг' : 'эндинг'} + + + )} + + setShowSettings((v) => !v)} + style={styles.gearButton} + > + + + + {showSettings && ( + <> + setShowSettings(false)} /> + + {available.length > 1 && ( + + Качество + + {available.map((q) => ( + selectQuality(q.key)} + style={[styles.qualityChip, quality === q.key && styles.qualityChipActive]} + > + {q.label} + + ))} + + + )} + + + + updatePrefs({ autoSkipOpening: v })} + /> + updatePrefs({ autoSkipEnding: v })} + /> + updatePrefs({ autoPlayNext: v })} + /> + + + )} + + ) +} + +function ToggleRow({ + label, + value, + onChange, +}: { + label: string + value: boolean + onChange: (v: boolean) => void +}) { + return ( + + {label} + + + ) +} + +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, + }, +}) diff --git a/src/lib/AuthContext.tsx b/src/lib/AuthContext.tsx index ff46f14..7351800 100644 --- a/src/lib/AuthContext.tsx +++ b/src/lib/AuthContext.tsx @@ -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) } diff --git a/src/lib/api.ts b/src/lib/api.ts index 0fc26bf..4d9e5d3 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -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(`/anime/releases/${idOrAlias}`), + episode: (releaseEpisodeId: string) => + req(`/anime/releases/episodes/${releaseEpisodeId}`), + search: (query: string) => req(`/app/search/releases?query=${encodeURIComponent(query)}`), diff --git a/src/lib/auth.ts b/src/lib/auth.ts index d87f0ab..37e1c70 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -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 { + if (Platform.OS === 'web') return localStorage.getItem(KEY) return SecureStore.getItemAsync(KEY) } export async function setToken(token: string): Promise { + if (Platform.OS === 'web') { + localStorage.setItem(KEY, token) + return + } await SecureStore.setItemAsync(KEY, token) } export async function clearToken(): Promise { + if (Platform.OS === 'web') { + localStorage.removeItem(KEY) + return + } await SecureStore.deleteItemAsync(KEY) } diff --git a/src/lib/continueWatching.ts b/src/lib/continueWatching.ts new file mode 100644 index 0000000..7c85774 --- /dev/null +++ b/src/lib/continueWatching.ts @@ -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 { + const merged = new Map() + + 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) +}