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
|
|
@ -1,8 +1,9 @@
|
|||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { Animated, Image, Pressable, RefreshControl, ScrollView, StyleSheet, Text, View } from 'react-native'
|
||||
import { Animated, FlatList, Image, Pressable, RefreshControl, ScrollView, StyleSheet, Text, View } from 'react-native'
|
||||
import { useRouter } from 'expo-router'
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context'
|
||||
import { api, imageUrl } from '../../src/lib/api'
|
||||
import { getContinueWatching, ContinueWatchingItem } from '../../src/lib/continueWatching'
|
||||
import type { Release, ScheduleDay } from '../../src/lib/types'
|
||||
import { colors, radius, spacing } from '../../src/lib/theme'
|
||||
import Row from '../../src/components/Row'
|
||||
|
|
@ -15,20 +16,23 @@ export default function HomeScreen() {
|
|||
const [latest, setLatest] = useState<Release[] | null>(null)
|
||||
const [recommended, setRecommended] = useState<Release[]>([])
|
||||
const [today, setToday] = useState<ScheduleDay[]>([])
|
||||
const [continueWatching, setContinueWatching] = useState<ContinueWatchingItem[]>([])
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
const heroOpacity = useRef(new Animated.Value(0)).current
|
||||
const heroTranslate = useRef(new Animated.Value(16)).current
|
||||
const buttonScale = useRef(new Animated.Value(1)).current
|
||||
|
||||
const load = useCallback(async () => {
|
||||
const [latestRes, recommendedRes, scheduleRes] = await Promise.all([
|
||||
const [latestRes, recommendedRes, scheduleRes, continueRes] = await Promise.all([
|
||||
api.latest(21).catch(() => [] as Release[]),
|
||||
api.recommended(14).catch(() => [] as Release[]),
|
||||
api.scheduleNow().catch(() => null),
|
||||
getContinueWatching().catch(() => [] as ContinueWatchingItem[]),
|
||||
])
|
||||
setLatest(latestRes)
|
||||
setRecommended(recommendedRes)
|
||||
setToday(scheduleRes?.today?.filter((s) => s.release) ?? [])
|
||||
setContinueWatching(continueRes)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -114,6 +118,46 @@ export default function HomeScreen() {
|
|||
</Animated.View>
|
||||
)}
|
||||
|
||||
{continueWatching.length > 0 && (
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>Продолжить просмотр</Text>
|
||||
<FlatList
|
||||
data={continueWatching}
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
keyExtractor={(item) => item.episode.id}
|
||||
contentContainerStyle={{ paddingHorizontal: spacing(4), gap: spacing(3) }}
|
||||
renderItem={({ item }) => (
|
||||
<Pressable
|
||||
style={styles.continueCard}
|
||||
onPress={() => router.push(`/title/${item.release.alias || item.release.id}`)}
|
||||
>
|
||||
<Image
|
||||
source={{
|
||||
uri: imageUrl(item.release.poster?.optimized?.preview || item.release.poster?.preview),
|
||||
}}
|
||||
style={styles.continueImage}
|
||||
resizeMode="cover"
|
||||
/>
|
||||
<View style={styles.continueOverlay}>
|
||||
<Text style={styles.continueEpisode}>Серия {item.episode.ordinal}</Text>
|
||||
{!!item.episode.duration && (
|
||||
<View style={styles.continueBarTrack}>
|
||||
<View
|
||||
style={[
|
||||
styles.continueBarFill,
|
||||
{ width: `${Math.min(100, (item.time / item.episode.duration) * 100)}%` },
|
||||
]}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</Pressable>
|
||||
)}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<Row title="Онгоинги сегодня" releases={today.map((s) => s.release)} />
|
||||
<Row title="Последние обновления" releases={latest} />
|
||||
<Row title="Рекомендуем" releases={recommended} />
|
||||
|
|
@ -128,6 +172,48 @@ const styles = StyleSheet.create({
|
|||
flex: 1,
|
||||
backgroundColor: colors.background,
|
||||
},
|
||||
section: {
|
||||
marginTop: spacing(5),
|
||||
},
|
||||
sectionTitle: {
|
||||
color: colors.text,
|
||||
fontSize: 17,
|
||||
fontWeight: '700',
|
||||
marginBottom: spacing(3),
|
||||
paddingHorizontal: spacing(4),
|
||||
},
|
||||
continueCard: {
|
||||
width: 110,
|
||||
},
|
||||
continueImage: {
|
||||
width: 110,
|
||||
height: 165,
|
||||
borderRadius: radius.md,
|
||||
backgroundColor: colors.surfaceCard,
|
||||
},
|
||||
continueOverlay: {
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
padding: spacing(2),
|
||||
},
|
||||
continueEpisode: {
|
||||
color: colors.text,
|
||||
fontSize: 11,
|
||||
fontWeight: '600',
|
||||
marginBottom: spacing(1),
|
||||
},
|
||||
continueBarTrack: {
|
||||
height: 3,
|
||||
borderRadius: 2,
|
||||
backgroundColor: 'rgba(0,0,0,0.4)',
|
||||
overflow: 'hidden',
|
||||
},
|
||||
continueBarFill: {
|
||||
height: '100%',
|
||||
backgroundColor: colors.accent,
|
||||
},
|
||||
hero: {
|
||||
width: '100%',
|
||||
aspectRatio: 3 / 4,
|
||||
|
|
|
|||
|
|
@ -13,7 +13,18 @@ export default function RootLayout() {
|
|||
<StatusBar style="light" />
|
||||
<Stack screenOptions={{ headerShown: false, contentStyle: { backgroundColor: colors.background } }}>
|
||||
<Stack.Screen name="(tabs)" />
|
||||
<Stack.Screen name="title/[id]" options={{ presentation: 'card' }} />
|
||||
<Stack.Screen
|
||||
name="title/[id]"
|
||||
options={{
|
||||
presentation: 'card',
|
||||
headerShown: true,
|
||||
headerTitle: '',
|
||||
headerBackTitle: '',
|
||||
headerStyle: { backgroundColor: colors.background },
|
||||
headerTintColor: colors.text,
|
||||
headerShadowVisible: false,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen name="login" options={{ presentation: 'modal' }} />
|
||||
</Stack>
|
||||
</AuthProvider>
|
||||
|
|
|
|||
609
app/title/[id].tsx
Normal file
609
app/title/[id].tsx
Normal file
|
|
@ -0,0 +1,609 @@
|
|||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import {
|
||||
FlatList,
|
||||
Image,
|
||||
Linking,
|
||||
Modal,
|
||||
Pressable,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Text,
|
||||
View,
|
||||
} from 'react-native'
|
||||
import { useLocalSearchParams } from 'expo-router'
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context'
|
||||
import Ionicons from '@expo/vector-icons/Ionicons'
|
||||
import { api, imageUrl } from '../../src/lib/api'
|
||||
import type { CollectionType, Episode, Release, TimecodeItem } from '../../src/lib/types'
|
||||
import { useAuth } from '../../src/lib/AuthContext'
|
||||
import { getAllLocalProgress, saveLocalProgress } from '../../src/lib/localProgress'
|
||||
import { isInWishlist, toggleWishlist } from '../../src/lib/wishlist'
|
||||
import { colors, radius, spacing } from '../../src/lib/theme'
|
||||
import Loader from '../../src/components/Loader'
|
||||
import Chip from '../../src/components/Chip'
|
||||
import Player from '../../src/components/Player'
|
||||
|
||||
const COLLECTION_LABELS: Record<CollectionType, string> = {
|
||||
WATCHING: 'Смотрю',
|
||||
PLANNED: 'Запланировано',
|
||||
WATCHED: 'Просмотрено',
|
||||
POSTPONED: 'Отложено',
|
||||
ABANDONED: 'Брошено',
|
||||
}
|
||||
|
||||
function formatBytes(n: number): string {
|
||||
const units = ['Б', 'КБ', 'МБ', 'ГБ']
|
||||
let i = 0
|
||||
let v = n
|
||||
while (v >= 1024 && i < units.length - 1) {
|
||||
v /= 1024
|
||||
i++
|
||||
}
|
||||
return `${v.toFixed(1)} ${units[i]}`
|
||||
}
|
||||
|
||||
function extractCollectionType(items: unknown, releaseId: number): CollectionType | null {
|
||||
if (!Array.isArray(items)) return null
|
||||
for (const item of items) {
|
||||
if (item && typeof item === 'object' && 'release_id' in item && 'type_of_collection' in item) {
|
||||
const o = item as { release_id: number; type_of_collection: CollectionType }
|
||||
if (o.release_id === releaseId) return o.type_of_collection
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function extractIsFavorite(items: unknown, releaseId: number): boolean {
|
||||
if (!Array.isArray(items)) return false
|
||||
return items.some((item) => {
|
||||
if (typeof item === 'number') return item === releaseId
|
||||
if (item && typeof item === 'object' && 'release_id' in item) {
|
||||
return (item as { release_id: number }).release_id === releaseId
|
||||
}
|
||||
return false
|
||||
})
|
||||
}
|
||||
|
||||
export default function TitleDetailScreen() {
|
||||
const { id } = useLocalSearchParams<{ id: string }>()
|
||||
const { user } = useAuth()
|
||||
const insets = useSafeAreaInsets()
|
||||
|
||||
const [release, setRelease] = useState<Release | null>(null)
|
||||
const [error, setError] = useState(false)
|
||||
const [selectedEp, setSelectedEp] = useState<Episode | null>(null)
|
||||
const [timecodes, setTimecodes] = useState<Map<string, TimecodeItem>>(new Map())
|
||||
const [saved, setSaved] = useState(false)
|
||||
const [listOpen, setListOpen] = useState(false)
|
||||
const [collectionType, setCollectionType] = useState<CollectionType | null>(null)
|
||||
const [isFavorite, setIsFavorite] = useState(false)
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return
|
||||
setRelease(null)
|
||||
setError(false)
|
||||
setSelectedEp(null)
|
||||
api
|
||||
.release(id)
|
||||
.then((r) => {
|
||||
setRelease(r)
|
||||
setSelectedEp(r.episodes?.[0] ?? null)
|
||||
})
|
||||
.catch(() => setError(true))
|
||||
}, [id])
|
||||
|
||||
useEffect(() => {
|
||||
if (!release) return
|
||||
isInWishlist(release.id).then(setSaved)
|
||||
}, [release])
|
||||
|
||||
useEffect(() => {
|
||||
getAllLocalProgress().then((local) => {
|
||||
const merged = new Map<string, TimecodeItem>(
|
||||
Object.entries(local).map(([epId, p]) => [
|
||||
epId,
|
||||
{ release_episode_id: epId, time: p.time, is_watched: p.is_watched },
|
||||
])
|
||||
)
|
||||
setTimecodes(merged)
|
||||
})
|
||||
|
||||
if (!user) return
|
||||
api
|
||||
.timecodes()
|
||||
.then((items) => {
|
||||
setTimecodes((prev) => {
|
||||
const next = new Map(prev)
|
||||
for (const i of items) next.set(i.release_episode_id, i)
|
||||
return next
|
||||
})
|
||||
})
|
||||
.catch(() => {})
|
||||
}, [user, id])
|
||||
|
||||
useEffect(() => {
|
||||
if (!user || !release) return
|
||||
api
|
||||
.collectionIds()
|
||||
.then((ids) => setCollectionType(extractCollectionType(ids, release.id)))
|
||||
.catch(() => {})
|
||||
api
|
||||
.favoriteIds()
|
||||
.then((ids) => setIsFavorite(extractIsFavorite(ids, release.id)))
|
||||
.catch(() => {})
|
||||
}, [user, release])
|
||||
|
||||
const poster = useMemo(
|
||||
() => imageUrl(release?.poster?.optimized?.preview || release?.poster?.preview),
|
||||
[release]
|
||||
)
|
||||
|
||||
const saveTimecode = useCallback(
|
||||
(episodeId: string, time: number, isWatched: boolean) => {
|
||||
saveLocalProgress(episodeId, time, isWatched)
|
||||
if (user) api.timecodeSave(episodeId, time, isWatched).catch(() => {})
|
||||
setTimecodes((prev) => {
|
||||
const next = new Map(prev)
|
||||
next.set(episodeId, { release_episode_id: episodeId, time, is_watched: isWatched })
|
||||
return next
|
||||
})
|
||||
},
|
||||
[user]
|
||||
)
|
||||
|
||||
function goToNextEpisode() {
|
||||
if (!release?.episodes || !selectedEp) return
|
||||
const idx = release.episodes.findIndex((e) => e.id === selectedEp.id)
|
||||
const next = release.episodes[idx + 1]
|
||||
if (next) setSelectedEp(next)
|
||||
}
|
||||
|
||||
async function handleToggleWishlist() {
|
||||
if (!release) return
|
||||
const nowSaved = await toggleWishlist(release)
|
||||
setSaved(nowSaved)
|
||||
}
|
||||
|
||||
async function pickCollection(type: CollectionType) {
|
||||
if (!release) return
|
||||
setBusy(true)
|
||||
try {
|
||||
await api.collectionAdd(release.id, type)
|
||||
setCollectionType(type)
|
||||
} finally {
|
||||
setBusy(false)
|
||||
setListOpen(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function removeFromList() {
|
||||
if (!release) return
|
||||
setBusy(true)
|
||||
try {
|
||||
await api.collectionRemove(release.id)
|
||||
setCollectionType(null)
|
||||
} finally {
|
||||
setBusy(false)
|
||||
setListOpen(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleFavorite() {
|
||||
if (!release) return
|
||||
setBusy(true)
|
||||
try {
|
||||
if (isFavorite) await api.favoriteRemove(release.id)
|
||||
else await api.favoriteAdd(release.id)
|
||||
setIsFavorite(!isFavorite)
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<View style={styles.centered}>
|
||||
<Text style={styles.errorText}>Тайтл не найден</Text>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
if (!release) return <Loader label="Загружаем..." />
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
style={styles.screen}
|
||||
contentContainerStyle={{ paddingBottom: insets.bottom + spacing(10) }}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
<View style={styles.header}>
|
||||
{poster && <Image source={{ uri: poster }} style={styles.poster} resizeMode="cover" />}
|
||||
<View style={styles.headerInfo}>
|
||||
<Text style={styles.title}>{release.name.main}</Text>
|
||||
{release.name.english && <Text style={styles.englishTitle}>{release.name.english}</Text>}
|
||||
|
||||
<View style={styles.chipRow}>
|
||||
<Chip>{release.type?.description}</Chip>
|
||||
<Chip>{release.year}</Chip>
|
||||
<Chip>{release.season?.description}</Chip>
|
||||
<Chip>{release.age_rating?.label}</Chip>
|
||||
{release.is_ongoing && <Chip accent>Онгоинг</Chip>}
|
||||
{release.episodes_total != null && <Chip>{release.episodes_total} эп.</Chip>}
|
||||
</View>
|
||||
|
||||
<View style={styles.actionsRow}>
|
||||
{user && (
|
||||
<Pressable
|
||||
onPress={() => setListOpen(true)}
|
||||
disabled={busy}
|
||||
style={[styles.listButton, collectionType && styles.listButtonActive]}
|
||||
>
|
||||
<Text style={styles.listButtonText}>
|
||||
{collectionType ? COLLECTION_LABELS[collectionType] : '+ В список'}
|
||||
</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
{user && (
|
||||
<Pressable onPress={toggleFavorite} disabled={busy} style={styles.iconButton}>
|
||||
<Ionicons
|
||||
name={isFavorite ? 'heart' : 'heart-outline'}
|
||||
size={18}
|
||||
color={isFavorite ? colors.accent : colors.textDim}
|
||||
/>
|
||||
</Pressable>
|
||||
)}
|
||||
<Pressable onPress={handleToggleWishlist} style={styles.iconButton}>
|
||||
<Ionicons
|
||||
name={saved ? 'star' : 'star-outline'}
|
||||
size={18}
|
||||
color={saved ? colors.accent : colors.textDim}
|
||||
/>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{!!release.genres?.length && (
|
||||
<View style={styles.genreRow}>
|
||||
{release.genres.map((g) => (
|
||||
<Chip key={g.id}>{g.name}</Chip>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{release.description && <Text style={styles.description}>{release.description}</Text>}
|
||||
|
||||
{selectedEp && (
|
||||
<View style={styles.playerWrap}>
|
||||
<Player
|
||||
key={selectedEp.id}
|
||||
episode={selectedEp}
|
||||
resumeAt={timecodes.get(selectedEp.id)?.time}
|
||||
onProgress={(t) => saveTimecode(selectedEp.id, t, false)}
|
||||
onEnded={() => {
|
||||
saveTimecode(selectedEp.id, 0, true)
|
||||
goToNextEpisode()
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{!!release.episodes?.length && (
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>Серии</Text>
|
||||
<FlatList
|
||||
horizontal
|
||||
data={release.episodes}
|
||||
keyExtractor={(ep) => ep.id}
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={styles.episodeList}
|
||||
renderItem={({ item: ep }) => {
|
||||
const tc = timecodes.get(ep.id)
|
||||
const isSelected = selectedEp?.id === ep.id
|
||||
return (
|
||||
<Pressable
|
||||
onPress={() => setSelectedEp(ep)}
|
||||
style={[styles.episodeButton, isSelected && styles.episodeButtonActive]}
|
||||
>
|
||||
<View style={styles.episodeRow}>
|
||||
<Text style={styles.episodeLabel}>Серия {ep.ordinal}</Text>
|
||||
{tc?.is_watched && <Text style={styles.episodeCheck}>✓</Text>}
|
||||
</View>
|
||||
{ep.name && (
|
||||
<Text numberOfLines={1} style={styles.episodeName}>
|
||||
{ep.name}
|
||||
</Text>
|
||||
)}
|
||||
{tc && !tc.is_watched && ep.duration ? (
|
||||
<View style={styles.progressTrack}>
|
||||
<View
|
||||
style={[
|
||||
styles.progressFill,
|
||||
{ width: `${Math.min(100, (tc.time / ep.duration) * 100)}%` },
|
||||
]}
|
||||
/>
|
||||
</View>
|
||||
) : null}
|
||||
</Pressable>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{!!release.torrents?.length && (
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>Торренты</Text>
|
||||
{release.torrents.map((t) => (
|
||||
<View key={t.id} style={styles.torrentRow}>
|
||||
<View style={styles.torrentInfo}>
|
||||
<Text numberOfLines={1} style={styles.torrentLabel}>
|
||||
{t.label}
|
||||
</Text>
|
||||
<Text style={styles.torrentMeta}>
|
||||
{formatBytes(t.size)} · {t.quality?.description} · {t.seeders} сидов
|
||||
</Text>
|
||||
</View>
|
||||
<Pressable onPress={() => Linking.openURL(t.magnet)} style={styles.magnetButton}>
|
||||
<Text style={styles.magnetButtonText}>Magnet</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
|
||||
<Modal
|
||||
visible={listOpen}
|
||||
transparent
|
||||
animationType="fade"
|
||||
onRequestClose={() => setListOpen(false)}
|
||||
>
|
||||
<Pressable style={styles.modalBackdrop} onPress={() => setListOpen(false)}>
|
||||
<View style={[styles.actionSheet, { paddingBottom: insets.bottom + spacing(4) }]}>
|
||||
{(Object.keys(COLLECTION_LABELS) as CollectionType[]).map((type) => (
|
||||
<Pressable key={type} onPress={() => pickCollection(type)} style={styles.actionSheetRow}>
|
||||
<Text
|
||||
style={[
|
||||
styles.actionSheetText,
|
||||
collectionType === type && styles.actionSheetTextActive,
|
||||
]}
|
||||
>
|
||||
{COLLECTION_LABELS[type]}
|
||||
</Text>
|
||||
</Pressable>
|
||||
))}
|
||||
{collectionType && (
|
||||
<Pressable onPress={removeFromList} style={styles.actionSheetRow}>
|
||||
<Text style={styles.actionSheetRemove}>Убрать из списка</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
<Pressable onPress={() => setListOpen(false)} style={styles.actionSheetRow}>
|
||||
<Text style={styles.actionSheetCancel}>Отмена</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</Pressable>
|
||||
</Modal>
|
||||
</ScrollView>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
screen: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.background,
|
||||
},
|
||||
centered: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: colors.background,
|
||||
},
|
||||
errorText: {
|
||||
color: colors.textFaint,
|
||||
fontSize: 14,
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
gap: spacing(3),
|
||||
paddingHorizontal: spacing(4),
|
||||
paddingTop: spacing(2),
|
||||
},
|
||||
poster: {
|
||||
width: 110,
|
||||
aspectRatio: 2 / 3,
|
||||
borderRadius: radius.md,
|
||||
backgroundColor: colors.surfaceRaised,
|
||||
},
|
||||
headerInfo: {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
},
|
||||
title: {
|
||||
color: colors.text,
|
||||
fontSize: 20,
|
||||
fontWeight: '800',
|
||||
},
|
||||
englishTitle: {
|
||||
color: colors.textFaint,
|
||||
fontSize: 12,
|
||||
marginTop: spacing(0.5),
|
||||
},
|
||||
chipRow: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
gap: spacing(1.5),
|
||||
marginTop: spacing(2.5),
|
||||
},
|
||||
actionsRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing(2),
|
||||
marginTop: spacing(3),
|
||||
},
|
||||
listButton: {
|
||||
backgroundColor: colors.accent,
|
||||
paddingHorizontal: spacing(3),
|
||||
paddingVertical: spacing(1.5),
|
||||
borderRadius: radius.full,
|
||||
},
|
||||
listButtonActive: {
|
||||
backgroundColor: colors.surfaceRaised,
|
||||
},
|
||||
listButtonText: {
|
||||
color: colors.text,
|
||||
fontSize: 12,
|
||||
fontWeight: '700',
|
||||
},
|
||||
iconButton: {
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: radius.full,
|
||||
backgroundColor: colors.surfaceRaised,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
genreRow: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
gap: spacing(1.5),
|
||||
paddingHorizontal: spacing(4),
|
||||
marginTop: spacing(3.5),
|
||||
},
|
||||
description: {
|
||||
color: colors.textDim,
|
||||
fontSize: 13,
|
||||
lineHeight: 19,
|
||||
paddingHorizontal: spacing(4),
|
||||
marginTop: spacing(3.5),
|
||||
},
|
||||
playerWrap: {
|
||||
paddingHorizontal: spacing(4),
|
||||
marginTop: spacing(5),
|
||||
},
|
||||
section: {
|
||||
marginTop: spacing(5),
|
||||
},
|
||||
sectionTitle: {
|
||||
color: colors.text,
|
||||
fontSize: 17,
|
||||
fontWeight: '700',
|
||||
marginBottom: spacing(2),
|
||||
paddingHorizontal: spacing(4),
|
||||
},
|
||||
episodeList: {
|
||||
paddingHorizontal: spacing(4),
|
||||
gap: spacing(2),
|
||||
},
|
||||
episodeButton: {
|
||||
width: 130,
|
||||
backgroundColor: colors.surfaceRaised,
|
||||
borderRadius: radius.md,
|
||||
padding: spacing(3),
|
||||
},
|
||||
episodeButtonActive: {
|
||||
backgroundColor: colors.accent,
|
||||
},
|
||||
episodeRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
episodeLabel: {
|
||||
color: colors.text,
|
||||
fontSize: 13,
|
||||
fontWeight: '700',
|
||||
},
|
||||
episodeCheck: {
|
||||
color: colors.text,
|
||||
fontSize: 11,
|
||||
},
|
||||
episodeName: {
|
||||
color: colors.textFaint,
|
||||
fontSize: 11,
|
||||
marginTop: spacing(1),
|
||||
},
|
||||
progressTrack: {
|
||||
height: 3,
|
||||
backgroundColor: 'rgba(0,0,0,0.3)',
|
||||
borderRadius: radius.full,
|
||||
marginTop: spacing(2),
|
||||
overflow: 'hidden',
|
||||
},
|
||||
progressFill: {
|
||||
height: '100%',
|
||||
backgroundColor: 'rgba(255,255,255,0.7)',
|
||||
},
|
||||
torrentRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
backgroundColor: colors.surfaceRaised,
|
||||
borderRadius: radius.md,
|
||||
paddingHorizontal: spacing(4),
|
||||
paddingVertical: spacing(3),
|
||||
marginHorizontal: spacing(4),
|
||||
marginBottom: spacing(2),
|
||||
},
|
||||
torrentInfo: {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
marginRight: spacing(3),
|
||||
},
|
||||
torrentLabel: {
|
||||
color: colors.text,
|
||||
fontSize: 13,
|
||||
fontWeight: '600',
|
||||
},
|
||||
torrentMeta: {
|
||||
color: colors.textFaint,
|
||||
fontSize: 11,
|
||||
marginTop: spacing(1),
|
||||
},
|
||||
magnetButton: {
|
||||
backgroundColor: colors.accent,
|
||||
paddingHorizontal: spacing(3),
|
||||
paddingVertical: spacing(1.5),
|
||||
borderRadius: radius.full,
|
||||
},
|
||||
magnetButtonText: {
|
||||
color: colors.text,
|
||||
fontSize: 11,
|
||||
fontWeight: '700',
|
||||
},
|
||||
modalBackdrop: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.overlay,
|
||||
justifyContent: 'flex-end',
|
||||
},
|
||||
actionSheet: {
|
||||
backgroundColor: colors.surfaceRaised,
|
||||
borderTopLeftRadius: radius.lg,
|
||||
borderTopRightRadius: radius.lg,
|
||||
paddingTop: spacing(3),
|
||||
paddingHorizontal: spacing(4),
|
||||
},
|
||||
actionSheetRow: {
|
||||
paddingVertical: spacing(3),
|
||||
borderBottomWidth: StyleSheet.hairlineWidth,
|
||||
borderBottomColor: colors.border,
|
||||
},
|
||||
actionSheetText: {
|
||||
color: colors.text,
|
||||
fontSize: 15,
|
||||
},
|
||||
actionSheetTextActive: {
|
||||
color: colors.accent,
|
||||
fontWeight: '700',
|
||||
},
|
||||
actionSheetRemove: {
|
||||
color: '#ff6b6b',
|
||||
fontSize: 15,
|
||||
},
|
||||
actionSheetCancel: {
|
||||
color: colors.textFaint,
|
||||
fontSize: 15,
|
||||
fontWeight: '600',
|
||||
textAlign: 'center',
|
||||
},
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue