diff --git a/app/api/scenes.py b/app/api/scenes.py index a364c7c..eb568af 100644 --- a/app/api/scenes.py +++ b/app/api/scenes.py @@ -387,13 +387,19 @@ def list_scenes( total_capped = False total: int | None = _default_scene_count(session) if _is_pure_default else None - # Sort: zawsze tie-break po created_at desc dla determinizmu paginacji. + # Sort: KAŻDY tryb kończy się `Scene.id.desc()` — unikalny PK jako ostateczny + # tie-break. Bez tego przy OFFSET-paginacji sceny z IDENTYCZNYM created_at (masowy + # ingest = ten sam timestamp co do sekundy) i release_date mają NIEustalony porządek, + # więc kolejne strony nakładają się na siebie → feed "powtarza się" przy scrollu + # (report 304c9258). created_at desc dalej pierwszy, więc index-walk zostaje. if sort == "release_date": ordered = base.order_by( - Scene.release_date.desc().nullslast(), Scene.created_at.desc() + Scene.release_date.desc().nullslast(), Scene.created_at.desc(), Scene.id.desc() ) elif sort == "title": - ordered = base.order_by(Scene.title_normalized.asc(), Scene.created_at.desc()) + ordered = base.order_by( + Scene.title_normalized.asc(), Scene.created_at.desc(), Scene.id.desc() + ) elif sort == "studio": # Sceny bez studio na końcu; w obrębie studio — najświeższe pierwsze. ordered = ( @@ -402,11 +408,12 @@ def list_scenes( Studio.name_normalized.asc().nullslast(), Scene.release_date.desc().nullslast(), Scene.created_at.desc(), + Scene.id.desc(), ) ) else: # created_at ordered = base.order_by( - Scene.created_at.desc(), Scene.release_date.desc().nullslast() + Scene.created_at.desc(), Scene.release_date.desc().nullslast(), Scene.id.desc() ) # Fetch per_page+1 — obecność (per_page+1)-szego wiersza = jest kolejna strona. diff --git a/mobile/src/changelog.ts b/mobile/src/changelog.ts index 5c63ea9..eab670d 100644 --- a/mobile/src/changelog.ts +++ b/mobile/src/changelog.ts @@ -16,6 +16,15 @@ export type ChangelogEntry = { }; export const CHANGELOG: ChangelogEntry[] = [ + { + id: '2026-07-07', + date: 'July 2026', + items: [ + 'The Scenes feed no longer repeats the same videos as you scroll down.', + 'Hold to fast-forward (2x) now mutes the sped-up audio until you let go.', + 'hqfap is back: the site returned with working video, so its scenes are being re-added.', + ], + }, { id: '2026-07-05b', date: 'July 2026', diff --git a/mobile/src/screens/PlayerScreen.tsx b/mobile/src/screens/PlayerScreen.tsx index 19132a4..56bcd11 100644 --- a/mobile/src/screens/PlayerScreen.tsx +++ b/mobile/src/screens/PlayerScreen.tsx @@ -549,6 +549,8 @@ function NativeVideoPlayer({ params }: { params: RouteParams }) { [player, showSeekHint], ); + // Stan wyciszenia SPRZED 2× (żeby przywrócić dokładnie preferencję usera po puszczeniu). + const preSpeedMutedRef = React.useRef(true); // Long-press → 2× speed dopóki trzymasz. minDuration 220ms żeby nie konfliktowało // z double-tap (drugi tap trwa krócej). onTouchesUp finalizuje gest. const longPress = React.useMemo( @@ -558,6 +560,10 @@ function NativeVideoPlayer({ params }: { params: RouteParams }) { .maxDistance(30) .onStart(() => { try { + // Wycisz na czas 2× — przyspieszony dźwięk (chipmunk) jest nieprzyjemny + // (report 35bbf428). Zapamiętujemy stan usera i przywracamy po puszczeniu. + preSpeedMutedRef.current = player.muted; + player.muted = true; player.playbackRate = 2.0; } catch { // ignore @@ -575,6 +581,7 @@ function NativeVideoPlayer({ params }: { params: RouteParams }) { .onEnd(() => { try { player.playbackRate = 1.0; + player.muted = preSpeedMutedRef.current; } catch { // ignore } @@ -585,6 +592,7 @@ function NativeVideoPlayer({ params }: { params: RouteParams }) { if (player.playbackRate !== 1.0) { player.playbackRate = 1.0; } + player.muted = preSpeedMutedRef.current; } catch { // ignore } diff --git a/mobile/src/screens/ScenesScreen.tsx b/mobile/src/screens/ScenesScreen.tsx index fba528f..8fa88a6 100644 --- a/mobile/src/screens/ScenesScreen.tsx +++ b/mobile/src/screens/ScenesScreen.tsx @@ -76,7 +76,15 @@ export function ScenesScreen() { return more ? lastPage.page + 1 : undefined; }, }); - const items = data?.pages.flatMap((p) => p.items) ?? []; + // Dedup po id przy sklejaniu stron. OFFSET-paginacja + ciągły ingest = kolejne strony + // potrafią nałożyć się na siebie (nowe sceny zsuwają offset w dół), przez co ta sama + // scena wracała w feedzie przy scrollu (report 304c9258). Zostawiamy pierwsze + // wystąpienie (najświeższe). Backend ma teraz też deterministyczny tie-break (Scene.id). + const items = React.useMemo(() => { + const flat = data?.pages.flatMap((p) => p.items) ?? []; + const seen = new Set(); + return flat.filter((s) => (seen.has(s.id) ? false : (seen.add(s.id), true))); + }, [data?.pages]); const total = data?.pages[0]?.total ?? 0; // total bywa bounded ("1000+") dla list filtrowanych (q/tag) — patrz backend _COUNT_CAP. const totalLabel = `${total}${data?.pages[0]?.total_capped ? '+' : ''}`;