Skip to content

Commit 3c63450

Browse files
committed
feat: show YouTube video via muted embed instead of proxying
Drop videoToken/extract video formats to save bandwidth, sync a muted IFrame to the stretch clock, and keep Show video working after audio cache hits without storing video in IndexedDB.
1 parent c34f0aa commit 3c63450

10 files changed

Lines changed: 618 additions & 146 deletions

File tree

src/app/api/stream/extract/route.ts

Lines changed: 1 addition & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -41,21 +41,9 @@ export async function POST(req: Request) {
4141
expiresAt,
4242
});
4343

44-
let videoToken: string | undefined;
45-
if (streamInfo.videoUrl) {
46-
videoToken = crypto.randomUUID();
47-
store.set(videoToken, {
48-
url: streamInfo.videoUrl,
49-
contentType: streamInfo.videoContentType || "video/mp4",
50-
headers: streamInfo.headers,
51-
sourceUrl: streamInfo.sourceUrl,
52-
expiresAt,
53-
});
54-
}
55-
44+
// Video is shown via YouTube embed on the client — no video proxy token.
5645
return NextResponse.json({
5746
token,
58-
...(videoToken && { videoToken }),
5947
url: streamInfo.url,
6048
metadata: {
6149
title: streamInfo.title,

src/components/HistoryList.tsx

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,13 +37,18 @@ export default function HistoryList({
3737
const handlePlay = async (item: HistoryItem) => {
3838
onPlay?.();
3939

40+
const url = historyItemSourceUrl(item);
4041
const cached = await resolveCachedMedia(item);
4142
if (cached) {
42-
openPlayer({ media: cached, expand: true });
43+
// Pass url for YouTube so embed id resolves; IDB stays audio-only.
44+
openPlayer({
45+
media: cached,
46+
url: url ?? item.sourceUrl,
47+
expand: true,
48+
});
4349
return;
4450
}
4551

46-
const url = historyItemSourceUrl(item);
4752
if (!url) return;
4853
openPlayer({ url, expand: true });
4954
};

src/components/Player.tsx

Lines changed: 80 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ import { usePlayerSheetGestures } from "@/hooks/usePlayerSheetGestures";
5555
import { usePlayerTapGestures } from "@/hooks/usePlayerTapGestures";
5656
import { useStretchPlayer } from "@/hooks/useStretchPlayer";
5757
import { useSyncedVideo } from "@/hooks/useSyncedVideo";
58+
import { useSyncedYouTubeEmbed } from "@/hooks/useSyncedYouTubeEmbed";
5859
import { HistoryItem, LyricsSettings, Media } from "@/interfaces";
5960
import { youtubeErrorTitle } from "@/lib/apiError";
6061
import { MAX_HISTORY_ITEMS } from "@/lib/constants";
@@ -68,8 +69,9 @@ import {
6869
} from "@/lib/playerPrefs";
6970
import { mergeTrackMetadata, peekSearchMeta } from "@/lib/searchMeta";
7071
import { appTheme } from "@/lib/theme";
72+
import { isMarkedAudioTrackVideo } from "@/lib/trackFlags";
7173
import { getModeFromRate, getVideoState, saveVideoState } from "@/lib/videoState";
72-
import { getFormattedTime, getYouTubeId, isSupportedURL } from "@/utils";
74+
import { getFormattedTime, getYouTubeId, isSupportedURL, isYoutubeURL } from "@/utils";
7375
import {
7476
createDynamicTheme,
7577
getOriginalPlatformUrl,
@@ -765,34 +767,69 @@ export function Player({
765767
savePlaybackPrefs({ advancedStretch: enabled });
766768
}, []);
767769

770+
const youtubeVideoId = (() => {
771+
if (!media?.sourceUrl || !isYoutubeURL(media.sourceUrl)) return undefined;
772+
if (media.isAudioTrackVideo) return undefined;
773+
const id = getYouTubeId(media.sourceUrl);
774+
if (!id || isMarkedAudioTrackVideo(id)) return undefined;
775+
return id;
776+
})();
777+
778+
/** Local file video (blob / same-origin) — not a proxied YouTube stream. */
779+
const localVideoUrl =
780+
media?.videoUrl && !media.videoUrl.startsWith("/api/stream/")
781+
? media.videoUrl
782+
: undefined;
783+
768784
const handleToggleShowVideo = useCallback(() => {
769-
if (!media?.videoUrl || media.isAudioTrackVideo) {
785+
const canShow = Boolean(youtubeVideoId || localVideoUrl);
786+
if (!canShow) {
770787
showToast("Video not available for this track");
771788
return;
772789
}
773790
const next = !showVideo;
774791
setShowVideoState(next);
775792
setShowVideo(next);
776-
}, [media, showVideo, showToast]);
793+
}, [youtubeVideoId, localVideoUrl, showVideo, showToast]);
777794

778-
/** Real motion video available (not YouTube Music ATV static art). */
779-
const hasVideoStream = Boolean(media?.videoUrl && !media?.isAudioTrackVideo);
795+
/** YouTube embed or local file video (not ATV / not proxied YT stream). */
796+
const hasVideoStream = Boolean(youtubeVideoId || localVideoUrl);
780797
/** User wants video and this track can show it. */
781798
const showingVideo = Boolean(showVideo && hasVideoStream);
799+
const showingYouTubeEmbed = Boolean(showingVideo && youtubeVideoId);
800+
const showingLocalVideo = Boolean(showingVideo && localVideoUrl && !youtubeVideoId);
782801

783-
const { videoRef, isVideoReady, videoEl } = useSyncedVideo({
784-
src: hasVideoStream ? media?.videoUrl : undefined,
785-
active: showingVideo,
802+
const {
803+
videoRef,
804+
isVideoReady: localVideoReady,
805+
videoEl,
806+
} = useSyncedVideo({
807+
src: localVideoUrl,
808+
active: showingLocalVideo,
786809
isPlaying,
787810
currentTime,
788811
rate,
789812
});
813+
const { containerRef: embedContainerRef, isVideoReady: embedReady } =
814+
useSyncedYouTubeEmbed({
815+
// Only load the iframe when Show video is on — saves YT CDN bandwidth.
816+
videoId: showingYouTubeEmbed ? youtubeVideoId : undefined,
817+
active: showingYouTubeEmbed,
818+
isPlaying,
819+
currentTime,
820+
rate,
821+
});
822+
const isVideoReady = showingYouTubeEmbed
823+
? embedReady
824+
: showingLocalVideo
825+
? localVideoReady
826+
: false;
790827
const showVideoCover = !showingVideo || !isVideoReady;
791828
const washCanvasRef = useRef<HTMLCanvasElement>(null);
792829

793-
// Paint a low-res moving wash from the on-screen video frames
830+
// Paint a low-res moving wash from local <video> frames (iframe pixels aren't readable).
794831
useEffect(() => {
795-
if (!showingVideo || isMini || !videoEl || !washCanvasRef.current) return;
832+
if (!showingLocalVideo || isMini || !videoEl || !washCanvasRef.current) return;
796833
const canvas = washCanvasRef.current;
797834
const ctx = canvas.getContext("2d", { alpha: false });
798835
if (!ctx) return;
@@ -818,7 +855,7 @@ export function Player({
818855
};
819856
raf = requestAnimationFrame(draw);
820857
return () => cancelAnimationFrame(raf);
821-
}, [showingVideo, videoEl, isMini]);
858+
}, [showingLocalVideo, videoEl, isMini]);
822859

823860
const handleReset = useCallback(() => {
824861
setRate(1);
@@ -1482,8 +1519,8 @@ export function Player({
14821519
touchAction: isMobile ? "none" : undefined,
14831520
}}
14841521
>
1485-
{/* Blurred wash — live video frames when showing video, else cover art */}
1486-
{showingVideo && media.videoUrl ? (
1522+
{/* Blurred wash — local video frames when available; YouTube embed uses cover */}
1523+
{showingLocalVideo ? (
14871524
<>
14881525
<canvas
14891526
ref={washCanvasRef}
@@ -1737,15 +1774,36 @@ export function Player({
17371774
style={{ display: "none" }}
17381775
preload="metadata"
17391776
/>
1740-
{/* Keep video mounted whenever a stream exists — hide instead of unmount. */}
1741-
{hasVideoStream && media.videoUrl ? (
1777+
{/* YouTube: muted embed (no video proxy). Local files: <video>. */}
1778+
{showingYouTubeEmbed && youtubeVideoId ? (
1779+
<div
1780+
ref={embedContainerRef}
1781+
key={youtubeVideoId}
1782+
// overflow + pointer-events help crop YT hover chrome (title/share/logo)
1783+
style={{
1784+
position: "absolute",
1785+
inset: 0,
1786+
width: "100%",
1787+
height: "100%",
1788+
borderRadius: theme.radius.md,
1789+
overflow: "hidden",
1790+
pointerEvents: "none",
1791+
background: "rgba(0,0,0,0.35)",
1792+
opacity: isVideoReady ? 1 : 0,
1793+
filter: stretchState === "loading" ? "blur(8px)" : "none",
1794+
transition: "opacity 0.2s ease-out, filter 0.3s ease-out",
1795+
isolation: "isolate",
1796+
}}
1797+
/>
1798+
) : null}
1799+
{localVideoUrl ? (
17421800
<video
17431801
ref={videoRef}
1744-
key={media.videoUrl}
1745-
src={media.videoUrl}
1802+
key={localVideoUrl}
1803+
src={localVideoUrl}
17461804
muted
17471805
playsInline
1748-
preload={showingVideo ? "auto" : "metadata"}
1806+
preload={showingLocalVideo ? "auto" : "metadata"}
17491807
poster={coverUrl || undefined}
17501808
style={{
17511809
position: "absolute",
@@ -1757,7 +1815,7 @@ export function Player({
17571815
userSelect: "none",
17581816
pointerEvents: "none",
17591817
background: "rgba(0,0,0,0.35)",
1760-
opacity: showingVideo && isVideoReady ? 1 : 0,
1818+
opacity: showingLocalVideo && isVideoReady ? 1 : 0,
17611819
filter: stretchState === "loading" ? "blur(8px)" : "none",
17621820
transition: "opacity 0.2s ease-out, filter 0.3s ease-out",
17631821
}}
@@ -2083,13 +2141,13 @@ export function Player({
20832141
)}
20842142
<Menu.Divider />
20852143
<Menu.Label>Actions</Menu.Label>
2086-
{!media?.isAudioTrackVideo && (
2144+
{hasVideoStream && (
20872145
<Menu.Item
20882146
leftSection={<IconVideo size={14} />}
20892147
onClick={handleToggleShowVideo}
2090-
disabled={!media?.videoUrl && !showVideo}
2148+
disabled={!hasVideoStream && !showVideo}
20912149
rightSection={
2092-
showVideo && media?.videoUrl ? (
2150+
showVideo && hasVideoStream ? (
20932151
<Text size="xs" c="dimmed">
20942152
On
20952153
</Text>

0 commit comments

Comments
 (0)