Skip to content

Commit d9ab6f7

Browse files
feat(theme): stream Rick and Morty intro song from YouTube on theme
Mirrors the minecraft music path in `minecraft-background.tsx` — invisible 1×1 YT iframe, autoplay nudge on first user gesture, position saved to sessionStorage so the song survives in-app navigations. Wired to localStorage `rick-morty-music` (default on) and exposed as a header toggle that only shows in the rick-morty theme. Source: Adult Swim's official "Seasons 1-3 Opening Credits" upload (DLaqu2QJYPY), looped. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 4157e5e commit d9ab6f7

5 files changed

Lines changed: 320 additions & 0 deletions

File tree

packages/app/src/app/layout.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { Footer } from '@/components/footer/footer';
1111
import { Header } from '@/components/header/header';
1212
import { CircuitBackground } from '@/components/circuit-background';
1313
import { MinecraftBackgroundLazy } from '@/components/minecraft/minecraft-background-lazy';
14+
import { RickMortyAudioLazy } from '@/components/rick-morty/rick-morty-audio-lazy';
1415
import { RickMortyDecorations } from '@/components/rick-morty/rick-morty-decorations';
1516
import { ThemeProvider } from '@/components/ui/theme-provider';
1617
import {
@@ -180,6 +181,7 @@ export default async function RootLayout({
180181
<CircuitBackground />
181182
<MinecraftBackgroundLazy />
182183
<RickMortyDecorations />
184+
<RickMortyAudioLazy />
183185
<PostHogProvider>
184186
<script type="application/ld+json">{JSON.stringify(jsonLd)}</script>
185187
<QueryProvider>

packages/app/src/components/header/header.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { track } from '@/lib/analytics';
88

99
import { ModeToggle } from '@/components/ui/mode-toggle';
1010
import { MinecraftToggles } from '@/components/minecraft/minecraft-toggles';
11+
import { RickMortyToggles } from '@/components/rick-morty/rick-morty-toggles';
1112
import { navigateInApp } from '@/lib/client-navigation';
1213
import { cn } from '@/lib/utils';
1314

@@ -134,6 +135,7 @@ export const Header = ({ starCount }: { starCount?: number | null }) => {
134135
<div className="ml-auto flex items-center gap-2">
135136
<GitHubStars owner="SemiAnalysisAI" repo="InferenceX" starCount={starCount} />
136137
<MinecraftToggles />
138+
<RickMortyToggles />
137139
<ModeToggle />
138140

139141
{/* Mobile hamburger */}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
'use client';
2+
3+
import dynamic from 'next/dynamic';
4+
5+
const RickMortyAudio = dynamic(
6+
() => import('./rick-morty-audio').then((mod) => mod.RickMortyAudio),
7+
{ ssr: false },
8+
);
9+
10+
export function RickMortyAudioLazy() {
11+
return <RickMortyAudio />;
12+
}
Lines changed: 235 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,235 @@
1+
'use client';
2+
3+
import { useEffect, useRef, useState } from 'react';
4+
5+
/** Adult Swim's official upload of the Rick and Morty Seasons 1-3 opening credits.
6+
* Short enough that a plain loop covers the whole audio bed — no song-picker
7+
* needed (unlike the Minecraft OST compilation). */
8+
const RM_INTRO_VIDEO_ID = 'DLaqu2QJYPY';
9+
10+
let userHasInteracted = false;
11+
if (typeof document !== 'undefined') {
12+
const markInteracted = () => {
13+
userHasInteracted = true;
14+
};
15+
document.addEventListener('pointerdown', markInteracted, { capture: true, once: true });
16+
document.addEventListener('keydown', markInteracted, { capture: true, once: true });
17+
}
18+
19+
function getInitialMusicStart(): number {
20+
try {
21+
const raw = sessionStorage.getItem('rick-morty-music-pos');
22+
if (raw) {
23+
const parsed = JSON.parse(raw);
24+
if (Date.now() - parsed.ts < 30_000 && typeof parsed.time === 'number' && parsed.time > 0) {
25+
return Math.floor(parsed.time);
26+
}
27+
}
28+
} catch {
29+
/* ignore parse errors */
30+
}
31+
return 0;
32+
}
33+
34+
/**
35+
* Streams the Rick and Morty intro song from YouTube whenever the
36+
* rick-morty theme is active and the user hasn't muted music. Mirrors
37+
* the audio path of `minecraft-background.tsx` (invisible 1×1 iframe,
38+
* autoplay nudge on first user gesture, sessionStorage position save)
39+
* but without the 3D scene — rick-morty's visuals are static cutouts
40+
* plus the Jerry-fall GIF, both of which live in
41+
* `rick-morty-decorations.tsx`.
42+
*/
43+
export function RickMortyAudio() {
44+
const [isRickMorty, setIsRickMorty] = useState(false);
45+
const [musicEnabled, setMusicEnabled] = useState(true);
46+
const playerRef = useRef<YT.Player | null>(null);
47+
const wrapperRef = useRef<HTMLDivElement | null>(null);
48+
const nudgeRef = useRef<(() => void) | null>(null);
49+
50+
useEffect(() => {
51+
function check() {
52+
setIsRickMorty(document.documentElement.classList.contains('rick-morty'));
53+
}
54+
check();
55+
const observer = new MutationObserver(check);
56+
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] });
57+
return () => observer.disconnect();
58+
}, []);
59+
60+
useEffect(() => {
61+
function check() {
62+
setMusicEnabled(localStorage.getItem('rick-morty-music') !== 'false');
63+
}
64+
check();
65+
window.addEventListener('rick-morty-music-toggle', check);
66+
return () => window.removeEventListener('rick-morty-music-toggle', check);
67+
}, []);
68+
69+
const showMusic = isRickMorty && musicEnabled;
70+
71+
useEffect(() => {
72+
if (!showMusic) return;
73+
if (document.querySelector('#yt-iframe-api')) return;
74+
const tag = document.createElement('script');
75+
tag.id = 'yt-iframe-api';
76+
tag.src = 'https://www.youtube.com/iframe_api';
77+
document.head.append(tag);
78+
}, [showMusic]);
79+
80+
useEffect(() => {
81+
if (!showMusic) return;
82+
const interval = setInterval(() => {
83+
const player = playerRef.current;
84+
if (!player) return;
85+
try {
86+
const time = player.getCurrentTime();
87+
if (time > 0) {
88+
sessionStorage.setItem('rick-morty-music-pos', JSON.stringify({ time, ts: Date.now() }));
89+
}
90+
} catch {
91+
/* player may not be ready */
92+
}
93+
}, 2000);
94+
return () => clearInterval(interval);
95+
}, [showMusic]);
96+
97+
useEffect(() => {
98+
if (!showMusic) return;
99+
function save() {
100+
const player = playerRef.current;
101+
if (!player) return;
102+
try {
103+
const time = player.getCurrentTime();
104+
if (time > 0) {
105+
sessionStorage.setItem('rick-morty-music-pos', JSON.stringify({ time, ts: Date.now() }));
106+
}
107+
} catch {
108+
/* ignore */
109+
}
110+
}
111+
window.addEventListener('beforeunload', save);
112+
return () => window.removeEventListener('beforeunload', save);
113+
}, [showMusic]);
114+
115+
useEffect(() => {
116+
if (!showMusic) {
117+
playerRef.current?.destroy();
118+
playerRef.current = null;
119+
if (wrapperRef.current) wrapperRef.current.innerHTML = '';
120+
return;
121+
}
122+
123+
function createPlayer() {
124+
if (playerRef.current || !wrapperRef.current) return;
125+
const el = document.createElement('div');
126+
wrapperRef.current.append(el);
127+
128+
let started = false;
129+
const startSeconds = getInitialMusicStart();
130+
131+
function nudge() {
132+
if (started) return;
133+
playerRef.current?.playVideo();
134+
}
135+
nudgeRef.current = nudge;
136+
function onStarted() {
137+
started = true;
138+
document.removeEventListener('pointerdown', nudge, true);
139+
document.removeEventListener('keydown', nudge, true);
140+
nudgeRef.current = null;
141+
}
142+
143+
try {
144+
playerRef.current = new YT.Player(el, {
145+
height: '1',
146+
width: '1',
147+
videoId: RM_INTRO_VIDEO_ID,
148+
playerVars: {
149+
autoplay: 1,
150+
loop: 1,
151+
playlist: RM_INTRO_VIDEO_ID,
152+
start: startSeconds,
153+
controls: 0,
154+
disablekb: 1,
155+
modestbranding: 1,
156+
},
157+
events: {
158+
onReady: (e: YT.PlayerEvent) => {
159+
e.target.setVolume(35);
160+
e.target.playVideo();
161+
document.addEventListener('pointerdown', nudge, true);
162+
document.addEventListener('keydown', nudge, true);
163+
if (userHasInteracted) {
164+
let retries = 0;
165+
const retry = setInterval(() => {
166+
if (started || retries++ > 10) {
167+
clearInterval(retry);
168+
return;
169+
}
170+
playerRef.current?.playVideo();
171+
}, 300);
172+
}
173+
},
174+
onStateChange: (e: YT.PlayerEvent & { data: number }) => {
175+
if (e.data === 1 && !started) onStarted();
176+
if (e.data === 0) e.target.playVideo();
177+
},
178+
},
179+
});
180+
} catch {
181+
// YouTube API failed (blocked) — degrade silently
182+
}
183+
}
184+
185+
let installedCallback = false;
186+
if (typeof YT !== 'undefined' && typeof YT.Player === 'function') {
187+
createPlayer();
188+
} else {
189+
installedCallback = true;
190+
const prev = window.onYouTubeIframeAPIReady;
191+
window.onYouTubeIframeAPIReady = () => {
192+
prev?.();
193+
createPlayer();
194+
};
195+
}
196+
197+
return () => {
198+
if (installedCallback) {
199+
window.onYouTubeIframeAPIReady = undefined;
200+
}
201+
if (nudgeRef.current) {
202+
document.removeEventListener('pointerdown', nudgeRef.current, true);
203+
document.removeEventListener('keydown', nudgeRef.current, true);
204+
nudgeRef.current = null;
205+
}
206+
const player = playerRef.current;
207+
if (player) {
208+
try {
209+
const time = player.getCurrentTime();
210+
if (time > 0) {
211+
sessionStorage.setItem(
212+
'rick-morty-music-pos',
213+
JSON.stringify({ time, ts: Date.now() }),
214+
);
215+
}
216+
} catch {
217+
/* ignore */
218+
}
219+
}
220+
playerRef.current?.destroy();
221+
playerRef.current = null;
222+
if (wrapperRef.current) wrapperRef.current.innerHTML = '';
223+
};
224+
}, [showMusic]);
225+
226+
if (!isRickMorty) return null;
227+
228+
return (
229+
<div
230+
ref={wrapperRef}
231+
className="fixed"
232+
style={{ width: 1, height: 1, opacity: 0, pointerEvents: 'none', zIndex: -1 }}
233+
/>
234+
);
235+
}
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
'use client';
2+
3+
import { useEffect, useState } from 'react';
4+
import { Music } from 'lucide-react';
5+
import { track } from '@/lib/analytics';
6+
import { cn } from '@/lib/utils';
7+
8+
const toggleClasses = cn(
9+
'inline-flex items-center justify-center rounded-md p-2',
10+
'text-muted-foreground hover:text-foreground hover:bg-accent',
11+
'transition-colors duration-200',
12+
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
13+
);
14+
15+
/** Header music toggle for the rick-morty theme — only renders when the
16+
* theme is active. Mirrors `MinecraftToggles` but ships only the music
17+
* button (no click-sound counterpart). */
18+
export function RickMortyToggles() {
19+
const [isRickMorty, setIsRickMorty] = useState(false);
20+
const [musicOn, setMusicOn] = useState(true);
21+
22+
useEffect(() => {
23+
function checkTheme() {
24+
setIsRickMorty(document.documentElement.classList.contains('rick-morty'));
25+
}
26+
function checkMusic() {
27+
setMusicOn(localStorage.getItem('rick-morty-music') !== 'false');
28+
}
29+
checkTheme();
30+
checkMusic();
31+
32+
const observer = new MutationObserver(checkTheme);
33+
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] });
34+
window.addEventListener('rick-morty-music-toggle', checkMusic);
35+
return () => {
36+
observer.disconnect();
37+
window.removeEventListener('rick-morty-music-toggle', checkMusic);
38+
};
39+
}, []);
40+
41+
function toggleMusic() {
42+
const next = !musicOn;
43+
setMusicOn(next);
44+
localStorage.setItem('rick-morty-music', String(next));
45+
window.dispatchEvent(new CustomEvent('rick-morty-music-toggle'));
46+
track('rick_morty_music_toggled', { enabled: next });
47+
}
48+
49+
if (!isRickMorty) return null;
50+
51+
return (
52+
<button
53+
type="button"
54+
className={toggleClasses}
55+
onClick={toggleMusic}
56+
title={musicOn ? 'Mute intro song' : 'Unmute intro song'}
57+
aria-label={musicOn ? 'Mute intro song' : 'Unmute intro song'}
58+
>
59+
<span className="relative">
60+
<Music className="w-[20px] h-[20px]" />
61+
{!musicOn && (
62+
<span className="absolute inset-0 flex items-center justify-center">
63+
<span className="block w-[22px] h-[2px] bg-current rotate-45" />
64+
</span>
65+
)}
66+
</span>
67+
</button>
68+
);
69+
}

0 commit comments

Comments
 (0)