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
|
limit: int
|
||||||
offset: int
|
offset: int
|
||||||
has_more: bool
|
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):
|
class ClipStatsOut(BaseModel):
|
||||||
|
|
@ -639,8 +642,17 @@ def list_clips(
|
||||||
ids = [r.id for r in rows]
|
ids = [r.id for r in rows]
|
||||||
tags = _tags_for(session, ids)
|
tags = _tags_for(session, ids)
|
||||||
favs = _favorites_for(session, device_id, 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(
|
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' });
|
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> {
|
async listClipFavorites(limit = 50, offset = 0): Promise<ClipListOut> {
|
||||||
return this.request(`/clips/favorites?limit=${limit}&offset=${offset}`);
|
return this.request(`/clips/favorites?limit=${limit}&offset=${offset}`);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,14 @@ export type ChangelogEntry = {
|
||||||
};
|
};
|
||||||
|
|
||||||
export const CHANGELOG: 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',
|
id: '2026-09-16a',
|
||||||
date: 'September 2026',
|
date: 'September 2026',
|
||||||
|
|
|
||||||
|
|
@ -40,9 +40,10 @@ interface Props {
|
||||||
onLike: () => void;
|
onLike: () => void;
|
||||||
onNotInterested: () => void;
|
onNotInterested: () => void;
|
||||||
onTagPress?: (tag: string) => 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 clip = item.clip;
|
||||||
const [progress, setProgress] = React.useState(0);
|
const [progress, setProgress] = React.useState(0);
|
||||||
const [ready, setReady] = React.useState(false);
|
const [ready, setReady] = React.useState(false);
|
||||||
|
|
@ -201,15 +202,27 @@ function ClipCellBase({ item, active, player, muted, liked, onToggleMute, onLike
|
||||||
|
|
||||||
{/* Podpis: tworca, tytul, tagi */}
|
{/* Podpis: tworca, tytul, tagi */}
|
||||||
<View style={styles.caption} pointerEvents="box-none">
|
<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}>
|
<Text style={styles.creator} numberOfLines={1}>
|
||||||
{clip.creator ? `@${clip.creator}` : clip.source}
|
{clip.creator ? `@${clip.creator}` : clip.source}
|
||||||
</Text>
|
</Text>
|
||||||
{clip.creator_verified ? (
|
{clip.creator_verified ? (
|
||||||
<MaterialCommunityIcons name="check-decagram" size={15} color={theme.accentSecondary} style={{ marginLeft: 4 }} />
|
<MaterialCommunityIcons name="check-decagram" size={15} color={theme.accentSecondary} style={{ marginLeft: 4 }} />
|
||||||
) : null}
|
) : 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}
|
{item.is_fresh ? <Text style={styles.fresh}>NEW</Text> : null}
|
||||||
</View>
|
</Pressable>
|
||||||
{clip.title ? (
|
{clip.title ? (
|
||||||
<Text style={styles.title} numberOfLines={2}>
|
<Text style={styles.title} numberOfLines={2}>
|
||||||
{clip.title}
|
{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 { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
import { AppLockSettingsScreen } from './screens/AppLockSettingsScreen';
|
import { AppLockSettingsScreen } from './screens/AppLockSettingsScreen';
|
||||||
import { BlacklistScreen } from './screens/BlacklistScreen';
|
import { BlacklistScreen } from './screens/BlacklistScreen';
|
||||||
|
import { ClipCreatorScreen } from './screens/ClipCreatorScreen';
|
||||||
import { DiagnosticBrowserScreen } from './screens/DiagnosticBrowserScreen';
|
import { DiagnosticBrowserScreen } from './screens/DiagnosticBrowserScreen';
|
||||||
import { DonateScreen } from './screens/DonateScreen';
|
import { DonateScreen } from './screens/DonateScreen';
|
||||||
import { FavoritesScreen } from './screens/FavoritesScreen';
|
import { FavoritesScreen } from './screens/FavoritesScreen';
|
||||||
|
|
@ -71,6 +72,8 @@ export type RootStackParamList = {
|
||||||
// slownictwo GLOWNEGO feedu, wiec z Anime kazdy tag prowadzil donikad.
|
// slownictwo GLOWNEGO feedu, wiec z Anime kazdy tag prowadzil donikad.
|
||||||
Tags: { vertical?: string } | undefined;
|
Tags: { vertical?: string } | undefined;
|
||||||
TagScenes: { slug: string; name: string; vertical?: string | null };
|
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;
|
AppLockSettings: undefined;
|
||||||
Blacklist: undefined;
|
Blacklist: undefined;
|
||||||
Donate: undefined;
|
Donate: undefined;
|
||||||
|
|
@ -280,6 +283,7 @@ export function AppNavigator({ onLogout, client, appVersion }: AppNavigatorProps
|
||||||
component={DonateScreen}
|
component={DonateScreen}
|
||||||
options={{ title: 'Support project' }}
|
options={{ title: 'Support project' }}
|
||||||
/>
|
/>
|
||||||
|
<Stack.Screen name="ClipCreator" component={ClipCreatorScreen} options={{ title: '' }} />
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name="DiagnosticBrowser"
|
name="DiagnosticBrowser"
|
||||||
component={DiagnosticBrowserScreen}
|
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.
|
* serwer uczy sie wag i uklada 80 % kolejnych stron.
|
||||||
*/
|
*/
|
||||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
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 { useInfiniteQuery, useQuery } from '@tanstack/react-query';
|
||||||
import * as Haptics from 'expo-haptics';
|
import * as Haptics from 'expo-haptics';
|
||||||
import { createVideoPlayer, type VideoPlayer } from 'expo-video';
|
|
||||||
import React from 'react';
|
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 { 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 { NATIVE_RUNTIME, isNewerVersion } from '../lib/appVersion';
|
||||||
import { setBarHidden } from '../lib/bottomBar';
|
import { setBarHidden } from '../lib/bottomBar';
|
||||||
import { usePersistedState } from '../lib/persistedState';
|
import { usePersistedState } from '../lib/persistedState';
|
||||||
|
|
@ -32,19 +34,9 @@ import { SwipeTelemetry, leaveKind, newSessionId } from '../lib/swipeTelemetry';
|
||||||
import { fonts, theme } from '../theme';
|
import { fonts, theme } from '../theme';
|
||||||
import type { ClipFeedItem, ClipOnboardingOptions } from '../types';
|
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 SWIPE_MIN_RUNTIME = '1.4';
|
||||||
export const swipeAvailable: boolean =
|
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 PAGE_SIZE = 10;
|
||||||
const PREFETCH_AT = 4;
|
const PREFETCH_AT = 4;
|
||||||
|
|
@ -57,10 +49,6 @@ interface SwipePrefs {
|
||||||
}
|
}
|
||||||
const DEFAULT_PREFS: SwipePrefs = { soundOn: false, autoSwipeSec: 0, onboarded: false };
|
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() {
|
export function SwipeScreen() {
|
||||||
const client = useClient();
|
const client = useClient();
|
||||||
const [prefs, setPrefs, prefsReady] = usePersistedState<SwipePrefs>('swipe_prefs', DEFAULT_PREFS);
|
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 [liked, setLiked] = React.useState<Set<string>>(new Set());
|
||||||
const [sheetItem, setSheetItem] = React.useState<ClipFeedItem | null>(null);
|
const [sheetItem, setSheetItem] = React.useState<ClipFeedItem | null>(null);
|
||||||
const pagerRef = React.useRef<any>(null);
|
const pagerRef = React.useRef<any>(null);
|
||||||
|
const navigation = useNavigation<NativeStackNavigationProp<RootStackParamList>>();
|
||||||
|
|
||||||
// ---- dane ------------------------------------------------------------------------
|
// ---- dane ------------------------------------------------------------------------
|
||||||
const feed = useInfiniteQuery({
|
const feed = useInfiniteQuery({
|
||||||
|
|
@ -84,7 +73,7 @@ export function SwipeScreen() {
|
||||||
const out: ClipFeedItem[] = [];
|
const out: ClipFeedItem[] = [];
|
||||||
for (const page of feed.data?.pages ?? []) {
|
for (const page of feed.data?.pages ?? []) {
|
||||||
for (const it of page.items) {
|
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);
|
seen.add(it.clip.id);
|
||||||
out.push(it);
|
out.push(it);
|
||||||
}
|
}
|
||||||
|
|
@ -106,8 +95,7 @@ export function SwipeScreen() {
|
||||||
// o jeden klip. Po wyjsciu z Edge pasek musi byc widoczny.
|
// o jeden klip. Po wyjsciu z Edge pasek musi byc widoczny.
|
||||||
React.useEffect(() => () => setBarHidden(false), []);
|
React.useEffect(() => () => setBarHidden(false), []);
|
||||||
const onPageSelected = React.useCallback(
|
const onPageSelected = React.useCallback(
|
||||||
(e: { nativeEvent: { position: number } }) => {
|
(next: number) => {
|
||||||
const next = e.nativeEvent.position;
|
|
||||||
if (next === 0 || next < index) setBarHidden(false);
|
if (next === 0 || next < index) setBarHidden(false);
|
||||||
else if (next > index) setBarHidden(true);
|
else if (next > index) setBarHidden(true);
|
||||||
setIndex(next);
|
setIndex(next);
|
||||||
|
|
@ -124,111 +112,21 @@ export function SwipeScreen() {
|
||||||
return () => t.stop();
|
return () => t.stop();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// ---- dwa playery -----------------------------------------------------------------
|
// ---- zejscie ze strony: jedno zdarzenie z dwell ------------------------------------
|
||||||
const players = React.useRef<VideoPlayer[]>([]);
|
const onLeave = React.useCallback(
|
||||||
const loopsRef = React.useRef(0);
|
(prev: number, info: ClipLeaveInfo) => {
|
||||||
const activatedAtRef = React.useRef(0);
|
const left = items[prev];
|
||||||
const [activePlayer, setActivePlayer] = React.useState<VideoPlayer | null>(null);
|
if (!left) return;
|
||||||
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;
|
|
||||||
}
|
|
||||||
telemetry.current?.push({
|
telemetry.current?.push({
|
||||||
clip_id: items[prev].clip.id,
|
clip_id: left.clip.id,
|
||||||
kind: leaveKind(dwell, loopsRef.current, pos, items[prev].clip.duration_sec),
|
kind: leaveKind(info.dwellMs, info.loops, info.positionSec, left.clip.duration_sec),
|
||||||
dwell_ms: dwell,
|
dwell_ms: info.dwellMs,
|
||||||
slot: items[prev].slot,
|
slot: left.slot,
|
||||||
position: prev,
|
position: prev,
|
||||||
});
|
});
|
||||||
try {
|
},
|
||||||
prevPlayer.pause();
|
[items],
|
||||||
} 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]);
|
|
||||||
|
|
||||||
// AutoSwipe: po N sekundach na stronie przechodzimy dalej.
|
// AutoSwipe: po N sekundach na stronie przechodzimy dalej.
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
|
|
@ -276,7 +174,6 @@ export function SwipeScreen() {
|
||||||
telemetry.current?.stop();
|
telemetry.current?.stop();
|
||||||
telemetry.current = new SwipeTelemetry(client, newSessionId());
|
telemetry.current = new SwipeTelemetry(client, newSessionId());
|
||||||
telemetry.current.start();
|
telemetry.current.start();
|
||||||
prevIndexRef.current = null;
|
|
||||||
setBarHidden(false);
|
setBarHidden(false);
|
||||||
setIndex(0);
|
setIndex(0);
|
||||||
setSeedKey((k) => k + 1);
|
setSeedKey((k) => k + 1);
|
||||||
|
|
@ -312,7 +209,7 @@ export function SwipeScreen() {
|
||||||
);
|
);
|
||||||
|
|
||||||
// ---- render --------------------------------------------------------------------------
|
// ---- render --------------------------------------------------------------------------
|
||||||
if (!swipeAvailable || !PagerViewImpl) {
|
if (!swipeAvailable) {
|
||||||
return (
|
return (
|
||||||
<View style={styles.center}>
|
<View style={styles.center}>
|
||||||
<MaterialCommunityIcons name="gesture-swipe-vertical" size={40} color={theme.accentText} />
|
<MaterialCommunityIcons name="gesture-swipe-vertical" size={40} color={theme.accentText} />
|
||||||
|
|
@ -321,36 +218,22 @@ export function SwipeScreen() {
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const PagerView = PagerViewImpl;
|
|
||||||
const current = items[index];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.root}>
|
<View style={styles.root}>
|
||||||
{items.length ? (
|
{items.length ? (
|
||||||
<PagerView
|
<ClipPager
|
||||||
ref={pagerRef}
|
items={items}
|
||||||
style={styles.pager}
|
index={index}
|
||||||
orientation="vertical"
|
pagerRef={pagerRef}
|
||||||
initialPage={0}
|
|
||||||
offscreenPageLimit={1}
|
|
||||||
overdrag
|
|
||||||
onPageSelected={onPageSelected}
|
onPageSelected={onPageSelected}
|
||||||
>
|
onLeave={onLeave}
|
||||||
{items.map((it, i) => (
|
soundOn={prefs.soundOn}
|
||||||
<View key={it.clip.id} style={styles.page} collapsable={false}>
|
onToggleSound={toggleMute}
|
||||||
<ClipCell
|
isLiked={(id) => liked.has(id)}
|
||||||
item={it}
|
onLike={likeItem}
|
||||||
active={i === index}
|
onNotInterested={(it) => setSheetItem(it)}
|
||||||
player={i === index ? activePlayer : null}
|
onCreatorPress={(creator) => navigation.navigate('ClipCreator', { creator })}
|
||||||
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>
|
|
||||||
) : feed.isLoading ? (
|
) : feed.isLoading ? (
|
||||||
<View style={styles.center}>
|
<View style={styles.center}>
|
||||||
<ActivityIndicator color={theme.accentText} />
|
<ActivityIndicator color={theme.accentText} />
|
||||||
|
|
@ -487,8 +370,6 @@ function OnboardingModal({
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
root: { flex: 1, backgroundColor: '#000' },
|
root: { flex: 1, backgroundColor: '#000' },
|
||||||
pager: { flex: 1 },
|
|
||||||
page: { flex: 1 },
|
|
||||||
center: { flex: 1, alignItems: 'center', justifyContent: 'center', padding: 28, backgroundColor: theme.bg },
|
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' },
|
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 },
|
centerText: { color: theme.muted, fontFamily: fonts.body, fontSize: 14, marginTop: 8, textAlign: 'center', lineHeight: 20 },
|
||||||
|
|
|
||||||
|
|
@ -520,4 +520,6 @@ export interface ClipListOut {
|
||||||
limit: number;
|
limit: number;
|
||||||
offset: number;
|
offset: number;
|
||||||
has_more: boolean;
|
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