fix(mobile): stop Scenes feed repeating on scroll + mute audio during 2x hold

- Scenes feed repeated the same videos on scroll (report 304c9258): OFFSET
  pagination + continuous ingest means new scenes shift the offset, so later pages
  re-include earlier rows. Backend now appends Scene.id as a unique final tiebreaker
  to every sort (deterministic order), and the mobile Scenes list dedups by scene id
  when concatenating pages so a drifted overlap can never show a duplicate.
- Player: holding to fast-forward at 2x now mutes the sped-up (chipmunk) audio for
  the duration of the hold and restores the user's real mute preference on release
  (report 35bbf428).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
goon-foss 2026-07-07 23:59:28 +02:00
parent b66817175a
commit e30f6830f4
4 changed files with 37 additions and 5 deletions

View file

@ -387,13 +387,19 @@ def list_scenes(
total_capped = False total_capped = False
total: int | None = _default_scene_count(session) if _is_pure_default else None 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": if sort == "release_date":
ordered = base.order_by( 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": 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": elif sort == "studio":
# Sceny bez studio na końcu; w obrębie studio — najświeższe pierwsze. # Sceny bez studio na końcu; w obrębie studio — najświeższe pierwsze.
ordered = ( ordered = (
@ -402,11 +408,12 @@ def list_scenes(
Studio.name_normalized.asc().nullslast(), Studio.name_normalized.asc().nullslast(),
Scene.release_date.desc().nullslast(), Scene.release_date.desc().nullslast(),
Scene.created_at.desc(), Scene.created_at.desc(),
Scene.id.desc(),
) )
) )
else: # created_at else: # created_at
ordered = base.order_by( 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. # Fetch per_page+1 — obecność (per_page+1)-szego wiersza = jest kolejna strona.

View file

@ -16,6 +16,15 @@ export type ChangelogEntry = {
}; };
export const CHANGELOG: 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', id: '2026-07-05b',
date: 'July 2026', date: 'July 2026',

View file

@ -549,6 +549,8 @@ function NativeVideoPlayer({ params }: { params: RouteParams }) {
[player, showSeekHint], [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 // Long-press → 2× speed dopóki trzymasz. minDuration 220ms żeby nie konfliktowało
// z double-tap (drugi tap trwa krócej). onTouchesUp finalizuje gest. // z double-tap (drugi tap trwa krócej). onTouchesUp finalizuje gest.
const longPress = React.useMemo( const longPress = React.useMemo(
@ -558,6 +560,10 @@ function NativeVideoPlayer({ params }: { params: RouteParams }) {
.maxDistance(30) .maxDistance(30)
.onStart(() => { .onStart(() => {
try { 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; player.playbackRate = 2.0;
} catch { } catch {
// ignore // ignore
@ -575,6 +581,7 @@ function NativeVideoPlayer({ params }: { params: RouteParams }) {
.onEnd(() => { .onEnd(() => {
try { try {
player.playbackRate = 1.0; player.playbackRate = 1.0;
player.muted = preSpeedMutedRef.current;
} catch { } catch {
// ignore // ignore
} }
@ -585,6 +592,7 @@ function NativeVideoPlayer({ params }: { params: RouteParams }) {
if (player.playbackRate !== 1.0) { if (player.playbackRate !== 1.0) {
player.playbackRate = 1.0; player.playbackRate = 1.0;
} }
player.muted = preSpeedMutedRef.current;
} catch { } catch {
// ignore // ignore
} }

View file

@ -76,7 +76,15 @@ export function ScenesScreen() {
return more ? lastPage.page + 1 : undefined; 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<string>();
return flat.filter((s) => (seen.has(s.id) ? false : (seen.add(s.id), true)));
}, [data?.pages]);
const total = data?.pages[0]?.total ?? 0; const total = data?.pages[0]?.total ?? 0;
// total bywa bounded ("1000+") dla list filtrowanych (q/tag) — patrz backend _COUNT_CAP. // total bywa bounded ("1000+") dla list filtrowanych (q/tag) — patrz backend _COUNT_CAP.
const totalLabel = `${total}${data?.pages[0]?.total_capped ? '+' : ''}`; const totalLabel = `${total}${data?.pages[0]?.total_capped ? '+' : ''}`;