fix(downloads): iPad audio playback + honest non-retryable failure for auth-walled hosts

5a — the public watch page rendered every asset in a <video>, but WebKit (all
iOS browsers) refuses to play an audio-only mp3 in <video>, so shared audio
links were broken on iPad while working on desktop Chromium. public_meta now
flags audio-only assets (codec present, no video stream) and WatchPage renders
a native <audio> in a padded surface card (no artwork exists for these).

5c — a login-walled Facebook post failed as a bare 'unknown', which offered a
misleading Retry. Classify closed-group / cookie-suggestion auth walls as
login_required and genuinely unsupported sites as a new unsupported_host class
(both non-retryable), and drop unknown from the modal's retryable set — an
unclassified failure is most often an unparsed auth wall. The queue row keeps
its own force-retry for real transients.
This commit is contained in:
2026-08-28 02:22:28 +02:00
parent 11e32163b8
commit b7a6451dba
9 changed files with 158 additions and 21 deletions
@@ -0,0 +1,39 @@
// @vitest-environment jsdom
//
// S3 item 5a: the public watch page must render a native <audio> element for an audio-only asset
// (mp3) and a <video> for everything else. WebKit (all iOS browsers) refuses to play an audio-only
// mp3 inside a <video>, which is exactly why the branch exists — this locks it so a refactor can't
// silently drop the audio player and reintroduce the iPad-broken behavior.
import { afterEach, describe, expect, it, vi } from "vitest";
import { render, waitFor, cleanup } from "@testing-library/react";
import WatchPage from "./WatchPage";
function mockMeta(meta: Record<string, unknown>) {
vi.stubGlobal(
"fetch",
vi.fn(async () => ({ ok: true, json: async () => meta })),
);
}
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});
describe("WatchPage media element (S3 · 5a)", () => {
it("renders a native <audio> for an audio-only asset", async () => {
window.history.replaceState({}, "", "/watch/tok-audio");
mockMeta({ needs_password: false, audio: true, file_url: "/f.mp3", title: "A song" });
const { container } = render(<WatchPage />);
await waitFor(() => expect(container.querySelector("audio")).not.toBeNull());
expect(container.querySelector("video")).toBeNull();
});
it("renders a <video> for a normal video asset", async () => {
window.history.replaceState({}, "", "/watch/tok-video");
mockMeta({ needs_password: false, audio: false, file_url: "/f.mp4", title: "A clip" });
const { container } = render(<WatchPage />);
await waitFor(() => expect(container.querySelector("video")).not.toBeNull());
expect(container.querySelector("audio")).toBeNull();
});
});
+33 -17
View File
@@ -11,10 +11,11 @@ import { Download, Link2, Lock, PlayCircle } from "lucide-react";
// playing in the background on Brave iOS. We deliberately do NOT route the audio through the Web
// Audio API: `createMediaElementSource` pulls the element out of the normal media pipeline that the
// background feature (and iOS) hooks into, and iOS then suspends the AudioContext on background —
// which STOPPED playback instead of keeping it alive (measured on iPad Brave). A plain <video> +
// MediaSession leaves the browser's native background handling in charge.
// which STOPPED playback instead of keeping it alive (measured on iPad Brave). A plain <video>/<audio>
// + MediaSession leaves the browser's native background handling in charge. Typed on HTMLMediaElement
// so it drives either the video or the audio-only player.
function useMediaSession(
videoRef: React.RefObject<HTMLVideoElement | null>,
videoRef: React.RefObject<HTMLMediaElement | null>,
meta: Meta | null,
ready: boolean,
) {
@@ -45,6 +46,7 @@ type Meta = {
source_url?: string | null;
extra_links?: string[];
duration_s?: number | null;
audio?: boolean;
allow_download?: boolean;
file_url?: string;
poster_url?: string | null;
@@ -112,8 +114,13 @@ export default function WatchPage() {
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const [unlocking, setUnlocking] = useState(false);
const videoRef = useRef<HTMLVideoElement>(null);
useMediaSession(videoRef, meta, status === "ready");
// One ref for whichever element renders (video or audio-only) — a callback ref so it can bind to
// both without a per-element typed ref.
const mediaRef = useRef<HTMLMediaElement | null>(null);
const setMedia = (el: HTMLMediaElement | null) => {
mediaRef.current = el;
};
useMediaSession(mediaRef, meta, status === "ready");
useEffect(() => {
if (!token) {
@@ -206,18 +213,27 @@ export default function WatchPage() {
{status === "ready" && meta && (
<div>
{/* Shrink-wrap the player to the video so a vertical/short clip renders as a narrow
player instead of a wide box with black bars; a landscape clip fills the width. */}
<div className="rounded-xl overflow-hidden bg-black border border-border w-fit max-w-full mx-auto">
<video
ref={videoRef}
controls
playsInline
src={meta.file_url}
poster={meta.poster_url || undefined}
className="max-h-[80vh] max-w-full mx-auto block"
/>
</div>
{meta.audio ? (
/* Audio-only asset (mp3): WebKit won't play it in a <video>, so render a native
<audio>. No poster exists for these, so present it as a deliberate padded bar on
the surface (not the black video box) so it looks intentional without artwork. */
<div className="rounded-xl border border-border bg-surface p-4">
<audio ref={setMedia} controls src={meta.file_url} className="w-full block" />
</div>
) : (
/* Shrink-wrap the player to the video so a vertical/short clip renders as a narrow
player instead of a wide box with black bars; a landscape clip fills the width. */
<div className="rounded-xl overflow-hidden bg-black border border-border w-fit max-w-full mx-auto">
<video
ref={setMedia}
controls
playsInline
src={meta.file_url}
poster={meta.poster_url || undefined}
className="max-h-[80vh] max-w-full mx-auto block"
/>
</div>
)}
<div className="mt-3 flex items-start gap-3">
<div className="min-w-0 flex-1">
<h1 className="text-lg font-semibold leading-snug">{meta.title || "Video"}</h1>
+6 -3
View File
@@ -114,9 +114,12 @@ export function MetaEditModal({ job, onClose }: { job: DownloadJob; onClose: ()
);
}
// Transient failures worth an in-modal Retry button; the rest are permanent (private/geo/DRM/…),
// where a retry can't help — the queue row still keeps its own retry control regardless.
const RETRYABLE_ERRORS = ["postprocess", "network", "unknown"];
// Transient failures worth an in-modal Retry button; the rest are permanent (private/geo/DRM/
// sign-in/unsupported-host/…), where a retry can't help — the queue row still keeps its own retry
// control regardless. `unknown` is deliberately NOT here: an unclassified failure is most often an
// auth wall we couldn't parse (a login-walled Facebook post reads as a bare "no format"), so a
// prominent Retry would mislead; force-retrying from the row stays available for genuine transients.
const RETRYABLE_ERRORS = ["postprocess", "network"];
// A readable "why did this fail" window for a failed job: a category headline + plain explanation,
// the raw yt-dlp/ffmpeg text behind a disclosure, and a Retry when the failure may be transient.