import type { RouteProp } from '@react-navigation/native'; import { useNavigation, useRoute } from '@react-navigation/native'; import type { NativeStackNavigationProp } from '@react-navigation/native-stack'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { ApiError } from '../api'; import * as IntentLauncher from 'expo-intent-launcher'; import React from 'react'; import { ActivityIndicator, Alert, Linking, Platform, Pressable, ScrollView, StyleSheet, Text, useWindowDimensions, View, } from 'react-native'; import { Image } from 'expo-image'; import { LinearGradient } from 'expo-linear-gradient'; import { useClient } from '../ClientContext'; import { isGetFileUrl, resolveGetFilePage } from '../lib/getfileResolver'; import { resolvePornxpPage } from '../lib/pornxpResolver'; import type { RootStackParamList } from '../navigation'; import { theme } from '../theme'; import type { PlaybackSource, SceneOut, StreamLink } from '../types'; import { PlaybackQualityModal } from './PlaybackQualityModal'; export function SceneDetailScreen() { const client = useClient(); const queryClient = useQueryClient(); const route = useRoute>(); const nav = useNavigation>(); const { id } = route.params; const { data, isLoading, error } = useQuery({ queryKey: ['scene', id], queryFn: () => client.getScene(id), }); const isFav = data?.is_favorite ?? false; const favMutation = useMutation({ mutationFn: () => (isFav ? client.removeSceneFavorite(id) : client.addSceneFavorite(id)), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['scene', id] }); queryClient.invalidateQueries({ queryKey: ['scenes'] }); }, }); // Auto-enrich tags z tube'a gdy scena ma 0 tagów + jest tube playback source. // Mainstream tubes (xhamster/porntrex/eporner/...) mają categories per scene które // yt-dlp generic nie wyciąga. Strzelamy raz przy otwarciu sceny — endpoint jest // idempotentny, kolejne otwarcia tej samej sceny już nic nie robią (gdy są tagi). const enrichMutation = useMutation({ mutationFn: () => client.enrichSceneTags(id), onSuccess: (out) => { if (out.added > 0) { queryClient.invalidateQueries({ queryKey: ['scene', id] }); } }, }); React.useEffect(() => { if (!data) return; if (data.tags.length > 0) return; const hasTubeSource = data.playback_sources.some((s) => s.origin?.startsWith('tube:')); if (!hasTubeSource) return; if (enrichMutation.isPending || enrichMutation.isSuccess) return; enrichMutation.mutate(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [data?.id, data?.tags.length]); // Auto-enrich duration: youporn/xvideos/xnxx/etc udostępniają og:video:duration // lub LD-JSON ISO 8601 — direct scrapery (search-only) ich nie pobierają, więc // sceny przychodzą z duration_sec=null. Tu jeden fetch → backfill. Bez tego // dedup capuje composite na 0.85 (bez strong signal), co ratuje od false-merge, // ale też blokuje legit auto-merges między tymi samymi scenami z różnych tube'ów. const enrichDurationMutation = useMutation({ mutationFn: () => client.enrichSceneDuration(id), onSuccess: (out) => { if (out.duration_sec) { queryClient.invalidateQueries({ queryKey: ['scene', id] }); } }, }); React.useEffect(() => { if (!data) return; if (data.duration_sec) return; const hasTubeSource = data.playback_sources.some((s) => s.origin?.startsWith('tube:')); if (!hasTubeSource) return; if (enrichDurationMutation.isPending || enrichDurationMutation.isSuccess) return; enrichDurationMutation.mutate(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [data?.id, data?.duration_sec]); // Auto-enrich thumbnail: dla scen ingestowanych z search-only scrapery // (hdporn92, sxyland, sxyprn itp.) thumbnail_url=None w PlaybackSource. // Detail page wystawia og:image — jeden fetch tu napełnia wszystkie źródła // dla tej sceny. SceneDetail = naturalny moment (user już patrzy na hero). const enrichThumbMutation = useMutation({ mutationFn: () => client.enrichSceneThumbnail(id), onSuccess: (out) => { if (out.thumbnail_url) { queryClient.invalidateQueries({ queryKey: ['scene', id] }); queryClient.invalidateQueries({ queryKey: ['scenes'] }); } }, }); React.useEffect(() => { if (!data) return; const hasThumb = data.playback_sources.some((s) => s.thumbnail_url); if (hasThumb) return; const hasTubeSource = data.playback_sources.some((s) => s.origin?.startsWith('tube:')); if (!hasTubeSource) return; if (enrichThumbMutation.isPending || enrichThumbMutation.isSuccess) return; enrichThumbMutation.mutate(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [data?.id]); // Auto-enrich tags: search-only scrapery nie pobierają tagów z detail page. // Pornhat ma `js-ajax-tag` data-setup JSON; xhamster/xvideos/youporn/inne mają // dedicated tag_extract patterny. SceneDetail wywołuje 1 fetch → upsert do DB. const enrichTagsMutation = useMutation({ mutationFn: () => client.enrichSceneTags(id), onSuccess: (out) => { if (out.added > 0) queryClient.invalidateQueries({ queryKey: ['scene', id] }); }, }); React.useEffect(() => { if (!data) return; if ((data.tags?.length ?? 0) > 0) return; const hasTubeSource = data.playback_sources.some((s) => s.origin?.startsWith('tube:')); if (!hasTubeSource) return; if (enrichTagsMutation.isPending || enrichTagsMutation.isSuccess) return; enrichTagsMutation.mutate(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [data?.id]); // Auto-enrich studio: pornhat ma `js-ajax-dvd` data-setup (DDF/Adult Time/...). // Inne tube'y bez handlera — endpoint zwraca null bez modyfikacji. const enrichStudioMutation = useMutation({ mutationFn: () => client.enrichSceneStudio(id), onSuccess: (out) => { if (out.studio_id) queryClient.invalidateQueries({ queryKey: ['scene', id] }); }, }); React.useEffect(() => { if (!data) return; if (data.studio) return; const hasPornhat = data.playback_sources.some((s) => s.origin === 'tube:pornhatcom'); if (!hasPornhat) return; if (enrichStudioMutation.isPending || enrichStudioMutation.isSuccess) return; enrichStudioMutation.mutate(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [data?.id]); // Long-press tag → confirm → DELETE relację scene_tag (tag zostaje w słowniku, // ale scena go nie ma). User-curated odśmiecanie błędnych tagów. const removeTagMutation = useMutation({ mutationFn: (tagId: string) => client.removeTagFromScene(id, tagId), onSuccess: () => queryClient.invalidateQueries({ queryKey: ['scene', id] }), onError: (e) => Alert.alert('Failed', e instanceof Error ? e.message : String(e)), }); // Long-press performer pill → DELETE relację scene_performer. Analogicznie // do tagów: false-match fuzzy (np. "anna bella" trafiona pod "Bad Bella" przez // alias "Bella") usuwany ręcznie z konkretnej sceny — performerka zostaje w // słowniku, tylko ta scena ją odpina (zgłoszenia #03e8044a, #12b8c122). const removePerformerMutation = useMutation({ mutationFn: (perfId: string) => client.removePerformerFromScene(id, perfId), onSuccess: () => queryClient.invalidateQueries({ queryKey: ['scene', id] }), onError: (e) => Alert.alert('Failed', e instanceof Error ? e.message : String(e)), }); const onPerformerLongPress = (perfId: string, perfName: string) => { Alert.alert( 'Remove performer', `Remove "${perfName}" from this scene?`, [ { text: 'Cancel', style: 'cancel' }, { text: 'Remove', style: 'destructive', onPress: () => removePerformerMutation.mutate(perfId), }, ], ); }; const onTagLongPress = (tagId: string, tagName: string) => { Alert.alert( 'Remove tag', `Remove tag "${tagName}" from this scene?`, [ { text: 'Cancel', style: 'cancel' }, { text: 'Remove', style: 'destructive', onPress: () => removeTagMutation.mutate(tagId), }, ], ); }; React.useLayoutEffect(() => { nav.setOptions({ headerRight: () => ( favMutation.mutate()} hitSlop={12} disabled={favMutation.isPending}> {isFav ? '★' : '☆'} ), }); }, [nav, isFav, favMutation]); if (isLoading) return ; if (error instanceof Error) return {error.message}; if (!data) return null; return ( {(data.code || data.director) && ( {data.code ? code · {data.code} : null} {data.director ? dir · {data.director} : null} )} {data.performers.length > 0 && (
{data.performers.map((p) => ( nav.push('PerformerScenes', { id: p.id, name: p.canonical_name }) } onLongPress={() => onPerformerLongPress(p.id, p.canonical_name)} delayLongPress={400} > {p.canonical_name} {p.as_alias ? ` (as ${p.as_alias})` : ''} ))} long-press to remove from this scene
)} {data.studio && (
nav.push('StudioScenes', { id: data.studio!.slug, studioId: data.studio!.id, name: data.studio!.name, }) } > {data.studio.name} {data.studio.network ? ` · ${data.studio.network}` : ''}
)} {data.tags.length > 0 && (
{data.tags.map((t) => ( nav.push('TagScenes', { slug: t.slug, name: t.name })} onLongPress={() => onTagLongPress(t.id, t.name)} delayLongPress={500} > {t.name} ))} long-press to remove from this scene
)} {data.external_refs.length > 0 && (
{(() => { // Sceny popularne mogą mieć dziesiątki refów na ten sam tube (różne URL, // np. /search/.../ + /tag/... + /performer/... — wszystkie dla tej samej sceny). // Deduplikujemy po source name, pokazujemy count jeśli > 1. const counts = new Map(); for (const r of data.external_refs) { counts.set(r.source, (counts.get(r.source) ?? 0) + 1); } return Array.from(counts.entries()).map(([source, count]) => ( {source}{count > 1 ? ` ×${count}` : ''} )); })()}
)} {data.playback_sources.length > 0 && (
{data.playback_sources.map((p) => ( ))} long-press: open in browser / mark as broken
)} {data.description ? (
{data.description}
) : null}
); } function Hero({ data }: { data: SceneOut }) { const { width } = useWindowDimensions(); const heroHeight = Math.round(width * (9 / 16)); const animatedUrl = data.playback_sources.find((s) => s.animated_thumbnail_url) ?.animated_thumbnail_url; const staticUrl = data.playback_sources.find((s) => s.thumbnail_url)?.thumbnail_url; const url = animatedUrl ?? staticUrl; const minutes = data.duration_sec ? Math.floor(data.duration_sec / 60) : null; const meta = [ data.release_date ?? null, data.studio?.name ?? null, minutes !== null ? `${minutes} min` : null, ] .filter(Boolean) .join(' · '); if (!url) { return ( {data.title} {meta ? {meta} : null} ); } return ( {data.title} {meta ? {meta} : null} ); } function Section({ title, children }: { title: string; children: React.ReactNode }) { return ( {title} {children} ); } function guessVideoMime(url: string): string { const path = url.split('?')[0].toLowerCase(); if (path.endsWith('.m3u8')) return 'application/vnd.apple.mpegurl'; if (path.endsWith('.mpd')) return 'application/dash+xml'; if (path.endsWith('.webm')) return 'video/webm'; if (path.endsWith('.mkv')) return 'video/x-matroska'; if (path.endsWith('.mp4')) return 'video/mp4'; return 'video/*'; } function PlaybackButton({ sceneId, sceneDurationSec, source, }: { sceneId: string; sceneDurationSec: number | null; source: PlaybackSource; }) { const client = useClient(); const queryClient = useQueryClient(); const nav = useNavigation>(); const [resolving, setResolving] = React.useState(false); const [qualityLinks, setQualityLinks] = React.useState(null); // Tube origin format: 'tube:hqpornercom' (nowy) lub 'pornapp:hqpornercom' (legacy // sprzed migracji 0011) — backend rozumie oba prefixy. const isTube = source.origin.startsWith('tube:') || source.origin.startsWith('pornapp:'); // Watch tracking: każde uruchomienie playera/browsera oznaczamy jako "started" // (position_sec=0 jeśli pierwszy raz; nie ruszamy jeśli już była progres). // Continue watching rail i `last_played_at` na home są zasilane stąd. const markStarted = React.useCallback(() => { client .upsertProgress(sceneId, { position_sec: 0, duration_sec: sceneDurationSec ?? undefined, }) .then(() => { queryClient.invalidateQueries({ queryKey: ['watch-recent'] }); }) .catch(() => { // best-effort, nie blokujemy odtwarzania }); }, [client, sceneId, sceneDurationSec, queryClient]); // origin format: 'tube:hqpornercom' → display 'hqpornercom' const label = source.origin.includes(':') ? source.origin.split(':').slice(1).join(':') : source.origin; const openUrl = async (url: string) => { const ok = await Linking.canOpenURL(url); if (!ok) { Alert.alert('Cannot open', url); return; } await Linking.openURL(url); }; // Inline player (expo-video / ExoPlayer) — odtwarza w tej samej apce, pasuje headery // (Referer/UA) potrzebne dla CDN-ów które blokują requesty bez właściwego origin'u // (hqporner/iceyfile/itd.). // Refererem jest origin z PlaybackSource.page_url (np. "hqporner.com"). // `fallbackEmbedUrl` jest używany przez Player gdy native ExoPlayer dostanie błąd // (typowe dla IP-bound CDN URL'i z luluvids/iceyfile). const openAsVideo = async ( link: StreamLink, fallbackEmbedUrl?: string, ) => { let refererHost: string | undefined; try { refererHost = new URL(source.page_url).hostname; } catch { // ignore } // Preferuj direct CDN URL (0 VPS bandwidth) → fallback proxy URL (jeśli direct // fails). Backend dostarcza oba w StreamLink. Headers tylko dla direct path. let initialUrl = link.direct_url || link.stream_url!; const isDirect = !!link.direct_url && initialUrl === link.direct_url; // hdporn.gg/fullmovies.xxx: direct_url to `.../get_file/...` które 302-redirectuje // na fpvcdn z IP fetchera. ExoPlayer wywala się na tym cross-domain redirekcie → // fallback proxy → „mignięcie". Resolvujemy redirect TU (na telefonie → fpvcdn z IP // telefonu) i podajemy ExoPlayerowi finalny URL — bez błędu/migotania/proxy. Fail = // gramy oryginał (obecne zachowanie z fallbackiem). ~100-300ms (get_file 302 szybki). // hdporn.gg/fullmovies.xxx: backendowy get_file jest zbindowany do IP VPS (page-loader) // → telefon dostaje 410 → fallback proxy (mignięcie). Re-fetchujemy STRONĘ na telefonie // (phone-bound get_file) → fpvcdn z IP telefonu → ExoPlayer gra direct (bez proxy/migotania). if (isDirect && isGetFileUrl(initialUrl)) { const ref = link.headers?.Referer || (refererHost ? `https://${refererHost}/` : undefined); const resolved = await resolveGetFilePage(source.page_url, link.quality, ref); if (resolved) initialUrl = resolved; } nav.navigate('Player', { url: initialUrl, sceneId, playbackId: source.id, durationSec: sceneDurationSec, refererHost, title: source.origin, headers: isDirect && link.headers ? link.headers : undefined, fallbackProxyUrl: isDirect ? link.stream_url || undefined : undefined, fallbackEmbedUrl, }); }; const onPress = async () => { // Non-tube origin: brak własnego extractora (np. paradisehill bez resolve), otwieramy // page/embed/stream w przeglądarce. if (!isTube) { try { markStarted(); await openUrl(source.stream_url || source.embed_url || source.page_url); } catch (e) { Alert.alert('Failed to open', e instanceof Error ? e.message : String(e)); } return; } // pornxp.ph: CDN token IP-bound (backend 403 cross-IP) → backend oddaje WebView // fallback który czarno-ekranił (bug-report 2026-06-07, fd06cd86). Telefon sam // pobiera stronę (phone-IP-bound mp4) → natywne multi-quality, zero WebView/reklam. if (source.origin === 'tube:pornxpph') { setResolving(true); try { const links = await resolvePornxpPage(source.page_url); if (links.length > 0) { markStarted(); if (links.length === 1) await openAsVideo(links[0], source.page_url); else setQualityLinks(links); return; } // pusto → spadnij na backend resolve (WebView) poniżej } catch { // ignore → backend fallback } finally { setResolving(false); } } // Tube origin: backend resolve → lista linków: część direct video (stream_url), // część hoster embed (embed_url, np. StreamWish/doodporn HTML page). // - direct → MX Player (intent video/*) // - embed-only → browser (HTML page nie odtworzy się w MX, MX wywaliłby błąd) setResolving(true); try { const res = await client.resolvePlayback(sceneId, source.id); const all = res.links || []; // Dedup po URL — porn-app czasem dubluje ten sam link z różnym quality string. const seen = new Set(); const unique = all.filter((l) => { const key = l.stream_url || l.embed_url || ''; if (!key || seen.has(key)) return false; seen.add(key); return true; }); const directLinks = unique.filter((l) => !!l.stream_url); const embedLinks = unique.filter((l) => !l.stream_url && !!l.embed_url); if (directLinks.length === 0 && embedLinks.length === 0) { Alert.alert('No stream', 'porn-app did not return any stream URL — falling back to the page.'); await openUrl(source.page_url); return; } // Brak direct video → otwórz embed w naszym WebView playerze. WebView (Chrome // engine) wpuszczany jest przez WAF/JA3-fingerprint hostów (xtremestream, // playmogo, streamtape) które blokują direct fetch z native playera. // Player JS hostera może też ujawnić finalny m3u8 — auto-extract w PlayerScreen // pokaże banner "Otwórz w native playerze". if (directLinks.length === 0) { const target = res.best?.embed_url || embedLinks[0].embed_url!; let refererHost: string | undefined; try { refererHost = new URL(source.page_url).hostname; } catch { // ignore } markStarted(); nav.navigate('Player', { url: target, sceneId, playbackId: source.id, durationSec: sceneDurationSec, refererHost, title: source.origin, mode: 'webview', }); return; } // Fallback embed do Playera — gdy ExoPlayer dostanie 403 (IP-bound CDN), apka // przełączy się na WebView z tym embed URL'em. Bierzemy pierwszy embed jaki // jest, niezależnie od kolejności w `links`. const fallbackEmbedUrl = embedLinks[0]?.embed_url || res.best?.embed_url || undefined; if (directLinks.length === 1) { markStarted(); await openAsVideo(directLinks[0], fallbackEmbedUrl); return; } setQualityLinks(directLinks); } catch (e) { // 410 = backend zaznaczył link jako PERMANENT dead (HosterDead exception // lub tube page 404/410). Refresh sceny żeby ten button zniknął z listy. if (e instanceof ApiError && e.status === 410) { Alert.alert( 'Dead link', 'The tube removed this video. We marked the source, you won\'t see it again.', ); queryClient.invalidateQueries({ queryKey: ['scene', sceneId] }); queryClient.invalidateQueries({ queryKey: ['scenes'] }); return; } // 503 = transient extraction failure (hoster chwilowo niedostępny, network // glitch, ad-iframe race condition). NIE oznaczamy źródła jako dead — // następne kliknięcie pewnie zadziała. if (e instanceof ApiError && e.status === 503) { Alert.alert( 'Try again', 'Temporary problem with the host. Tap Play again in a moment.', ); return; } Alert.alert( 'Resolve failed', (e instanceof Error ? e.message : String(e)) + '\n\nFallback: opening source page.', ); try { await openUrl(source.page_url); } catch { // pomijamy — alert już był } } finally { setResolving(false); } }; const onSelectQuality = async (link: StreamLink) => { setQualityLinks(null); try { markStarted(); if (link.stream_url) { // Wybór z dropdownu jakości — fallback na inny embed gdyby ten konkretny // direct link dał 403. `qualityLinks` zawiera tylko stream_url-e, więc // szukamy embed'a w `links` na resolve response wcześniej; tu już go nie // mamy, ale to kompromis (rzadki przypadek multi-quality + dead direct). await openAsVideo(link); } else if (link.embed_url) { let refererHost: string | undefined; try { refererHost = new URL(source.page_url).hostname; } catch { // ignore } nav.navigate('Player', { url: link.embed_url, sceneId, playbackId: source.id, durationSec: sceneDurationSec, refererHost, title: source.origin, mode: 'webview', }); } } catch (e) { Alert.alert('Failed to open', e instanceof Error ? e.message : String(e)); } }; const action = isTube ? (resolving ? 'resolving…' : 'pick quality & play') : 'open in browser'; // Long-press → action sheet z 3 opcjami. Bug-reports 2026-05-12/2026-05-20: // (1) user chce móc usunąć złe linki (xhamster wrong-video/eporner wrong-scene), // (2) user chce diagnostyki — otworzyć page_url w browser (zobaczyć HTML tube'a // bez WebView playera), żeby rozsądnie ocenić czy hoster faktycznie broken. const onLongPress = () => { Alert.alert( label, 'What do you want to do with this link?', [ { text: 'Open in browser (diagnostics)', onPress: async () => { try { const url = source.page_url || source.embed_url || source.stream_url; if (url) { await Linking.openURL(url); } else { Alert.alert('No URL', 'This playback has no page_url to open.'); } } catch (e) { Alert.alert('Could not open', e instanceof Error ? e.message : String(e)); } }, }, { text: 'Mark as invalid', style: 'destructive', onPress: async () => { try { await client.markPlaybackDead(sceneId, source.id); queryClient.invalidateQueries({ queryKey: ['scene', sceneId] }); queryClient.invalidateQueries({ queryKey: ['scenes'] }); } catch (e) { Alert.alert('Failed', e instanceof Error ? e.message : String(e)); } }, }, { text: 'Cancel', style: 'cancel' }, ], ); }; return ( <> [ styles.watchButton, pressed && styles.watchButtonPressed, ]} onPress={onPress} onLongPress={onLongPress} delayLongPress={500} disabled={resolving} > {label} {source.quality ? `${source.quality} · ` : ''} {source.duration_sec ? `${Math.floor(source.duration_sec / 60)}m · ` : ''} {action} setQualityLinks(null)} /> ); } const styles = StyleSheet.create({ container: { flex: 1, backgroundColor: theme.bg }, scrollContent: { paddingBottom: 32 }, hero: { width: '100%', backgroundColor: theme.card, overflow: 'hidden', }, heroOverlay: { position: 'absolute', left: 16, right: 16, bottom: 12, }, heroFallback: { paddingHorizontal: 16, paddingTop: 24, paddingBottom: 16, backgroundColor: theme.bgElevated, justifyContent: 'flex-end', }, heroTitle: { color: theme.fg, fontSize: 24, fontWeight: '800', letterSpacing: -0.3, textShadowColor: 'rgba(0,0,0,0.6)', textShadowOffset: { width: 0, height: 1 }, textShadowRadius: 4, }, heroMeta: { color: theme.muted, fontSize: 13, marginTop: 6, fontWeight: '500', }, body: { paddingHorizontal: 16, paddingTop: 8 }, metaRow: { flexDirection: 'row', flexWrap: 'wrap', gap: 14, marginTop: 8 }, metaDim: { color: theme.mutedDim, fontSize: 12, fontWeight: '500' }, section: { marginTop: 20 }, sectionTitle: { color: theme.mutedDim, fontSize: 11, textTransform: 'uppercase', marginBottom: 10, letterSpacing: 1.2, fontWeight: '700', }, pillRow: { flexDirection: 'row', flexWrap: 'wrap', gap: 8 }, pillPrimary: { borderColor: theme.accent, borderWidth: 1, backgroundColor: `${theme.accent}1A`, borderRadius: 16, paddingHorizontal: 12, paddingVertical: 6, }, pillPrimaryText: { color: theme.accentGlow, fontSize: 13, fontWeight: '600', }, pillSecondary: { borderColor: theme.border, borderWidth: 1, borderRadius: 14, paddingHorizontal: 10, paddingVertical: 5, }, pillSecondaryText: { color: theme.muted, fontSize: 13, }, tagHint: { color: theme.mutedDim, fontSize: 11, marginTop: 6 }, pillSource: { borderColor: theme.accent, borderWidth: 1, borderRadius: 14, paddingHorizontal: 10, paddingVertical: 4, }, pillSourceText: { color: theme.accent, fontSize: 11, fontWeight: '700', textTransform: 'uppercase', letterSpacing: 0.6, }, description: { color: theme.fg, fontSize: 14, lineHeight: 21 }, error: { color: theme.bad, padding: 16 }, watchButton: { backgroundColor: theme.bgElevated, borderColor: theme.accent, borderWidth: 1.5, borderRadius: 12, padding: 14, marginBottom: 10, shadowColor: theme.accent, shadowOffset: { width: 0, height: 0 }, shadowOpacity: 0.25, shadowRadius: 8, elevation: 3, }, watchButtonPressed: { backgroundColor: `${theme.accent}26`, borderColor: theme.accentGlow, }, watchLabel: { color: theme.accentGlow, fontWeight: '700', fontSize: 15 }, watchMeta: { color: theme.muted, fontSize: 12, marginTop: 4 }, });