fix(player): short-video auto-watch + player cleanups

BUG (YB1): the auto-watch checkpoint marked a video watched once position
crossed `duration - FINISH_MARGIN` (10s). For clips shorter than ~10s that
threshold is negative, and for ~11-20s clips it's only a few seconds in, so the
5s checkpoint tick marked short videos watched almost immediately (clearing their
resume position). Cap the margin at half the clip: Math.min(FINISH_MARGIN, dur/2).

Cleanups (behavior-neutral):
- Extract format.formatDate(iso, lang) — the day/month/year toLocaleDateString
  option object was duplicated verbatim in PlayerModal (fullDate) and VideoCard.
  (ChannelPage's "joined" is month/year only, so it's left as-is.)
- The in-bar prev/next buttons now reuse goPrev/goNext instead of re-implementing
  the step + bounds inline (they already exist for the side arrows + keyboard).
- Memoize savedPrefs so the ['me'] cache read + spread runs once, not on every
  render (it only seeds the initial autoMode/loopMode state).

tsc green, knip clean, localdev boots healthy.
This commit is contained in:
npeter83 2026-07-11 18:49:56 +02:00
parent c0e941bc0c
commit 31c1284eb7
3 changed files with 26 additions and 26 deletions

View file

@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query";
@ -7,7 +7,7 @@ import Avatar from "./Avatar";
import AddToPlaylist from "./AddToPlaylist"; import AddToPlaylist from "./AddToPlaylist";
import DownloadButton from "./DownloadButton"; import DownloadButton from "./DownloadButton";
import { api, type Video } from "../lib/api"; import { api, type Video } from "../lib/api";
import { channelYouTubeUrl, formatDuration, formatViews, relativeTime } from "../lib/format"; import { channelYouTubeUrl, formatDate, formatDuration, formatViews, relativeTime } from "../lib/format";
import { renderDescription } from "../lib/descriptionLinks"; import { renderDescription } from "../lib/descriptionLinks";
import { useBackToClose } from "../lib/history"; import { useBackToClose } from "../lib/history";
@ -84,14 +84,7 @@ export default function PlayerModal({
// non-embeddable still surface the existing playback-error overlay when reached. // non-embeddable still surface the existing playback-error overlay when reached.
const [queue] = useState(() => queueProp); const [queue] = useState(() => queueProp);
// Precise upload date shown inline after the relative time (e.g. "9 yr ago · 12 Jan 2017"). // Precise upload date shown inline after the relative time (e.g. "9 yr ago · 12 Jan 2017").
const fullDate = (d: string | null | undefined) => const fullDate = (d: string | null | undefined) => formatDate(d, i18n.language);
d
? new Date(d).toLocaleDateString(i18n.language, {
year: "numeric",
month: "short",
day: "numeric",
})
: "";
// Track the playing item by id (not a frozen index) so it survives the queue changing // Track the playing item by id (not a frozen index) so it survives the queue changing
// under us — e.g. an item removed in another tab. The index is derived from the *live* // under us — e.g. an item removed in another tab. The index is derived from the *live*
// queue, so the N / M counter and prev/next neighbours stay correct without disrupting // queue, so the N / M counter and prev/next neighbours stay correct without disrupting
@ -235,8 +228,14 @@ export default function PlayerModal({
// Auto-advance + loop are persistent per-user settings. Read the current values from the cached // Auto-advance + loop are persistent per-user settings. Read the current values from the cached
// `me`, mirror them in local state for instant UI, and write both to the server + cache on change // `me`, mirror them in local state for instant UI, and write both to the server + cache on change
// so they apply to every player and survive reloads. A ref feeds the (once-bound) end handler. // so they apply to every player and survive reloads. A ref feeds the (once-bound) end handler.
const savedPrefs = (qc.getQueryData<{ preferences?: Record<string, unknown> }>(["me"]) const savedPrefs = useMemo(
?.preferences ?? {}) as { playerAutoAdvance?: AutoMode; playerLoop?: LoopMode }; () =>
(qc.getQueryData<{ preferences?: Record<string, unknown> }>(["me"])?.preferences ?? {}) as {
playerAutoAdvance?: AutoMode;
playerLoop?: LoopMode;
},
[qc]
);
const [autoMode, setAutoMode] = useState<AutoMode>(savedPrefs.playerAutoAdvance ?? "off"); const [autoMode, setAutoMode] = useState<AutoMode>(savedPrefs.playerAutoAdvance ?? "off");
const [loopMode, setLoopMode] = useState<LoopMode>(savedPrefs.playerLoop ?? "off"); const [loopMode, setLoopMode] = useState<LoopMode>(savedPrefs.playerLoop ?? "off");
const modeRef = useRef({ autoMode, loopMode }); const modeRef = useRef({ autoMode, loopMode });
@ -446,7 +445,10 @@ export default function PlayerModal({
// navigated to via description links. // navigated to via description links.
const maybeAutoWatch = (current: number, duration: number): boolean => { const maybeAutoWatch = (current: number, duration: number): boolean => {
if (autoMarkedRef.current || duration <= 0 || currentIdRef.current !== id) return false; if (autoMarkedRef.current || duration <= 0 || currentIdRef.current !== id) return false;
if (current > duration - FINISH_MARGIN) { // Cap the finish margin at half the clip, so a video shorter than FINISH_MARGIN isn't
// auto-marked watched almost immediately (its threshold would otherwise go negative).
const margin = Math.min(FINISH_MARGIN, duration / 2);
if (current > duration - margin) {
autoMarkedRef.current = true; autoMarkedRef.current = true;
setWatched(true); setWatched(true);
return true; return true;
@ -646,7 +648,7 @@ export default function PlayerModal({
{hasQueue && ( {hasQueue && (
<div className="flex flex-wrap items-center justify-center gap-x-4 gap-y-1.5 px-4 py-2 border-b border-border text-sm"> <div className="flex flex-wrap items-center justify-center gap-x-4 gap-y-1.5 px-4 py-2 border-b border-border text-sm">
<button <button
onClick={() => index > 0 && setPlayingId(queue![index - 1].id)} onClick={goPrev}
disabled={index === 0} disabled={index === 0}
title={`${t("player.previous")} · Shift+←`} title={`${t("player.previous")} · Shift+←`}
className="inline-flex items-center gap-1.5 text-muted enabled:hover:text-fg disabled:opacity-40 transition" className="inline-flex items-center gap-1.5 text-muted enabled:hover:text-fg disabled:opacity-40 transition"
@ -657,7 +659,7 @@ export default function PlayerModal({
{index + 1} / {queue!.length} {index + 1} / {queue!.length}
</span> </span>
<button <button
onClick={() => index < queue!.length - 1 && setPlayingId(queue![index + 1].id)} onClick={goNext}
disabled={index === queue!.length - 1} disabled={index === queue!.length - 1}
title={`${t("player.next")} · Shift+→`} title={`${t("player.next")} · Shift+→`}
className="inline-flex items-center gap-1.5 text-muted enabled:hover:text-fg disabled:opacity-40 transition" className="inline-flex items-center gap-1.5 text-muted enabled:hover:text-fg disabled:opacity-40 transition"

View file

@ -14,7 +14,7 @@ import AddToPlaylist from "./AddToPlaylist";
import DownloadButton from "./DownloadButton"; import DownloadButton from "./DownloadButton";
import clsx from "clsx"; import clsx from "clsx";
import type { Video } from "../lib/api"; import type { Video } from "../lib/api";
import { formatDuration, formatViews, relativeTime } from "../lib/format"; import { formatDate, formatDuration, formatViews, relativeTime } from "../lib/format";
function Actions({ function Actions({
video, video,
@ -257,16 +257,7 @@ function VideoCard({
</> </>
)} )}
{relativeTime(video.published_at)} {relativeTime(video.published_at)}
{video.published_at && ( {video.published_at && <>{" · "}{formatDate(video.published_at, i18n.language)}</>}
<>
{" · "}
{new Date(video.published_at).toLocaleDateString(i18n.language, {
year: "numeric",
month: "short",
day: "numeric",
})}
</>
)}
</> </>
); );
// Show the full title in a native tooltip only when it's clamped (overflows its 2 lines). // Show the full title in a native tooltip only when it's clamped (overflows its 2 lines).

View file

@ -106,3 +106,10 @@ export function formatViews(n: number | null): string {
export function formatCountOrDash(n: number | null | undefined): string { export function formatCountOrDash(n: number | null | undefined): string {
return n != null ? formatViews(n) : "—"; return n != null ? formatViews(n) : "—";
} }
// Absolute day-precision date (e.g. "12 Jan 2017"), shown alongside a relative time. Empty for null.
export function formatDate(iso: string | null | undefined, lang: string): string {
return iso
? new Date(iso).toLocaleDateString(lang, { year: "numeric", month: "short", day: "numeric" })
: "";
}