Merge chore/code-hygiene: Phase 2 #5 YouTube player (1 bug + cleanups)
Fix: short videos were auto-marked watched almost immediately (negative finish margin). Cleanups: extract format.formatDate, reuse goPrev/goNext for the in-bar buttons, memoize the savedPrefs cache read. Verified: tsc, knip, localdev healthy.
This commit is contained in:
commit
5550f9ac89
3 changed files with 26 additions and 26 deletions
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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).
|
||||||
|
|
|
||||||
|
|
@ -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" })
|
||||||
|
: "";
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue