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:
parent
b66817175a
commit
e30f6830f4
4 changed files with 37 additions and 5 deletions
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 ? '+' : ''}`;
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue