feat(mobile): strona tworcy w Edge

Tapniecie nazwy tworcy otwiera jego strone: siatka wszystkich klipow, a tapniecie
kafelka gra je na pelnym ekranie z tym samym przewijaniem w pionie co Edge.
Zgloszenie z apki 2026-09-15: "cannot tap username as it's hidden behind icon tray".

- ClipPager: pionowy pager z dwoma playerami wyjety ze SwipeScreen, wspolny dla Edge
  i strony tworcy; feed, telemetria i autoSwipe zostaly w ekranach
- /clips?creator= oddaje `total` (liczba klipow tworcy) do naglowka
- ze strony tworcy leca tylko like/unlike i "nie interesuje": ogladanie stad nie moze
  zabetonowac feedu na jednym tworcy

Sprawdzone na emulatorze: link z Edge otwiera strone (@lilylovesyou2, 2 clips, verified),
kafelki trzymaja trzecia szerokosci takze w niepelnym rzedzie, pelny ekran gra i zamyka
sie przyciskiem pod paskiem stanu, a Edge dalej odklada zdarzenia w clip_feed_events.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
goon-foss 2026-09-16 10:20:04 +02:00
parent 7d5dd450f7
commit bfa3444de2
9 changed files with 538 additions and 157 deletions

View file

@ -75,6 +75,9 @@ class ClipListOut(BaseModel):
limit: int
offset: int
has_more: bool
#: Liczba wszystkich klipow tworcy (tylko przy filtrze `creator`, indeks ix_clips_creator).
#: Strona tworcy w apce pokazuje ja w naglowku; przy innych listach nie liczymy.
total: int | None = None
class ClipStatsOut(BaseModel):
@ -639,8 +642,17 @@ def list_clips(
ids = [r.id for r in rows]
tags = _tags_for(session, ids)
favs = _favorites_for(session, device_id, ids)
total = None
if creator:
total = session.execute(
select(func.count()).select_from(Clip).where(Clip.dead_at.is_(None), Clip.creator == creator)
).scalar_one()
return ClipListOut(
items=[_clip_out(r, tags.get(r.id, []), r.id in favs) for r in rows], limit=limit, offset=offset, has_more=has_more
items=[_clip_out(r, tags.get(r.id, []), r.id in favs) for r in rows],
limit=limit,
offset=offset,
has_more=has_more,
total=total,
)

View file

@ -686,6 +686,23 @@ export class GoonClient {
await this.request<void>(`/clips/affinity?seen=${seen ? 'true' : 'false'}`, { method: 'DELETE' });
}
/** Klipy jednego tworcy (albo tagu/zrodla), najnowsze pierwsze. */
async listClips(params: {
creator?: string;
tag?: string;
source?: string;
limit?: number;
offset?: number;
}): Promise<ClipListOut> {
const q = new URLSearchParams();
if (params.creator) q.set('creator', params.creator);
if (params.tag) q.set('tag', params.tag);
if (params.source) q.set('source', params.source);
q.set('limit', String(params.limit ?? 50));
q.set('offset', String(params.offset ?? 0));
return this.request(`/clips?${q.toString()}`);
}
async listClipFavorites(limit = 50, offset = 0): Promise<ClipListOut> {
return this.request(`/clips/favorites?limit=${limit}&offset=${offset}`);
}

View file

@ -35,6 +35,14 @@ export type ChangelogEntry = {
};
export const CHANGELOG: ChangelogEntry[] = [
{
id: '2026-09-16b',
date: 'September 2026',
items: [
'Edge: tap the creator name to open their page. You get every clip they have, and tapping one plays it full screen with the same swipe up for the next one.',
],
minRuntime: '1.4',
},
{
id: '2026-09-16a',
date: 'September 2026',

View file

@ -40,9 +40,10 @@ interface Props {
onLike: () => void;
onNotInterested: () => void;
onTagPress?: (tag: string) => void;
onCreatorPress?: (creator: string) => void;
}
function ClipCellBase({ item, active, player, muted, liked, onToggleMute, onLike, onNotInterested, onTagPress }: Props) {
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);
@ -201,15 +202,27 @@ function ClipCellBase({ item, active, player, muted, liked, onToggleMute, onLike
{/* Podpis: tworca, tytul, tagi */}
<View style={styles.caption} pointerEvents="box-none">
<View style={styles.creatorRow}>
{/* 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}
</View>
</Pressable>
{clip.title ? (
<Text style={styles.title} numberOfLines={2}>
{clip.title}

View file

@ -0,0 +1,214 @@
/**
* Pionowy pager klipow z DWOMA playerami (aktywny + nastepny w buforze).
*
* Wydzielone ze SwipeScreen (2026-09-16), bo strona tworcy pokazuje te same klipy w ten
* sam sposob. Komponent odpowiada tylko za odtwarzanie i przewracanie stron; feed,
* telemetria, autoSwipe i dolny pasek zostaly w ekranach, ktore go uzywaja.
*
* Pager (`react-native-pager-view`) jest modulem natywnym z APK od runtime 1.4, wiec
* ladujemy go przez `require` w try/catch: ten sam bundle JS leci OTA takze na starsze
* APK, tam zakladka Edge jest ukryta (`swipeAvailable`).
*/
import { createVideoPlayer, type VideoPlayer } from 'expo-video';
import * as Haptics from 'expo-haptics';
import React from 'react';
import { AppState, StyleSheet, View } from 'react-native';
import type { ClipFeedItem } from '../../types';
import { ClipCell } from './ClipCell';
let PagerViewImpl: React.ComponentType<any> | null = null;
try {
// eslint-disable-next-line @typescript-eslint/no-var-requires
PagerViewImpl = require('react-native-pager-view').default ?? null;
} catch {
PagerViewImpl = null;
}
export const pagerAvailable: boolean = PagerViewImpl != null;
/** Co dzialo sie na stronie, z ktorej wlasnie zeszlismy (do telemetrii). */
export interface ClipLeaveInfo {
dwellMs: number;
loops: number;
positionSec: number;
}
interface Props {
items: ClipFeedItem[];
index: number;
initialPage?: number;
onPageSelected: (next: number) => void;
/** Zejscie ze strony `prevIndex`; wolane po zmianie `index`, nie przy odmontowaniu. */
onLeave?: (prevIndex: number, info: ClipLeaveInfo) => void;
soundOn: boolean;
onToggleSound: () => void;
isLiked: (clipId: string) => boolean;
onLike: (item: ClipFeedItem, position: number) => void;
onNotInterested: (item: ClipFeedItem, position: number) => void;
onCreatorPress?: (creator: string) => void;
/** Ref pagera dla wywolujacego (autoSwipe robi `setPage`). */
pagerRef?: React.MutableRefObject<any>;
}
/** Adres wideo klipu (HD, a gdy go nie ma, SD). Klip bez zadnego z nich sie nie odtworzy. */
export function clipSource(item: ClipFeedItem): string | null {
return item.clip.hd_url || item.clip.sd_url || null;
}
export function ClipPager({
items,
index,
initialPage = 0,
onPageSelected,
onLeave,
soundOn,
onToggleSound,
isLiked,
onLike,
onNotInterested,
onCreatorPress,
pagerRef,
}: Props) {
const localRef = React.useRef<any>(null);
const ref = pagerRef ?? localRef;
const players = React.useRef<VideoPlayer[]>([]);
const loopsRef = React.useRef(0);
const activatedAtRef = React.useRef(0);
const [activePlayer, setActivePlayer] = React.useState<VideoPlayer | null>(null);
React.useEffect(() => {
// Tworzymy tylko na pierwszym planie (ExoPlayer w tle rzuca; wzor z PlayerScreen).
if (AppState.currentState !== 'active') return;
players.current = [createVideoPlayer(null), createVideoPlayer(null)];
for (const p of players.current) {
p.loop = true;
p.timeUpdateEventInterval = 0.25;
}
return () => {
for (const p of players.current) {
try {
p.release();
} catch {
// juz zwolniony
}
}
players.current = [];
};
}, []);
const loadedSrc = React.useRef<Map<VideoPlayer, string | null>>(new Map());
const ensureSource = React.useCallback((p: VideoPlayer, src: string | null) => {
if (loadedSrc.current.get(p) === src) return;
loadedSrc.current.set(p, src);
try {
p.replace(src ? { uri: src } : null);
} catch {
// brak zrodla / zwolniony player
}
}, []);
// Aktywacja strony `index`: player i%2 gra, (i+1)%2 laduje nastepny.
const prevIndexRef = React.useRef<number | null>(null);
const leaveRef = React.useRef(onLeave);
leaveRef.current = onLeave;
React.useEffect(() => {
const ps = players.current;
if (!ps.length || !items.length) return;
const item = items[index];
if (!item) return;
const active = ps[index % 2];
const standby = ps[(index + 1) % 2];
const prev = prevIndexRef.current;
if (prev !== null && prev !== index && items[prev]) {
const prevPlayer = ps[prev % 2];
let pos = 0;
try {
pos = prevPlayer.currentTime || 0;
} catch {
pos = 0;
}
leaveRef.current?.(prev, {
dwellMs: Date.now() - activatedAtRef.current,
loops: loopsRef.current,
positionSec: pos,
});
try {
prevPlayer.pause();
} catch {
// ignore
}
}
prevIndexRef.current = index;
activatedAtRef.current = Date.now();
loopsRef.current = 0;
ensureSource(active, clipSource(item));
active.muted = !soundOn;
active.playbackRate = 1.0;
try {
active.currentTime = 0;
active.play();
} catch {
// ignore
}
setActivePlayer(active);
const sub = active.addListener('playToEnd', () => {
loopsRef.current += 1;
});
const next = items[index + 1];
if (next) {
ensureSource(standby, clipSource(next));
try {
standby.pause();
} catch {
// ignore
}
}
Haptics.selectionAsync().catch(() => {});
return () => sub.remove();
}, [index, items, ensureSource, soundOn]);
// Zmiana dzwieku bez zmiany strony.
React.useEffect(() => {
if (activePlayer) activePlayer.muted = !soundOn;
}, [soundOn, activePlayer]);
if (!PagerViewImpl) return null;
const PagerView = PagerViewImpl;
return (
<PagerView
ref={ref}
style={styles.pager}
orientation="vertical"
initialPage={initialPage}
offscreenPageLimit={1}
overdrag
onPageSelected={(e: { nativeEvent: { position: number } }) => onPageSelected(e.nativeEvent.position)}
>
{items.map((it, i) => (
<View key={it.clip.id} style={styles.page} collapsable={false}>
<ClipCell
item={it}
active={i === index}
player={i === index ? activePlayer : null}
muted={!soundOn}
liked={isLiked(it.clip.id) || Boolean(it.clip.is_favorite)}
onToggleMute={onToggleSound}
onLike={() => onLike(it, i)}
onNotInterested={() => onNotInterested(it, i)}
onCreatorPress={onCreatorPress}
/>
</View>
))}
</PagerView>
);
}
const styles = StyleSheet.create({
pager: { flex: 1 },
page: { flex: 1 },
});

View file

@ -15,6 +15,7 @@ import { View } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { AppLockSettingsScreen } from './screens/AppLockSettingsScreen';
import { BlacklistScreen } from './screens/BlacklistScreen';
import { ClipCreatorScreen } from './screens/ClipCreatorScreen';
import { DiagnosticBrowserScreen } from './screens/DiagnosticBrowserScreen';
import { DonateScreen } from './screens/DonateScreen';
import { FavoritesScreen } from './screens/FavoritesScreen';
@ -71,6 +72,8 @@ export type RootStackParamList = {
// slownictwo GLOWNEGO feedu, wiec z Anime kazdy tag prowadzil donikad.
Tags: { vertical?: string } | undefined;
TagScenes: { slug: string; name: string; vertical?: string | null };
// Strona tworcy klipow (Edge): siatka jego klipow + pelny ekran z pionowym pagerem.
ClipCreator: { creator: string };
AppLockSettings: undefined;
Blacklist: undefined;
Donate: undefined;
@ -280,6 +283,7 @@ export function AppNavigator({ onLogout, client, appVersion }: AppNavigatorProps
component={DonateScreen}
options={{ title: 'Support project' }}
/>
<Stack.Screen name="ClipCreator" component={ClipCreatorScreen} options={{ title: '' }} />
<Stack.Screen
name="DiagnosticBrowser"
component={DiagnosticBrowserScreen}

View file

@ -0,0 +1,230 @@
/**
* Strona tworcy klipow: siatka jego klipow, tapniecie otwiera je na pelnym ekranie
* i dalej przewija sie w pionie, tak jak w Edge.
*
* Skad sie wziela: zgloszenie z apki 2026-09-15 („cannot tap username as it's hidden
* behind icon tray"). Pasek zaslanial podpis, ale pod nazwa tworcy i tak nic nie bylo.
*
* Dane: `GET /clips?creator=<nazwa>` (najnowsze pierwsze, strony po 60). Odtwarzanie
* i dwa playery to ten sam `ClipPager` co w Edge.
*
* Telemetria: wysylamy TYLKO like/unlike i „nie interesuje". Ogladania stad NIE licza sie
* do feedu: user wszedl na konkretnego tworce, wiec potraktowanie tego jak zwyklego
* ogladania zabetonowaloby feed na nim.
*/
import { MaterialCommunityIcons } from '@expo/vector-icons';
import { RouteProp, useNavigation, useRoute } from '@react-navigation/native';
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { useInfiniteQuery } from '@tanstack/react-query';
import { Image } from 'expo-image';
import React from 'react';
import { ActivityIndicator, FlatList, Modal, Pressable, StyleSheet, Text, View } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { useClient } from '../ClientContext';
import { ClipPager, clipSource } from '../components/swipe/ClipPager';
import { newSessionId } from '../lib/swipeTelemetry';
import type { RootStackParamList } from '../navigation';
import { fonts, theme } from '../theme';
import type { ClipFeedItem, ClipOut } from '../types';
const PAGE = 60;
const COLS = 3;
function asFeedItem(clip: ClipOut): ClipFeedItem {
return { clip, slot: 'creator', is_fresh: false };
}
function dur(sec: number | null): string {
if (!sec || sec <= 0) return '';
const s = Math.round(sec);
return s >= 60 ? `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}` : `0:${String(s).padStart(2, '0')}`;
}
export function ClipCreatorScreen() {
const client = useClient();
const navigation = useNavigation<NativeStackNavigationProp<RootStackParamList, 'ClipCreator'>>();
const route = useRoute<RouteProp<RootStackParamList, 'ClipCreator'>>();
const { creator } = route.params;
const insets = useSafeAreaInsets();
const [playing, setPlaying] = React.useState<number | null>(null);
const [soundOn, setSoundOn] = React.useState(false);
const [liked, setLiked] = React.useState<Set<string>>(new Set());
const sessionId = React.useRef(newSessionId());
const q = useInfiniteQuery({
queryKey: ['clips-by-creator', creator],
queryFn: ({ pageParam }) => client.listClips({ creator, limit: PAGE, offset: pageParam as number }),
initialPageParam: 0,
getNextPageParam: (last, all) => (last.has_more ? all.length * PAGE : undefined),
});
const items = React.useMemo<ClipFeedItem[]>(() => {
const out: ClipFeedItem[] = [];
for (const page of q.data?.pages ?? []) {
for (const clip of page.items) {
if (clipSource(asFeedItem(clip))) out.push(asFeedItem(clip));
}
}
return out;
}, [q.data]);
const total = q.data?.pages[0]?.total ?? null;
const verified = items.some((it) => it.clip.creator_verified);
React.useLayoutEffect(() => {
navigation.setOptions({ title: `@${creator}` });
}, [navigation, creator]);
const sendEvent = React.useCallback(
(clipId: string, kind: 'like' | 'unlike' | 'not_interested', reason?: string) => {
client
.postClipEvents(sessionId.current, [{ clip_id: clipId, kind, reason, slot: 'creator' }])
.catch(() => {});
},
[client],
);
const like = React.useCallback(
(item: ClipFeedItem) => {
const was = liked.has(item.clip.id) || Boolean(item.clip.is_favorite);
setLiked((s) => {
const n = new Set(s);
if (was) n.delete(item.clip.id);
else n.add(item.clip.id);
return n;
});
sendEvent(item.clip.id, was ? 'unlike' : 'like');
},
[liked, sendEvent],
);
return (
<View style={styles.root}>
<FlatList
data={items}
keyExtractor={(it) => it.clip.id}
numColumns={COLS}
removeClippedSubviews={false}
columnWrapperStyle={styles.row}
contentContainerStyle={{ padding: 8, paddingBottom: 24 }}
ListHeaderComponent={
<View style={styles.header}>
<Text style={styles.count}>
{total != null ? `${total} ${total === 1 ? 'clip' : 'clips'}` : `${items.length} clips`}
</Text>
{verified ? (
<View style={styles.verified}>
<MaterialCommunityIcons name="check-decagram" size={14} color={theme.accentSecondary} />
<Text style={styles.verifiedText}>verified</Text>
</View>
) : null}
</View>
}
renderItem={({ item, index }) => (
<Pressable style={styles.tile} onPress={() => setPlaying(index)}>
{item.clip.poster_url || item.clip.thumb_url ? (
<Image
source={{ uri: (item.clip.poster_url || item.clip.thumb_url) as string }}
style={StyleSheet.absoluteFill}
contentFit="cover"
transition={100}
/>
) : null}
<View style={styles.tileFoot}>
<Text style={styles.tileDur}>{dur(item.clip.duration_sec)}</Text>
{liked.has(item.clip.id) || item.clip.is_favorite ? (
<MaterialCommunityIcons name="heart" size={13} color={theme.accentText} />
) : null}
</View>
</Pressable>
)}
onEndReachedThreshold={0.4}
onEndReached={() => {
if (q.hasNextPage && !q.isFetchingNextPage) void q.fetchNextPage();
}}
ListEmptyComponent={
q.isLoading ? (
<ActivityIndicator color={theme.accentText} style={{ marginTop: 32 }} />
) : (
<Text style={styles.empty}>No clips from this creator right now.</Text>
)
}
ListFooterComponent={
q.isFetchingNextPage ? <ActivityIndicator color={theme.accentText} style={{ margin: 16 }} /> : null
}
/>
<Modal visible={playing !== null} animationType="slide" onRequestClose={() => setPlaying(null)}>
<View style={styles.player}>
{playing !== null ? (
<ClipPager
items={items}
index={playing}
initialPage={playing}
onPageSelected={setPlaying}
soundOn={soundOn}
onToggleSound={() => setSoundOn((v) => !v)}
isLiked={(id) => liked.has(id)}
onLike={like}
onNotInterested={(it) => {
sendEvent(it.clip.id, 'not_interested', `creator:${creator}`);
setPlaying(null);
}}
/>
) : null}
{/* Modal rysuje sie od samej gory ekranu, wiec bez insetu przycisk laduje pod
paskiem stanu i tapniecie idzie do systemu (sprawdzone na emulatorze). */}
<Pressable
style={[styles.close, { top: insets.top + 8 }]}
onPress={() => setPlaying(null)}
accessibilityLabel="Close"
hitSlop={8}
>
<MaterialCommunityIcons name="chevron-down" size={26} color="#fff" />
</Pressable>
</View>
</Modal>
</View>
);
}
const styles = StyleSheet.create({
root: { flex: 1, backgroundColor: theme.bg },
header: { flexDirection: 'row', alignItems: 'center', gap: 10, paddingHorizontal: 4, paddingBottom: 10 },
count: { color: theme.muted, fontFamily: fonts.body, fontSize: 13 },
verified: { flexDirection: 'row', alignItems: 'center', gap: 4 },
verifiedText: { color: theme.accentSecondary, fontFamily: fonts.medium, fontSize: 12 },
row: { gap: 6, marginBottom: 6 },
tile: {
// maxWidth, bo ostatni rzad bywa niepelny: bez tego jeden klip tworcy rozciagal
// sie na cala szerokosc ekranu (sprawdzone na emulatorze).
flex: 1,
maxWidth: `${100 / COLS}%`,
aspectRatio: 0.62,
borderRadius: 10,
overflow: 'hidden',
backgroundColor: theme.card,
justifyContent: 'flex-end',
},
tileFoot: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 6,
paddingVertical: 4,
backgroundColor: 'rgba(0,0,0,0.45)',
},
tileDur: { color: '#fff', fontFamily: fonts.mono, fontSize: 11 },
empty: { color: theme.muted, textAlign: 'center', marginTop: 40, paddingHorizontal: 24, lineHeight: 20 },
player: { flex: 1, backgroundColor: '#000' },
close: {
position: 'absolute',
left: 10,
width: 44,
height: 44,
borderRadius: 22,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'rgba(0,0,0,0.35)',
},
});

View file

@ -17,14 +17,16 @@
* serwer uczy sie wag i uklada 80 % kolejnych stron.
*/
import { MaterialCommunityIcons } from '@expo/vector-icons';
import { useNavigation } from '@react-navigation/native';
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { useInfiniteQuery, useQuery } from '@tanstack/react-query';
import * as Haptics from 'expo-haptics';
import { createVideoPlayer, type VideoPlayer } from 'expo-video';
import React from 'react';
import { ActivityIndicator, AppState, Modal, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
import { ActivityIndicator, Modal, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
import { useClient } from '../ClientContext';
import { ClipCell } from '../components/swipe/ClipCell';
import { ClipPager, clipSource, pagerAvailable, type ClipLeaveInfo } from '../components/swipe/ClipPager';
import type { RootStackParamList } from '../navigation';
import { NATIVE_RUNTIME, isNewerVersion } from '../lib/appVersion';
import { setBarHidden } from '../lib/bottomBar';
import { usePersistedState } from '../lib/persistedState';
@ -32,19 +34,9 @@ import { SwipeTelemetry, leaveKind, newSessionId } from '../lib/swipeTelemetry';
import { fonts, theme } from '../theme';
import type { ClipFeedItem, ClipOnboardingOptions } from '../types';
// Pager jest modulem natywnym: jest w APK od runtime 1.4. Wczesniejsze APK dostaja ten
// bundle przez OTA, wiec import musi byc miekki.
let PagerViewImpl: React.ComponentType<any> | null = null;
try {
// eslint-disable-next-line @typescript-eslint/no-var-requires
PagerViewImpl = require('react-native-pager-view').default ?? null;
} catch {
PagerViewImpl = null;
}
export const SWIPE_MIN_RUNTIME = '1.4';
export const swipeAvailable: boolean =
PagerViewImpl != null && !isNewerVersion(SWIPE_MIN_RUNTIME, NATIVE_RUNTIME || '0');
pagerAvailable && !isNewerVersion(SWIPE_MIN_RUNTIME, NATIVE_RUNTIME || '0');
const PAGE_SIZE = 10;
const PREFETCH_AT = 4;
@ -57,10 +49,6 @@ interface SwipePrefs {
}
const DEFAULT_PREFS: SwipePrefs = { soundOn: false, autoSwipeSec: 0, onboarded: false };
function sourceOf(item: ClipFeedItem): string | null {
return item.clip.hd_url || item.clip.sd_url || null;
}
export function SwipeScreen() {
const client = useClient();
const [prefs, setPrefs, prefsReady] = usePersistedState<SwipePrefs>('swipe_prefs', DEFAULT_PREFS);
@ -69,6 +57,7 @@ export function SwipeScreen() {
const [liked, setLiked] = React.useState<Set<string>>(new Set());
const [sheetItem, setSheetItem] = React.useState<ClipFeedItem | null>(null);
const pagerRef = React.useRef<any>(null);
const navigation = useNavigation<NativeStackNavigationProp<RootStackParamList>>();
// ---- dane ------------------------------------------------------------------------
const feed = useInfiniteQuery({
@ -84,7 +73,7 @@ export function SwipeScreen() {
const out: ClipFeedItem[] = [];
for (const page of feed.data?.pages ?? []) {
for (const it of page.items) {
if (!seen.has(it.clip.id) && sourceOf(it)) {
if (!seen.has(it.clip.id) && clipSource(it)) {
seen.add(it.clip.id);
out.push(it);
}
@ -106,8 +95,7 @@ export function SwipeScreen() {
// o jeden klip. Po wyjsciu z Edge pasek musi byc widoczny.
React.useEffect(() => () => setBarHidden(false), []);
const onPageSelected = React.useCallback(
(e: { nativeEvent: { position: number } }) => {
const next = e.nativeEvent.position;
(next: number) => {
if (next === 0 || next < index) setBarHidden(false);
else if (next > index) setBarHidden(true);
setIndex(next);
@ -124,111 +112,21 @@ export function SwipeScreen() {
return () => t.stop();
}, []);
// ---- dwa playery -----------------------------------------------------------------
const players = React.useRef<VideoPlayer[]>([]);
const loopsRef = React.useRef(0);
const activatedAtRef = React.useRef(0);
const [activePlayer, setActivePlayer] = React.useState<VideoPlayer | null>(null);
React.useEffect(() => {
// Tworzymy tylko na pierwszym planie (ExoPlayer w tle rzuca; wzor z PlayerScreen).
if (AppState.currentState !== 'active') return;
players.current = [createVideoPlayer(null), createVideoPlayer(null)];
for (const p of players.current) {
p.loop = true;
p.timeUpdateEventInterval = 0.25;
}
return () => {
for (const p of players.current) {
try {
p.release();
} catch {
// juz zwolniony
}
}
players.current = [];
};
}, []);
const loadedSrc = React.useRef<Map<VideoPlayer, string | null>>(new Map());
const ensureSource = React.useCallback((p: VideoPlayer, src: string | null) => {
if (loadedSrc.current.get(p) === src) return;
loadedSrc.current.set(p, src);
try {
p.replace(src ? { uri: src } : null);
} catch {
// brak zrodla / zwolniony player
}
}, []);
// Aktywacja strony `index`: player i%2 gra, (i+1)%2 laduje nastepny.
const prevIndexRef = React.useRef<number | null>(null);
React.useEffect(() => {
const ps = players.current;
if (!ps.length || !items.length) return;
const item = items[index];
if (!item) return;
const active = ps[index % 2];
const standby = ps[(index + 1) % 2];
// Zamkniecie poprzedniej strony: jedno zdarzenie z dwell.
const prev = prevIndexRef.current;
if (prev !== null && prev !== index && items[prev]) {
const prevPlayer = ps[prev % 2];
const dwell = Date.now() - activatedAtRef.current;
let pos = 0;
try {
pos = prevPlayer.currentTime || 0;
} catch {
pos = 0;
}
// ---- zejscie ze strony: jedno zdarzenie z dwell ------------------------------------
const onLeave = React.useCallback(
(prev: number, info: ClipLeaveInfo) => {
const left = items[prev];
if (!left) return;
telemetry.current?.push({
clip_id: items[prev].clip.id,
kind: leaveKind(dwell, loopsRef.current, pos, items[prev].clip.duration_sec),
dwell_ms: dwell,
slot: items[prev].slot,
clip_id: left.clip.id,
kind: leaveKind(info.dwellMs, info.loops, info.positionSec, left.clip.duration_sec),
dwell_ms: info.dwellMs,
slot: left.slot,
position: prev,
});
try {
prevPlayer.pause();
} catch {
// ignore
}
}
prevIndexRef.current = index;
activatedAtRef.current = Date.now();
loopsRef.current = 0;
ensureSource(active, sourceOf(item));
active.muted = !prefs.soundOn;
active.playbackRate = 1.0;
try {
active.currentTime = 0;
active.play();
} catch {
// ignore
}
setActivePlayer(active);
const sub = active.addListener('playToEnd', () => {
loopsRef.current += 1;
});
const next = items[index + 1];
if (next) {
ensureSource(standby, sourceOf(next));
try {
standby.pause();
} catch {
// ignore
}
}
Haptics.selectionAsync().catch(() => {});
return () => sub.remove();
}, [index, items, ensureSource, prefs.soundOn]);
// Zmiana dzwieku bez zmiany strony.
React.useEffect(() => {
if (activePlayer) activePlayer.muted = !prefs.soundOn;
}, [prefs.soundOn, activePlayer]);
},
[items],
);
// AutoSwipe: po N sekundach na stronie przechodzimy dalej.
React.useEffect(() => {
@ -276,7 +174,6 @@ export function SwipeScreen() {
telemetry.current?.stop();
telemetry.current = new SwipeTelemetry(client, newSessionId());
telemetry.current.start();
prevIndexRef.current = null;
setBarHidden(false);
setIndex(0);
setSeedKey((k) => k + 1);
@ -312,7 +209,7 @@ export function SwipeScreen() {
);
// ---- render --------------------------------------------------------------------------
if (!swipeAvailable || !PagerViewImpl) {
if (!swipeAvailable) {
return (
<View style={styles.center}>
<MaterialCommunityIcons name="gesture-swipe-vertical" size={40} color={theme.accentText} />
@ -321,36 +218,22 @@ export function SwipeScreen() {
</View>
);
}
const PagerView = PagerViewImpl;
const current = items[index];
return (
<View style={styles.root}>
{items.length ? (
<PagerView
ref={pagerRef}
style={styles.pager}
orientation="vertical"
initialPage={0}
offscreenPageLimit={1}
overdrag
<ClipPager
items={items}
index={index}
pagerRef={pagerRef}
onPageSelected={onPageSelected}
>
{items.map((it, i) => (
<View key={it.clip.id} style={styles.page} collapsable={false}>
<ClipCell
item={it}
active={i === index}
player={i === index ? activePlayer : null}
muted={!prefs.soundOn}
liked={liked.has(it.clip.id) || Boolean(it.clip.is_favorite)}
onToggleMute={toggleMute}
onLike={() => likeItem(it, i)}
onNotInterested={() => setSheetItem(it)}
/>
</View>
))}
</PagerView>
onLeave={onLeave}
soundOn={prefs.soundOn}
onToggleSound={toggleMute}
isLiked={(id) => liked.has(id)}
onLike={likeItem}
onNotInterested={(it) => setSheetItem(it)}
onCreatorPress={(creator) => navigation.navigate('ClipCreator', { creator })}
/>
) : feed.isLoading ? (
<View style={styles.center}>
<ActivityIndicator color={theme.accentText} />
@ -487,8 +370,6 @@ function OnboardingModal({
const styles = StyleSheet.create({
root: { flex: 1, backgroundColor: '#000' },
pager: { flex: 1 },
page: { flex: 1 },
center: { flex: 1, alignItems: 'center', justifyContent: 'center', padding: 28, backgroundColor: theme.bg },
centerTitle: { color: theme.fg, fontFamily: fonts.display, fontSize: 18, marginTop: 12, textAlign: 'center' },
centerText: { color: theme.muted, fontFamily: fonts.body, fontSize: 14, marginTop: 8, textAlign: 'center', lineHeight: 20 },

View file

@ -520,4 +520,6 @@ export interface ClipListOut {
limit: number;
offset: number;
has_more: boolean;
/** Liczba wszystkich klipow przy filtrze po tworcy; null przy innych listach. */
total?: number | null;
}