fix(mobile): stop Scenes feed repeating on scroll + mute audio during 2x hold
Some checks are pending
Backend tests / test (push) Waiting to run

- 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:
jtrzupek 2026-07-07 23:59:28 +02:00
parent 28f56d646c
commit fa697726e9
4 changed files with 37 additions and 5 deletions

View file

@ -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.

View file

@ -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',

View file

@ -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
}

View file

@ -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<string>();
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 ? '+' : ''}`;