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', }, })