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>
308 lines
11 KiB
TypeScript
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 },
|
|
});
|