Fun Feature Breakdown: MiniTV
A floating YouTube player disguised as a tiny retro television. Because why not.
Fun Feature: MiniTV
Like many others, I like working to videos or audio playlists sometimes. So I built a fun little feature around that: a draggable YouTube playlist player styled like a miniature CRT television, antennas and all. Because why not.
It floats over The Big Idea, remembers where you put it, and expands when you want more screen.
Loading the YouTube API
I load the YouTube IFrame API dynamically when the component mounts:
const loadScript = useCallback(() => {
// If the API is already loaded, we're good
if ((window as any).YT?.Player) {
setYtReady(true);
return;
}
// Inject the script tag
const tag = document.createElement("script");
tag.id = "youtube-iframe-api";
tag.src = "https://www.youtube.com/iframe_api";
document.body.appendChild(tag);
// YouTube's API calls this global function when ready
(window as any).onYouTubeIframeAPIReady = () => setYtReady(true);
}, []);
This avoids bundling the YouTube player code. It only loads when someone actually uses MiniTV. The onYouTubeIframeAPIReady callback is YouTube's official pattern for knowing when the API is ready to use.
Parsing Playlist Input
Users might paste a full YouTube URL or just a playlist ID. I handle both:
function parsePlaylistId(val: string): string | null {
// Check if it looks like a raw playlist ID (alphanumeric, dashes, underscores)
if (/^[A-Za-z0-9_-]+$/.test(val) && val.length >= 10) return val;
// Otherwise try to parse it as a URL and extract the "list" param
try {
const url = new URL(val);
return url.searchParams.get("list");
} catch {
return null;
}
}
The regex check comes first. If someone pastes just PLrAXtmErZgOeiKm4sgNOknGvNjby9efdf, return it directly. If they paste https://youtube.com/playlist?list=PLrAXtmErZgOeiKm4sgNOknGvNjby9efdf, parse the URL and pull out the list parameter. If neither works, null.
Persisting Position
The TV position saves to localStorage so it stays where you put it:
const [position, setPosition] = useState<Position>(() => {
const saved = localStorage.getItem("miniTvPosition");
return saved
? JSON.parse(saved)
: { x: window.innerWidth - 200, y: window.innerHeight - 150 };
});
useEffect(() => {
localStorage.setItem("miniTvPosition", JSON.stringify(position));
}, [position]);
The lazy initializer in useState reads from localStorage on first render. The useEffect writes back whenever position changes. Default lands it in the bottom-right corner.
Component Communication
The settings modal and the player need to stay in sync. I used custom DOM events instead of a shared context. Felt like overkill for two components:
// In the settings modal, after saving a new playlist:
window.dispatchEvent(new CustomEvent('miniTvPlaylistChange'));
// In the player, listening for changes:
useEffect(() => {
const handlePlaylistChange = () => {
const saved = localStorage.getItem('miniTvPlaylist');
const id = saved ? parsePlaylistId(saved) : null;
if (id) setPlaylistId(id);
};
window.addEventListener('miniTvPlaylistChange', handlePlaylistChange);
return () => window.removeEventListener('miniTvPlaylistChange', handlePlaylistChange);
}, []);
The modal writes to localStorage and fires an event. The player listens, reads from localStorage, and updates. They don't need to know about each other.
Hover Delay
Controls only appear on hover. But I added a 300ms delay before hiding them:
const handleMouseLeave = () => {
hoverTimeoutRef.current = window.setTimeout(() => {
setIsHovered(false);
}, 300);
};
Without this, the controls flicker annoyingly when your mouse accidentally crosses the boundary. Small thing, but it makes a noticeable difference in how it feels.
Error Messages
YouTube's API returns numeric error codes. I map them to something readable:
const errorMap: Record<number, string> = {
2: "Invalid parameter.",
100: "Video not found or removed.",
101: "Embedding not allowed by the owner.",
150: "Embedding not allowed by the owner.",
};
Codes 101 and 150 mean the same thing: the video owner disabled embedding. At least now users know why their playlist won't play.
The Retro Look
CSS gradients do most of the work:
const styles = {
tv: {
background: "linear-gradient(145deg, #4b4b4b, #2b2b2b)",
borderRadius: 8,
boxShadow: "0 5px 15px rgba(0,0,0,0.2)",
border: "3px solid #1a1a1a",
},
antenna: {
width: 1,
height: 20,
background: "#808080",
transform: "rotate(-20deg)",
},
knob: {
borderRadius: "50%",
background: "linear-gradient(145deg, #2b2b2b, #1a1a1a)",
},
};
Antennas are just rotated divs. Knobs use gradient backgrounds for a bit of depth. Nothing fancy, but it sells the vintage TV vibe.
That's MiniTV. A fun little thing that didn't need to exist, but I'm glad it does.