goon/mobile/src/components/swipe/ClipCell.tsx
goon-foss 7bfd194485 fix(mobile): Edge - serduszko nie rusza dzwieku ani nie przewija klipu, jeden przycisk dzwieku, stroik feedu
Zgloszenia Jana 2026-09-16:
1+3. Tapniecie serduszka przelaczalo dzwiek i przewijalo klip na poczatek. Gesty lapala
   cala komorka razem z przyciskami, wiec tap w serduszko byl tez tapem w ekran, a zmiana
   dzwieku restartowala odtwarzanie (efekt aktywacji strony mial soundOn w zaleznosciach).
   Teraz gesty lapie tylko warstwa wideo, a wyciszenie ma osobny efekt.
2. Dwa przyciski dzwieku. Zostaje ten w kolumnie przy kciuku, w gornym pasku wchodzi stroik.
4. Za malo tagow na starcie: bylo 24 z 40, „anal" (27. co do liczby klipow) wypadal.
   Teraz 60 tagow + nisze + szukanie po calym slowniku (1,7 tys. tagow, nowy param q),
   wskazanie wazy 8 zamiast 5, a stroik mozna otworzyc w kazdej chwili i zmiana dziala
   od nastepnego rozdania.
5. Losowe miganie przy przewijaniu: dociagniecie kolejnej strony feedu podmienialo tablice
   klipow i restartowalo biezacy klip. Efekt zalezy teraz od numeru strony i adresow wideo.

Sprawdzone na emulatorze: tap w serduszko zostawia wyciszenie bez zmian i zapisuje like,
przycisk dzwieku odcisza, gorny pasek otwiera stroik, szukanie „anal" daje 8 wariantow,
a po Apply w clip_affinity siedzi anal=8.0. Edge dalej odklada zdarzenia w clip_feed_events.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 12:00:10 +02:00

308 lines
11 KiB
TypeScript

/**
* 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).
<View style={styles.root}>
<GestureDetector gesture={gestures}>
<View style={StyleSheet.absoluteFill} collapsable={false}>
{poster ? (
<Image source={{ uri: poster }} style={StyleSheet.absoluteFill} contentFit="cover" transition={0} />
) : null}
{active && player ? (
<VideoView
player={player}
style={[StyleSheet.absoluteFill, !ready && { opacity: 0 }]}
nativeControls={false}
contentFit="cover"
allowsPictureInPicture={false}
/>
) : null}
{/* Wybuch serca po double-tapie */}
<Animated.View
pointerEvents="none"
style={[
styles.heartBurst,
{
opacity: heart,
transform: [{ scale: Animated.add(0.4, Animated.multiply(heart, 0.9)) }],
},
]}
>
<MaterialCommunityIcons name="heart" size={120} color="#fff" />
</Animated.View>
</View>
</GestureDetector>
<Animated.View style={[StyleSheet.absoluteFill, { transform: [{ translateY: lift }] }]} pointerEvents="box-none">
{/* Kolumna przyciskow */}
<View style={styles.rail} pointerEvents="box-none">
<Pressable
onPress={() => {
burst();
onLike();
}}
style={styles.railBtn}
accessibilityLabel={liked ? 'Remove from favorites' : 'Add to favorites'}
>
<MaterialCommunityIcons name={liked ? 'heart' : 'heart-outline'} size={30} color={liked ? theme.accentText : '#fff'} />
</Pressable>
<Pressable onPress={onToggleMute} style={styles.railBtn} accessibilityLabel={muted ? 'Unmute' : 'Mute'}>
<MaterialCommunityIcons
name={clip.has_audio ? (muted ? 'volume-off' : 'volume-high') : 'volume-variant-off'}
size={28}
color={clip.has_audio ? '#fff' : '#9a9a9a'}
/>
</Pressable>
<Pressable onPress={onNotInterested} style={styles.railBtn} accessibilityLabel="Not interested">
<MaterialCommunityIcons name="thumb-down-outline" size={26} color="#fff" />
</Pressable>
</View>
{/* Podpis: tworca, tytul, tagi */}
<View style={styles.caption} pointerEvents="box-none">
{/* Nazwa tworcy prowadzi do jego strony (zgloszenie 2026-09-15: „cannot tap
username"). Bez tworcy (albo bez obslugi) zostaje sam napis. */}
<Pressable
style={styles.creatorRow}
disabled={!clip.creator || !onCreatorPress}
onPress={() => clip.creator && onCreatorPress?.(clip.creator)}
accessibilityRole={clip.creator && onCreatorPress ? 'link' : undefined}
accessibilityLabel={clip.creator ? `Clips by ${clip.creator}` : undefined}
hitSlop={8}
>
<Text style={styles.creator} numberOfLines={1}>
{clip.creator ? `@${clip.creator}` : clip.source}
</Text>
{clip.creator_verified ? (
<MaterialCommunityIcons name="check-decagram" size={15} color={theme.accentSecondary} style={{ marginLeft: 4 }} />
) : null}
{clip.creator && onCreatorPress ? (
<MaterialCommunityIcons name="chevron-right" size={18} color="#fff" style={{ marginLeft: 2, opacity: 0.8 }} />
) : null}
{item.is_fresh ? <Text style={styles.fresh}>NEW</Text> : null}
</Pressable>
{clip.title ? (
<Text style={styles.title} numberOfLines={2}>
{clip.title}
</Text>
) : null}
{tags.length ? (
<View style={styles.tagsRow}>
{tags.map((t) => (
<Pressable key={t} onPress={() => onTagPress?.(t)} style={styles.tagChip}>
<Text style={styles.tagText}>#{t}</Text>
</Pressable>
))}
</View>
) : null}
</View>
{/* Pasek postepu */}
<View style={styles.progressTrack} pointerEvents="none">
<View style={[styles.progressFill, { width: `${Math.round(progress * 100)}%` }]} />
</View>
</Animated.View>
</View>
);
}
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 },
});