diff --git a/app/api/clips.py b/app/api/clips.py index 70c8237..4777293 100644 --- a/app/api/clips.py +++ b/app/api/clips.py @@ -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, ) diff --git a/mobile/src/api.ts b/mobile/src/api.ts index 5031b47..94b5f2d 100644 --- a/mobile/src/api.ts +++ b/mobile/src/api.ts @@ -686,6 +686,23 @@ export class GoonClient { await this.request(`/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 { + 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 { return this.request(`/clips/favorites?limit=${limit}&offset=${offset}`); } diff --git a/mobile/src/changelog.ts b/mobile/src/changelog.ts index c3e5157..772f4b7 100644 --- a/mobile/src/changelog.ts +++ b/mobile/src/changelog.ts @@ -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', diff --git a/mobile/src/components/swipe/ClipCell.tsx b/mobile/src/components/swipe/ClipCell.tsx index 35259e0..6af24d8 100644 --- a/mobile/src/components/swipe/ClipCell.tsx +++ b/mobile/src/components/swipe/ClipCell.tsx @@ -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 */} - + {/* Nazwa tworcy prowadzi do jego strony (zgloszenie 2026-09-15: „cannot tap + username"). Bez tworcy (albo bez obslugi) zostaje sam napis. */} + clip.creator && onCreatorPress?.(clip.creator)} + accessibilityRole={clip.creator && onCreatorPress ? 'link' : undefined} + accessibilityLabel={clip.creator ? `Clips by ${clip.creator}` : undefined} + hitSlop={8} + > {clip.creator ? `@${clip.creator}` : clip.source} {clip.creator_verified ? ( ) : null} + {clip.creator && onCreatorPress ? ( + + ) : null} {item.is_fresh ? NEW : null} - + {clip.title ? ( {clip.title} diff --git a/mobile/src/components/swipe/ClipPager.tsx b/mobile/src/components/swipe/ClipPager.tsx new file mode 100644 index 0000000..2bc01f1 --- /dev/null +++ b/mobile/src/components/swipe/ClipPager.tsx @@ -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 | 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; +} + +/** 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(null); + const ref = pagerRef ?? localRef; + + const players = React.useRef([]); + const loopsRef = React.useRef(0); + const activatedAtRef = React.useRef(0); + const [activePlayer, setActivePlayer] = React.useState(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>(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(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 ( + onPageSelected(e.nativeEvent.position)} + > + {items.map((it, i) => ( + + onLike(it, i)} + onNotInterested={() => onNotInterested(it, i)} + onCreatorPress={onCreatorPress} + /> + + ))} + + ); +} + +const styles = StyleSheet.create({ + pager: { flex: 1 }, + page: { flex: 1 }, +}); diff --git a/mobile/src/navigation.tsx b/mobile/src/navigation.tsx index ce809be..21ffdf2 100644 --- a/mobile/src/navigation.tsx +++ b/mobile/src/navigation.tsx @@ -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' }} /> +