/** * Jedna strona feedu Swipe: klip na caly ekran, poster pod spodem, nakladka z tworca * i tagami, kolumna przyciskow po prawej, pasek postepu na dole. * * Komorka NIE tworzy playera. Player (jeden z dwoch wspoldzielonych, patrz SwipeScreen) * przychodzi w propsach tylko do aktywnej strony; nieaktywna renderuje sam poster, zeby * pager z kilkuset stronami nie trzymal kilkuset instancji ExoPlayera. * * Gesty (react-native-gesture-handler, bez reanimated, callbacki na watku JS): * tap → dzwiek wl./wyl. (czeka, az double-tap padnie), * double-tap → serce (ulubione) + wybuch serca + haptyka, * przytrzymanie → 2x do puszczenia, * przesuniecie w lewo → arkusz „nie interesuje" (pion zostaje dla pagera). * * Dolny pasek zakladek wisi nad feedem. Podpis, kolumna przyciskow i pasek postepu jada * razem z nim (ta sama wartosc Animated `barHidden`): gdy pasek jest widoczny, stoja nad * nim, gdy sie schowa, schodza na dol ekranu. Wczesniej pasek zaslanial podpis, tagi i * postep (uwaga Jana 2026-09-15). */ import { MaterialCommunityIcons } from '@expo/vector-icons'; import { Image } from 'expo-image'; import type { VideoPlayer } from 'expo-video'; import { VideoView } from 'expo-video'; import React from 'react'; import { Animated, Pressable, StyleSheet, Text, View } from 'react-native'; import { Gesture, GestureDetector } from 'react-native-gesture-handler'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { BOTTOM_BAR_HEIGHT, barHidden } from '../../lib/bottomBar'; import { fonts, theme } from '../../theme'; import type { ClipFeedItem } from '../../types'; interface Props { item: ClipFeedItem; active: boolean; player: VideoPlayer | null; muted: boolean; liked: boolean; onToggleMute: () => void; onLike: () => void; onNotInterested: () => void; onTagPress?: (tag: string) => void; onCreatorPress?: (creator: string) => void; } function ClipCellBase({ item, active, player, muted, liked, onToggleMute, onLike, onNotInterested, onTagPress, onCreatorPress }: Props) { const clip = item.clip; const [progress, setProgress] = React.useState(0); const [ready, setReady] = React.useState(false); const heart = React.useRef(new Animated.Value(0)).current; const insets = useSafeAreaInsets(); const lift = React.useMemo( () => barHidden.interpolate({ inputRange: [0, 1], outputRange: [-(BOTTOM_BAR_HEIGHT + insets.bottom), -insets.bottom], }), [insets.bottom], ); // Postep i gotowosc tylko dla aktywnej strony z playerem. React.useEffect(() => { if (!active || !player) { setProgress(0); setReady(false); return; } const subTime = player.addListener('timeUpdate', (e) => { const d = player.duration || clip.duration_sec || 0; setProgress(d > 0 ? Math.min(1, e.currentTime / d) : 0); }); const subStatus = player.addListener('statusChange', (e) => { setReady(e.status === 'readyToPlay'); }); setReady(player.status === 'readyToPlay'); return () => { subTime.remove(); subStatus.remove(); }; }, [active, player, clip.duration_sec]); const burst = React.useCallback(() => { heart.setValue(0); Animated.sequence([ Animated.spring(heart, { toValue: 1, useNativeDriver: true, speed: 30, bounciness: 12 }), Animated.timing(heart, { toValue: 0, duration: 350, delay: 250, useNativeDriver: true }), ]).start(); }, [heart]); const doubleTap = React.useMemo( () => Gesture.Tap() .numberOfTaps(2) .maxDelay(280) .onEnd((_e, success) => { if (!success) return; burst(); onLike(); }) .runOnJS(true), [burst, onLike], ); const singleTap = React.useMemo( () => Gesture.Tap() .numberOfTaps(1) .maxDuration(250) .requireExternalGestureToFail(doubleTap) .onEnd((_e, success) => { if (success) onToggleMute(); }) .runOnJS(true), [doubleTap, onToggleMute], ); const longPress = React.useMemo( () => Gesture.LongPress() .minDuration(220) .maxDistance(30) .onStart(() => { if (player) player.playbackRate = 2.0; }) .onFinalize(() => { if (player) player.playbackRate = 1.0; }) .runOnJS(true), [player], ); const swipeLeft = React.useMemo( () => Gesture.Pan() .activeOffsetX([-40, 40]) .failOffsetY([-15, 15]) .onEnd((e) => { if (e.translationX < -80) onNotInterested(); }) .runOnJS(true), [onNotInterested], ); const gestures = React.useMemo( () => Gesture.Race(swipeLeft, Gesture.Exclusive(doubleTap, singleTap), longPress), [swipeLeft, doubleTap, singleTap, longPress], ); const poster = clip.poster_url || clip.thumb_url || undefined; const tags = clip.tags.slice(0, 4); return ( // Gesty (tap = dzwiek, double-tap = serce, przytrzymanie = 2x, w lewo = „nie // interesuje") lapie TYLKO warstwa wideo. Wczesniej GestureDetector obejmowal cala // komorke razem z przyciskami, wiec tapniecie serduszka bylo jednoczesnie tapnieciem // w ekran i przelaczalo dzwiek (zgloszenie Jana 2026-09-16). {poster ? ( ) : null} {active && player ? ( ) : null} {/* Wybuch serca po double-tapie */} {/* Kolumna przyciskow */} { burst(); onLike(); }} style={styles.railBtn} accessibilityLabel={liked ? 'Remove from favorites' : 'Add to favorites'} > {/* Podpis: tworca, tytul, tagi */} {/* Nazwa tworcy prowadzi do jego strony (zgloszenie 2026-09-15: „cannot tap username"). Bez tworcy (albo bez obslugi) zostaje sam napis. */} clip.creator && onCreatorPress?.(clip.creator)} accessibilityRole={clip.creator && onCreatorPress ? 'link' : undefined} accessibilityLabel={clip.creator ? `Clips by ${clip.creator}` : undefined} hitSlop={8} > {clip.creator ? `@${clip.creator}` : clip.source} {clip.creator_verified ? ( ) : null} {clip.creator && onCreatorPress ? ( ) : null} {item.is_fresh ? NEW : null} {clip.title ? ( {clip.title} ) : null} {tags.length ? ( {tags.map((t) => ( onTagPress?.(t)} style={styles.tagChip}> #{t} ))} ) : null} {/* Pasek postepu */} ); } export const ClipCell = React.memo(ClipCellBase); const styles = StyleSheet.create({ root: { flex: 1, backgroundColor: '#000' }, heartBurst: { position: 'absolute', left: 0, right: 0, top: 0, bottom: 0, alignItems: 'center', justifyContent: 'center', }, // Nad dolnym paskiem pilnuje tego `lift`, wiec kolumna stoi na wysokosci podpisu. rail: { position: 'absolute', right: 8, bottom: 20, alignItems: 'center' }, railBtn: { width: 48, height: 48, borderRadius: 24, alignItems: 'center', justifyContent: 'center', marginTop: 10, backgroundColor: 'rgba(0,0,0,0.28)', }, caption: { position: 'absolute', left: 14, right: 72, bottom: 24 }, creatorRow: { flexDirection: 'row', alignItems: 'center' }, creator: { color: '#fff', fontFamily: fonts.display, fontSize: 16, textShadowColor: 'rgba(0,0,0,0.6)', textShadowRadius: 4 }, fresh: { marginLeft: 8, color: '#fff', backgroundColor: theme.accent, fontFamily: fonts.mono, fontSize: 10, paddingHorizontal: 6, paddingVertical: 1, borderRadius: 4, }, title: { color: '#eaeaea', fontFamily: fonts.body, fontSize: 14, marginTop: 4, textShadowColor: 'rgba(0,0,0,0.6)', textShadowRadius: 4 }, tagsRow: { flexDirection: 'row', flexWrap: 'wrap', marginTop: 8 }, tagChip: { backgroundColor: 'rgba(255,255,255,0.14)', borderRadius: 12, paddingHorizontal: 9, paddingVertical: 3, marginRight: 6, marginBottom: 6, }, tagText: { color: '#fff', fontFamily: fonts.medium, fontSize: 12 }, progressTrack: { position: 'absolute', left: 0, right: 0, bottom: 0, height: 3, backgroundColor: 'rgba(255,255,255,0.18)' }, progressFill: { height: 3, backgroundColor: theme.accentText }, });