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:
parent
7d5dd450f7
commit
bfa3444de2
9 changed files with 538 additions and 157 deletions
|
|
@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
214
mobile/src/components/swipe/ClipPager.tsx
Normal file
214
mobile/src/components/swipe/ClipPager.tsx
Normal 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 },
|
||||
});
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
230
mobile/src/screens/ClipCreatorScreen.tsx
Normal file
230
mobile/src/screens/ClipCreatorScreen.tsx
Normal 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)',
|
||||
},
|
||||
});
|
||||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue