fix(player): friendly fallback when a video can't be embedded

The in-app IFrame player showed YouTube's bare 'Video unavailable' screen
with no way out when a video couldn't be embedded (auto-generated Topic
art-tracks disable embedding -> error 101/150; removed/private -> 100).
Catch the IFrame onError and overlay a clear message + 'Open on YouTube'
button, with a tailored note for embedding-disabled videos.
This commit is contained in:
npeter83 2026-06-16 10:45:57 +02:00
parent 4ea2043f3d
commit ef4bdee1f7
4 changed files with 48 additions and 5 deletions

View file

@ -3,7 +3,7 @@ import { useTranslation } from "react-i18next";
import type { TFunction } from "i18next";
import { createPortal } from "react-dom";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { ArrowLeft, Check, CheckCheck, SkipBack, SkipForward, X } from "lucide-react";
import { AlertTriangle, ArrowLeft, Check, CheckCheck, ExternalLink, SkipBack, SkipForward, X } from "lucide-react";
import Avatar from "./Avatar";
import AddToPlaylist from "./AddToPlaylist";
import { api, type Video } from "../lib/api";
@ -237,12 +237,17 @@ export default function PlayerModal({
const navigated = currentVideoId !== active.id;
// Title/author of a navigated-to video, read from the player (free, no API call).
const [liveData, setLiveData] = useState<{ title?: string; author?: string } | null>(null);
// IFrame Player API error code, when the current video can't play in the embed (e.g. an
// auto-generated Topic "Art Track" with embedding disabled → 101/150, or removed/private →
// 100). We overlay a friendly message + "Open on YouTube" instead of YouTube's bare error.
const [playerError, setPlayerError] = useState<number | null>(null);
const loadVideo = (id: string, start: number | null) => {
const p = playerRef.current;
if (!p || typeof p.loadVideoById !== "function") return;
// Explicit start wins; otherwise resume the active item, start others at 0.
const startSeconds = start != null ? start : id === active.id ? resumeAt : 0;
setPlayerError(null);
p.loadVideoById({ videoId: id, startSeconds: startSeconds || 0 });
currentIdRef.current = id;
setCurrentVideoId(id);
@ -330,6 +335,7 @@ export default function PlayerModal({
let cancelled = false;
const id = active.id;
autoMarkedRef.current = false;
setPlayerError(null);
// Auto-watch only applies to the active item — not to other videos the player
// navigated to via description links.
@ -388,6 +394,9 @@ export default function PlayerModal({
if (queue && index < queue.length - 1) setPlayingId(queue[index + 1].id);
}
},
// The embed couldn't play this video (embedding disabled, removed, private…).
// Surface our own message + an "Open on YouTube" escape hatch.
onError: (e: any) => setPlayerError(typeof e?.data === "number" ? e.data : -1),
},
});
});
@ -427,8 +436,30 @@ export default function PlayerModal({
className="glass-card relative w-full max-w-4xl max-h-full overflow-y-auto rounded-2xl shadow-2xl"
onClick={(e) => e.stopPropagation()}
>
<div className="aspect-video w-full bg-black rounded-t-2xl overflow-hidden">
<div className="relative aspect-video w-full bg-black rounded-t-2xl overflow-hidden">
<div ref={mountRef} className="w-full h-full" />
{playerError != null && (
<div className="absolute inset-0 grid place-items-center bg-bg/95 p-6 text-center">
<div className="max-w-sm">
<AlertTriangle className="w-8 h-8 text-amber-500 mx-auto mb-3" />
<h3 className="text-base font-semibold">{t("player.unavailableTitle")}</h3>
<p className="text-sm text-muted mt-1.5 mb-4">
{playerError === 101 || playerError === 150
? t("player.embedDisabledBody")
: t("player.unavailableBody")}
</p>
<a
href={`https://www.youtube.com/watch?v=${currentVideoId}`}
target="_blank"
rel="noreferrer"
className="inline-flex items-center justify-center gap-2 px-4 py-2 rounded-xl font-semibold bg-accent text-accent-fg hover:opacity-90 transition"
>
<ExternalLink className="w-4 h-4" />
{t("player.openOnYoutube")}
</a>
</div>
</div>
)}
</div>
{hasQueue && (