From 94166904f7b2dab57d4bea2d626c7c0efb8916d3 Mon Sep 17 00:00:00 2001 From: codey Date: Wed, 18 Feb 2026 13:34:27 -0500 Subject: [PATCH 1/6] TRip: add video support --- src/components/Radio.tsx | 145 +++++--- src/components/TRip/MediaRequestForm.tsx | 428 ++++++++++++++++++++++ src/components/TRip/RequestManagement.css | 29 ++ src/components/TRip/RequestManagement.tsx | 27 +- src/config.ts | 2 +- 5 files changed, 574 insertions(+), 57 deletions(-) diff --git a/src/components/Radio.tsx b/src/components/Radio.tsx index 2890b0b..0c6c5d5 100644 --- a/src/components/Radio.tsx +++ b/src/components/Radio.tsx @@ -178,27 +178,37 @@ export default function Player({ user }: PlayerProps) { // Initialize or switch HLS stream const initializeStream = (station) => { setIsStreamReady(false); - import('hls.js').then(({ default: Hls }) => { - const audio = audioElement.current; - if (!audio) return; - const streamUrl = `https://stream.codey.lol/hls/${station}/${station}.m3u8`; - - // Clean up previous HLS - if (hlsInstance.current) { - hlsInstance.current.destroy(); - hlsInstance.current = null; - } - audio.pause(); - audio.removeAttribute("src"); + + const audio = audioElement.current; + if (!audio) return; + + // Clean up previous HLS instance first (synchronous but quick) + if (hlsInstance.current) { + hlsInstance.current.destroy(); + hlsInstance.current = null; + } + + // Pause and reset audio element + audio.pause(); + audio.removeAttribute("src"); + + // Defer the blocking audio.load() call + setTimeout(() => { audio.load(); + }, 0); - // Handle audio load errors - audio.onerror = () => { - setIsPlaying(false); - setIsStreamReady(false); - }; + // Handle audio load errors + audio.onerror = () => { + setIsPlaying(false); + setIsStreamReady(false); + }; - if (audio.canPlayType("application/vnd.apple.mpegurl")) { + const streamUrl = `https://stream.codey.lol/hls/${station}/${station}.m3u8`; + + // Check for native HLS support (Safari) + if (audio.canPlayType("application/vnd.apple.mpegurl")) { + // Use setTimeout to defer the blocking operations + setTimeout(() => { audio.src = streamUrl; audio.load(); setIsStreamReady(true); @@ -206,9 +216,15 @@ export default function Player({ user }: PlayerProps) { setTrackTitle("Offline"); setIsPlaying(false); }); - return; - } + }, 0); + return; + } + // Dynamic import HLS.js for non-Safari browsers + import('hls.js').then(({ default: Hls }) => { + // Double-check audio element still exists after async import + if (!audioElement.current) return; + if (!Hls.isSupported()) { console.error("HLS not supported"); return; @@ -216,15 +232,8 @@ export default function Player({ user }: PlayerProps) { const hls = new Hls({ lowLatencyMode: false, - // abrEnabled: false, // ABR not in current HLS.js types - liveSyncDuration: 0.6, // seconds behind live edge target - liveMaxLatencyDuration: 3.0, // max allowed latency before catchup - // liveCatchUpPlaybackRate: 1.02, // Not in current HLS.js types - // maxBufferLength: 30, - // maxMaxBufferLength: 60, - // maxBufferHole: 2.0, - // manifestLoadingTimeOut: 5000, - // fragLoadingTimeOut: 10000, // playback speed when catching up + liveSyncDuration: 0.6, + liveMaxLatencyDuration: 3.0, } as Partial); hlsInstance.current = hls; @@ -236,14 +245,48 @@ export default function Player({ user }: PlayerProps) { setIsPlaying(false); }); }); + let mediaRecoveryAttempts = 0; + const MAX_RECOVERY_ATTEMPTS = 3; + hls.on(Hls.Events.ERROR, (event, data) => { console.warn("HLS error:", data); if (data.fatal) { - hls.destroy(); - hlsInstance.current = null; - setTrackTitle("Offline"); - setIsPlaying(false); - setIsStreamReady(false); + switch (data.type) { + case Hls.ErrorTypes.MEDIA_ERROR: + // bufferAppendError and other media errors are recoverable + if (mediaRecoveryAttempts < MAX_RECOVERY_ATTEMPTS) { + mediaRecoveryAttempts++; + console.log(`Attempting media error recovery (attempt ${mediaRecoveryAttempts})`); + if (mediaRecoveryAttempts === 1) { + hls.recoverMediaError(); + } else { + // On subsequent attempts, try swapping audio codec + hls.swapAudioCodec(); + hls.recoverMediaError(); + } + } else { + console.error("Media error recovery failed after max attempts"); + hls.destroy(); + hlsInstance.current = null; + setTrackTitle("Offline"); + setIsPlaying(false); + setIsStreamReady(false); + } + break; + case Hls.ErrorTypes.NETWORK_ERROR: + // Network errors - try to recover by restarting the load + console.log("Network error, attempting to recover..."); + hls.startLoad(); + break; + default: + // Unrecoverable error + hls.destroy(); + hlsInstance.current = null; + setTrackTitle("Offline"); + setIsPlaying(false); + setIsStreamReady(false); + break; + } } }); }); @@ -318,22 +361,34 @@ export default function Player({ user }: PlayerProps) { // Handle station changes: reset and start new stream useEffect(() => { - // Reset metadata and state when switching stations - setTrackTitle(""); - setTrackArtist(""); - setTrackGenre(""); - setTrackAlbum(""); - setCoverArt("/images/radio_art_default.jpg"); - setLyrics([]); - setCurrentLyricIndex(0); - setElapsedTime(0); - setTrackDuration(0); + // Batch all state resets to minimize re-renders + // Use startTransition to mark this as a non-urgent update + const resetState = () => { + setTrackTitle(""); + setTrackArtist(""); + setTrackGenre(""); + setTrackAlbum(""); + setCoverArt("/images/radio_art_default.jpg"); + setLyrics([]); + setCurrentLyricIndex(0); + setElapsedTime(0); + setTrackDuration(0); + }; + // Reset refs currentTrackUuid.current = null; baseTrackElapsed.current = 0; lastUpdateTimestamp.current = Date.now(); - initializeStream(activeStation); + // Use requestAnimationFrame to defer state updates and stream init + // This allows the UI to update the station button immediately + requestAnimationFrame(() => { + resetState(); + // Defer stream initialization to next frame to keep UI responsive + requestAnimationFrame(() => { + initializeStream(activeStation); + }); + }); // Update page title to reflect the new station document.title = `${metaData.title} - Radio [${activeStation}]`; diff --git a/src/components/TRip/MediaRequestForm.tsx b/src/components/TRip/MediaRequestForm.tsx index 62bc5c8..3483ecf 100644 --- a/src/components/TRip/MediaRequestForm.tsx +++ b/src/components/TRip/MediaRequestForm.tsx @@ -42,6 +42,23 @@ interface Track { version?: string; } +interface Video { + id: string | number; + title: string; + name?: string; // Alternative to title + artist?: string; + artistId?: string | number; + artist_id?: string | number; // snake_case variant + duration?: number | string; + image?: string; + imageUrl?: string; + image_url?: string; // snake_case variant + cover?: string; + thumbnail?: string; + releaseDate?: string; + release_date?: string; // snake_case variant +} + interface DiskSpaceInfo { total?: number; used?: number; @@ -89,6 +106,16 @@ export default function MediaRequestForm() { const [audioProgress, setAudioProgress] = useState({ current: 0, duration: 0 }); const [diskSpace, setDiskSpace] = useState(null); + // Video search state + const [videoSearchQuery, setVideoSearchQuery] = useState(""); + const [videoResults, setVideoResults] = useState([]); + const [isVideoSearching, setIsVideoSearching] = useState(false); + const [selectedVideos, setSelectedVideos] = useState>(new Set()); + const [videoPreviewId, setVideoPreviewId] = useState(null); + const [videoStreamUrl, setVideoStreamUrl] = useState(null); + const [isVideoLoading, setIsVideoLoading] = useState(false); + const [showVideoSection, setShowVideoSection] = useState(false); + const { attachScrollFix, cleanupScrollFix } = useAutoCompleteScrollFix(); const debounceTimeout = useRef | null>(null); @@ -698,6 +725,187 @@ export default function MediaRequestForm() { } }; + // ======================== + // VIDEO SEARCH & PLAYBACK + // ======================== + + const searchVideos = async () => { + const query = videoSearchQuery.trim(); + if (!query) { + toast.error("Please enter a search query."); + return; + } + + setIsVideoSearching(true); + setVideoResults([]); + setSelectedVideos(new Set()); + setVideoPreviewId(null); + setVideoStreamUrl(null); + + try { + const res = await authFetch( + `${API_URL}/trip/videos/search?q=${encodeURIComponent(query)}&limit=50` + ); + if (!res.ok) throw new Error("API error"); + const data = await res.json(); + setVideoResults(Array.isArray(data) ? data : data.videos || []); + } catch (err) { + console.error(err); + toast.error("Failed to search for videos."); + setVideoResults([]); + } finally { + setIsVideoSearching(false); + } + }; + + const fetchArtistVideos = async (artistId: string | number) => { + setIsVideoSearching(true); + setVideoResults([]); + setSelectedVideos(new Set()); + setVideoPreviewId(null); + setVideoStreamUrl(null); + setShowVideoSection(true); + + try { + const res = await authFetch(`${API_URL}/trip/videos/artist/${artistId}`); + if (!res.ok) throw new Error("API error"); + const data = await res.json(); + setVideoResults(Array.isArray(data) ? data : data.videos || []); + } catch (err) { + console.error(err); + toast.error("Failed to fetch artist videos."); + setVideoResults([]); + } finally { + setIsVideoSearching(false); + } + }; + + const toggleVideoSelection = (videoId: string | number) => { + setSelectedVideos((prev) => { + const next = new Set(prev); + if (next.has(videoId)) { + next.delete(videoId); + } else { + next.add(videoId); + } + return next; + }); + }; + + const handleVideoPreview = async (video: Video) => { + if (videoPreviewId === video.id && videoStreamUrl) { + // Already previewing this video, close it + setVideoPreviewId(null); + setVideoStreamUrl(null); + return; + } + + setVideoPreviewId(video.id); + setVideoStreamUrl(null); + setIsVideoLoading(true); + + try { + const res = await authFetch(`${API_URL}/trip/video/${video.id}/stream`); + if (!res.ok) throw new Error("Failed to fetch video stream URL"); + const data = await res.json(); + if (data.stream_url || data.url) { + setVideoStreamUrl(data.stream_url || data.url); + } else { + throw new Error("No stream URL returned"); + } + } catch (err) { + console.error(err); + toast.error("Failed to load video preview."); + setVideoPreviewId(null); + } finally { + setIsVideoLoading(false); + } + }; + + const handleVideoDownload = async (video: Video) => { + try { + toast.info(`Starting download: ${video.title}`, { autoClose: 2000 }); + + // Use authFetch to get the video with proper authentication + const res = await authFetch(`${API_URL}/trip/video/${video.id}/download`); + if (!res.ok) throw new Error(`Failed to download video: ${res.status}`); + + const blob = await res.blob(); + const url = URL.createObjectURL(blob); + const artistName = video.artist || selectedArtist?.artist || selectedArtist?.name || "Unknown Artist"; + const filename = `${sanitizeFilename(artistName)} - ${sanitizeFilename(video.title || video.name || "video")}.mp4`; + + const link = document.createElement("a"); + link.href = url; + link.download = filename; + document.body.appendChild(link); + link.click(); + link.remove(); + URL.revokeObjectURL(url); + + toast.success(`Downloaded: ${video.title}`, { autoClose: 2000 }); + } catch (error) { + console.error(error); + toast.error("Failed to download video."); + } + }; + + // Submit selected videos via bulk_fetch endpoint (creates a job) + const handleSubmitVideoRequest = async () => { + if (selectedVideos.size === 0) { + toast.error("Please select at least one video."); + return; + } + + setIsSubmitting(true); + try { + const videoIds = Array.from(selectedVideos).map((id) => + typeof id === "string" ? parseInt(id, 10) : id + ); + const target = selectedArtist?.artist || selectedArtist?.name || videoSearchQuery || "Videos"; + + const response = await authFetch(`${API_URL}/trip/videos/bulk_fetch`, { + method: "POST", + headers: { + "Content-Type": "application/json; charset=utf-8", + }, + body: JSON.stringify({ + video_ids: videoIds, + target: target, + }), + }); + + if (!response.ok) { + throw new Error(`Server error: ${response.status}`); + } + + const data = await response.json(); + const toastId = 'trip-video-request-submitted'; + toast.success(`Video request submitted! (${videoIds.length} video${videoIds.length !== 1 ? 's' : ''})`, { + toastId, + autoClose: 3000, + onClose: () => { + if (typeof window !== 'undefined') window.location.href = '/TRip/requests'; + } + }); + setSelectedVideos(new Set()); + } catch (err) { + console.error(err); + toast.error("Failed to submit video request."); + } finally { + setIsSubmitting(false); + } + }; + + const formatVideoDuration = (duration: number | string | undefined) => { + if (!duration) return ""; + const secs = typeof duration === "string" ? parseInt(duration, 10) : duration; + if (!Number.isFinite(secs) || secs <= 0) return ""; + const mins = Math.floor(secs / 60); + const remainingSecs = Math.floor(secs % 60).toString().padStart(2, "0"); + return `${mins}:${remainingSecs}`; + }; + useEffect(() => { if (typeof Audio === "undefined") { return undefined; @@ -1387,6 +1595,226 @@ export default function MediaRequestForm() { )} + + {/* Videos Section - Show when artist is selected */} + {selectedArtist && ( +
+
+

+ + + + Music Videos +

+ +
+ + {showVideoSection && ( +
+ {/* Video Search Bar */} +
+ setVideoSearchQuery(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && searchVideos()} + placeholder="Search videos by title..." + className="flex-1 px-3 py-2 rounded border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 text-black dark:text-white" + /> + +
+ + {/* Video Select All / Count */} + {videoResults.length > 0 && ( + + )} + + {/* Video Results Grid */} + {videoResults.length > 0 ? ( +
+ {videoResults.map((video) => ( +
+ {/* Video Thumbnail */} +
handleVideoPreview(video)} + > + {video.image || video.imageUrl ? ( + {video.title} + ) : ( +
+ + + +
+ )} + {/* Play overlay */} +
+ {isVideoLoading && videoPreviewId === video.id ? ( + + ) : ( + + + + )} +
+ {/* Duration badge */} + {video.duration && ( + + {formatVideoDuration(video.duration)} + + )} +
+ + {/* Video Info */} +
+
+ toggleVideoSelection(video.id)} + className="mt-1 cursor-pointer" + /> +
+

+ {video.title} +

+ {video.artist && ( +

+ {video.artist} +

+ )} +
+
+
+ +
+
+ + {/* Video Preview Player */} + {videoPreviewId === video.id && videoStreamUrl && ( +
+
+ )} +
+ ))} +
+ ) : !isVideoSearching && showVideoSection ? ( +
+ + + +

No videos found. Try searching for something else.

+
+ ) : null} + + {/* Selected Videos Summary */} + {selectedVideos.size > 0 && ( +
+ + {selectedVideos.size} video{selectedVideos.size !== 1 ? "s" : ""} selected + + +
+ )} +
+ )} +
+ )} ) } diff --git a/src/components/TRip/RequestManagement.css b/src/components/TRip/RequestManagement.css index ed1f2cf..b282512 100644 --- a/src/components/TRip/RequestManagement.css +++ b/src/components/TRip/RequestManagement.css @@ -475,6 +475,35 @@ } .p-dialog .rm-progress-text { font-size: .75rem; font-weight: 600; color: #e5e7eb !important; margin-left: 0.5rem; white-space: nowrap; } +/* Request Details Dialog - Responsive Track List */ +.request-details-dialog .p-dialog-content { + display: flex; + flex-direction: column; + max-height: 80vh; + overflow: hidden; +} + +.request-details-content { + display: flex; + flex-direction: column; + min-height: 0; + flex: 1; + overflow: hidden; +} + +.request-details-content > .track-list-card { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.track-list-scrollable { + flex: 1; + min-height: 120px; + overflow-y: auto; +} /* Container Styles */ .trip-management-container { diff --git a/src/components/TRip/RequestManagement.tsx b/src/components/TRip/RequestManagement.tsx index d798da6..0881b5e 100644 --- a/src/components/TRip/RequestManagement.tsx +++ b/src/components/TRip/RequestManagement.tsx @@ -54,15 +54,19 @@ export default function RequestManagement() { const resolveTarballPath = (job: RequestJob) => job.tarball; - const tarballUrl = (absPath: string | undefined, quality: string) => { + const tarballUrl = (absPath: string | undefined, quality: string, jobType?: string) => { if (!absPath) return null; const filename = absPath.split("/").pop(); // get "SOMETHING.tar.gz" // If the backend already stores a fully qualified URL, return as-is if (/^https?:\/\//i.test(absPath)) return absPath; + const isVideo = jobType === "video" || absPath.includes("/videos/"); + // Check if path is /storage/music/TRIP if (absPath.includes("/storage/music/TRIP/")) { - return `https://_music.codey.lol/TRIP/${filename}`; + return isVideo + ? `https://_music.codey.lol/TRIP/videos/${filename}` + : `https://_music.codey.lol/TRIP/${filename}`; } // Otherwise, assume /storage/music2/completed/{quality} format @@ -436,7 +440,7 @@ export default function RequestManagement() { } body={(row: RequestJob) => { - const url = tarballUrl(resolveTarballPath(row as RequestJob), row.quality || "FLAC"); + const url = tarballUrl(resolveTarballPath(row as RequestJob), row.quality || "FLAC", row.type); if (!url) return "—"; const encodedURL = encodeURI(url); @@ -464,15 +468,16 @@ export default function RequestManagement() { setIsDialogVisible(false)} breakpoints={{ "960px": "95vw" }} modal dismissableMask - className="dark:bg-neutral-900 dark:text-neutral-100" + maximizable + className="dark:bg-neutral-900 dark:text-neutral-100 request-details-dialog" > {selectedRequest ? ( -
+
{/* --- Metadata Card --- */} @@ -566,9 +571,9 @@ export default function RequestManagement() { {/* --- Track List Card --- */} {selectedRequest.track_list && selectedRequest.track_list.length > 0 && ( -
-

Tracks ({selectedRequest.track_list.length}):

-
+
+

Tracks ({selectedRequest.track_list.length}):

+
{selectedRequest.track_list.map((track, idx) => { const rawStatus = String(track.status || "pending"); const statusNorm = rawStatus.trim().toLowerCase(); diff --git a/src/config.ts b/src/config.ts index bb1edfb..f263bc7 100644 --- a/src/config.ts +++ b/src/config.ts @@ -63,7 +63,7 @@ export const RADIO_API_URL: string = "https://radio-api.codey.lol"; export const socialLinks: Record = { }; -export const MAJOR_VERSION: string = "0.7" +export const MAJOR_VERSION: string = "0.8" export const RELEASE_FLAG: string | null = null; export const ENVIRONMENT: "Dev" | "Prod" = import.meta.env.DEV ? "Dev" : "Prod"; From b5bf5fd5a750fdef9f544cb9b5d0b63f7fa16f78 Mon Sep 17 00:00:00 2001 From: codey Date: Sat, 21 Feb 2026 08:00:04 -0500 Subject: [PATCH 2/6] update references to codey.lol -> codey.horse [new domain] --- README.md | 4 +- src/components/DiscordLogs.tsx | 2 +- src/components/Login.tsx | 2 +- src/components/Memes.tsx | 2 +- src/components/Radio.tsx | 2 +- src/components/TRip/MediaRequestForm.tsx | 271 +++++++++++++++------- src/components/TRip/RequestManagement.tsx | 6 +- src/config.ts | 8 +- src/layouts/Nav.astro | 10 +- src/middleware.js | 6 +- src/middleware.ts | 6 +- src/pages/login.astro | 2 +- src/utils/authFetch.ts | 2 +- 13 files changed, 215 insertions(+), 108 deletions(-) diff --git a/README.md b/README.md index d49d750..b553c8a 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@ -# codey.lol -codey.lol is a web app built with Astro, TypeScript, Tailwind CSS, React, and Vite. +# codey.horse +codey.horse is a web app built with Astro, TypeScript, Tailwind CSS, React, and Vite. ## Pages Overview diff --git a/src/components/DiscordLogs.tsx b/src/components/DiscordLogs.tsx index 922c612..f052118 100644 --- a/src/components/DiscordLogs.tsx +++ b/src/components/DiscordLogs.tsx @@ -665,7 +665,7 @@ const ARCHIVE_USERS = { id: '456226577798135808', username: 'slip', displayName: 'poopboy', - avatar: 'https://codey.lol/images/456226577798135808.png', + avatar: 'https://codey.horse/images/456226577798135808.png', color: null, }, }; diff --git a/src/components/Login.tsx b/src/components/Login.tsx index 7418106..e57bb7f 100644 --- a/src/components/Login.tsx +++ b/src/components/Login.tsx @@ -15,7 +15,7 @@ function clearCookie(name: string): void { } // Trusted domains for redirect validation -const TRUSTED_DOMAINS = ['codey.lol', 'boatson.boats']; +const TRUSTED_DOMAINS = ['codey.horse', 'boatson.boats']; /** * Parse and decode the redirect URL from query params diff --git a/src/components/Memes.tsx b/src/components/Memes.tsx index f7ca60b..4bb5fc0 100644 --- a/src/components/Memes.tsx +++ b/src/components/Memes.tsx @@ -8,7 +8,7 @@ import { toast } from 'react-toastify'; import { API_URL } from '../config'; const MEME_API_URL = `${API_URL}/memes/list_memes`; -const BASE_IMAGE_URL = "https://codey.lol/meme"; +const BASE_IMAGE_URL = "https://codey.horse/meme"; interface MemeImage { id: string; diff --git a/src/components/Radio.tsx b/src/components/Radio.tsx index 0c6c5d5..3a5792d 100644 --- a/src/components/Radio.tsx +++ b/src/components/Radio.tsx @@ -203,7 +203,7 @@ export default function Player({ user }: PlayerProps) { setIsStreamReady(false); }; - const streamUrl = `https://stream.codey.lol/hls/${station}/${station}.m3u8`; + const streamUrl = `https://stream.codey.horse/hls/${station}/${station}.m3u8`; // Check for native HLS support (Safari) if (audio.canPlayType("application/vnd.apple.mpegurl")) { diff --git a/src/components/TRip/MediaRequestForm.tsx b/src/components/TRip/MediaRequestForm.tsx index 3483ecf..26b5921 100644 --- a/src/components/TRip/MediaRequestForm.tsx +++ b/src/components/TRip/MediaRequestForm.tsx @@ -1,11 +1,11 @@ -import React, { useState, useEffect, useRef, Suspense, lazy, useMemo } from "react"; +import React, { useState, useEffect, useRef, Suspense, lazy, useMemo, useCallback } from "react"; import type { RefObject } from "react"; import type { Id } from "react-toastify"; import { toast } from "react-toastify"; import { Button } from "@mui/joy"; import { Accordion, AccordionTab } from "primereact/accordion"; import { AutoComplete } from "primereact/autocomplete"; -import { authFetch } from "@/utils/authFetch"; +import { authFetch, doRefresh } from "@/utils/authFetch"; import { useAutoCompleteScrollFix } from '@/hooks/useAutoCompleteScrollFix'; import BreadcrumbNav from "./BreadcrumbNav"; import { API_URL, ENVIRONMENT } from "@/config"; @@ -114,7 +114,21 @@ export default function MediaRequestForm() { const [videoPreviewId, setVideoPreviewId] = useState(null); const [videoStreamUrl, setVideoStreamUrl] = useState(null); const [isVideoLoading, setIsVideoLoading] = useState(false); + const [videoDownloadProgress, setVideoDownloadProgress] = useState(0); // 0-100 const [showVideoSection, setShowVideoSection] = useState(false); + const videoAbortRef = useRef(null); + + const resetVideoState = () => { + videoAbortRef.current?.abort(); + if (videoStreamUrl?.startsWith("blob:")) URL.revokeObjectURL(videoStreamUrl); + setVideoResults([]); + setSelectedVideos(new Set()); + setVideoPreviewId(null); + setVideoStreamUrl(null); + setVideoSearchQuery(""); + setVideoDownloadProgress(0); + setIsVideoLoading(false); + }; const { attachScrollFix, cleanupScrollFix } = useAutoCompleteScrollFix(); @@ -506,6 +520,8 @@ export default function MediaRequestForm() { toast.dismiss(); setIsSearching(true); resetQueueState(); + const videosWereOpen = showVideoSection; + resetVideoState(); setShuffleAlbums({}); if (audioRef.current) { audioRef.current.pause(); @@ -558,6 +574,11 @@ export default function MediaRequestForm() { return acc; }, {}) ); + + // If the video section was open before, auto-fetch videos for the new artist + if (videosWereOpen && selectedArtist) { + fetchArtistVideos(selectedArtist.id); + } } catch (err) { toast.error("Failed to fetch albums for artist."); setAlbums([]); @@ -793,27 +814,80 @@ export default function MediaRequestForm() { }; const handleVideoPreview = async (video: Video) => { - if (videoPreviewId === video.id && videoStreamUrl) { - // Already previewing this video, close it + if (videoPreviewId === video.id) { + // Already previewing this video — toggle off + videoAbortRef.current?.abort(); + if (videoStreamUrl?.startsWith("blob:")) URL.revokeObjectURL(videoStreamUrl); setVideoPreviewId(null); setVideoStreamUrl(null); + setVideoDownloadProgress(0); return; } + // Abort any in-flight preview download + videoAbortRef.current?.abort(); + if (videoStreamUrl?.startsWith("blob:")) URL.revokeObjectURL(videoStreamUrl); + + const abort = new AbortController(); + videoAbortRef.current = abort; + setVideoPreviewId(video.id); setVideoStreamUrl(null); setIsVideoLoading(true); + setVideoDownloadProgress(0); + + // Use XMLHttpRequest instead of fetch to avoid tying up a fetch + // connection slot — the browser limits concurrent fetch connections + // per origin (~6), and a long video download via ReadableStream + // would starve the sequential track metadata fetches. + const doXhr = (retry: boolean): Promise => new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest(); + xhr.open("GET", `${API_URL}/trip/video/${video.id}/download`); + xhr.responseType = "blob"; + xhr.withCredentials = true; // send cookies + + abort.signal.addEventListener("abort", () => { + xhr.abort(); + reject(new DOMException("Aborted", "AbortError")); + }); + + xhr.onprogress = (e) => { + if (e.lengthComputable) { + setVideoDownloadProgress(Math.round((e.loaded / e.total) * 100)); + } else { + setVideoDownloadProgress(Math.min(95, Math.round(e.loaded / 1024 / 1024))); + } + }; + + xhr.onload = () => { + if (xhr.status >= 200 && xhr.status < 300) { + const blobUrl = URL.createObjectURL(xhr.response); + setVideoStreamUrl(blobUrl); + setVideoDownloadProgress(100); + resolve(); + } else if (xhr.status === 401 && retry) { + // Token expired — refresh and retry once + doRefresh().then((ok) => { + if (!ok || abort.signal.aborted) { + reject(new Error("Auth refresh failed")); + return; + } + setVideoDownloadProgress(0); + doXhr(false).then(resolve, reject); + }).catch(reject); + } else { + reject(new Error(`Failed to fetch video: ${xhr.status}`)); + } + }; + + xhr.onerror = () => reject(new Error("Network error downloading video")); + xhr.send(); + }); try { - const res = await authFetch(`${API_URL}/trip/video/${video.id}/stream`); - if (!res.ok) throw new Error("Failed to fetch video stream URL"); - const data = await res.json(); - if (data.stream_url || data.url) { - setVideoStreamUrl(data.stream_url || data.url); - } else { - throw new Error("No stream URL returned"); - } - } catch (err) { + await doXhr(true); + } catch (err: any) { + if (err?.name === "AbortError") return; console.error(err); toast.error("Failed to load video preview."); setVideoPreviewId(null); @@ -993,6 +1067,8 @@ export default function MediaRequestForm() { audio.removeEventListener("seeked", updateProgress); Object.values(audioSourcesRef.current).forEach((url) => URL.revokeObjectURL(url)); audioSourcesRef.current = {}; + // Abort any in-flight video download and revoke blob URL + videoAbortRef.current?.abort(); }; }, []); @@ -1072,6 +1148,7 @@ export default function MediaRequestForm() { const fetchTracksSequentially: FetchTracksSequentiallyFn = async () => { const minDelay = 650; // ms between API requests + const maxRetries = 3; // retry up to 3 times on failure setIsFetching(true); const totalAlbums = albumsToFetch.length; @@ -1083,28 +1160,38 @@ export default function MediaRequestForm() { setLoadingAlbumId(album.id); - try { - const now = Date.now(); - if (!fetchTracksSequentially.lastCall) fetchTracksSequentially.lastCall = 0; - const elapsed = now - fetchTracksSequentially.lastCall; - if (elapsed < minDelay) await delay(minDelay - elapsed); - fetchTracksSequentially.lastCall = Date.now(); + let succeeded = false; + for (let attempt = 0; attempt < maxRetries && !isCancelled; attempt++) { + try { + const now = Date.now(); + if (!fetchTracksSequentially.lastCall) fetchTracksSequentially.lastCall = 0; + const elapsed = now - fetchTracksSequentially.lastCall; + const currentDelay = attempt === 0 ? minDelay : minDelay + attempt * 1000; + if (elapsed < currentDelay) await delay(currentDelay - elapsed); + fetchTracksSequentially.lastCall = Date.now(); - const res = await authFetch(`${API_URL}/trip/get_tracks_by_album_id/${album.id}`); - if (!res.ok) throw new Error("API error"); - const data = await res.json(); + const res = await authFetch(`${API_URL}/trip/get_tracks_by_album_id/${album.id}`); + if (!res.ok) throw new Error("API error"); + const data = await res.json(); - if (isCancelled) break; + if (isCancelled) break; - setTracksByAlbum((prev) => ({ ...prev, [album.id]: data })); - setSelectedTracks((prev) => ({ - ...prev, - [album.id]: data.map((t) => String(t.id)), - })); - } catch (err) { - toast.error(`Failed to fetch tracks for album ${album.album}.`); - setTracksByAlbum((prev) => ({ ...prev, [album.id]: [] })); - setSelectedTracks((prev) => ({ ...prev, [album.id]: [] })); + setTracksByAlbum((prev) => ({ ...prev, [album.id]: data })); + setSelectedTracks((prev) => ({ + ...prev, + [album.id]: data.map((t) => String(t.id)), + })); + succeeded = true; + break; // success — move to next album + } catch (err) { + if (attempt < maxRetries - 1) { + console.warn(`Retry ${attempt + 1}/${maxRetries} for album "${album.album}" (id ${album.id})`); + } else { + toast.error(`Failed to fetch tracks for album ${album.album}.`); + setTracksByAlbum((prev) => ({ ...prev, [album.id]: [] })); + setSelectedTracks((prev) => ({ ...prev, [album.id]: [] })); + } + } } // Update progress toast @@ -1596,8 +1683,8 @@ export default function MediaRequestForm() {
- {/* Videos Section - Show when artist is selected */} - {selectedArtist && ( + {/* Videos Section - Show when artist is selected or videos are loaded */} + {(selectedArtist || (showVideoSection && videoResults.length > 0)) && (

@@ -1694,41 +1781,78 @@ export default function MediaRequestForm() { : "border-neutral-200 dark:border-neutral-700" }`} > - {/* Video Thumbnail */} -
handleVideoPreview(video)} - > - {video.image || video.imageUrl ? ( - {video.title} +
- - {/* Video Preview Player */} - {videoPreviewId === video.id && videoStreamUrl && ( -
-
- )}

))}
diff --git a/src/components/TRip/RequestManagement.tsx b/src/components/TRip/RequestManagement.tsx index 0881b5e..d6a93a4 100644 --- a/src/components/TRip/RequestManagement.tsx +++ b/src/components/TRip/RequestManagement.tsx @@ -36,7 +36,7 @@ interface RequestJob { } const STATUS_OPTIONS = ["Queued", "Started", "Compressing", "Finished", "Failed"]; -const TAR_BASE_URL = "https://kr.codey.lol"; // configurable prefix +const TAR_BASE_URL = "https://kr.codey.horse"; // configurable prefix export default function RequestManagement() { const [requests, setRequests] = useState([]); @@ -65,8 +65,8 @@ export default function RequestManagement() { // Check if path is /storage/music/TRIP if (absPath.includes("/storage/music/TRIP/")) { return isVideo - ? `https://_music.codey.lol/TRIP/videos/${filename}` - : `https://_music.codey.lol/TRIP/${filename}`; + ? `https://_music.codey.horse/TRIP/videos/${filename}` + : `https://_music.codey.horse/TRIP/${filename}`; } // Otherwise, assume /storage/music2/completed/{quality} format diff --git a/src/config.ts b/src/config.ts index f263bc7..7ee7a16 100644 --- a/src/config.ts +++ b/src/config.ts @@ -39,9 +39,9 @@ export interface ProtectedRoute { } export const metaData: MetaData = { - baseUrl: "https://codey.lol/", + baseUrl: "https://codey.horse/", title: "CODEY STUFF", - name: "codey.lol", + name: "codey.horse", owner: "codey", ogImage: "/images/favicon.png", description: "CODEY STUFF!", @@ -57,8 +57,8 @@ export const metaData: MetaData = { ], }; -export const API_URL: string = "https://api.codey.lol"; -export const RADIO_API_URL: string = "https://radio-api.codey.lol"; +export const API_URL: string = "https://api.codey.horse"; +export const RADIO_API_URL: string = "https://radio-api.codey.horse"; export const socialLinks: Record = { }; diff --git a/src/layouts/Nav.astro b/src/layouts/Nav.astro index 4598968..d56a9d4 100644 --- a/src/layouts/Nav.astro +++ b/src/layouts/Nav.astro @@ -24,15 +24,15 @@ const navItems = [ { label: "Discord Logs", href: "/discord-logs", auth: true }, { label: "Lighting", href: "/lighting", auth: true, adminOnly: true }, { label: "Git", href: "https://kode.boatson.boats", icon: "external" }, - { label: "Glances", href: "https://_gl.codey.lol", auth: true, icon: "external", + { label: "Glances", href: "https://_gl.codey.horse", auth: true, icon: "external", adminOnly: true }, - { label: "PSQL", href: "https://_pg.codey.lol", auth: true, icon: "external", + { label: "PSQL", href: "https://_pg.codey.horse", auth: true, icon: "external", adminOnly: true }, - { label: "qBitTorrent", href: "https://_qb.codey.lol", auth: true, icon: "external", + { label: "qBitTorrent", href: "https://_qb.codey.horse", auth: true, icon: "external", adminOnly: true }, - { label: "RQ", href: "https://_rq.codey.lol", auth: true, icon: "external", + { label: "RQ", href: "https://_rq.codey.horse", auth: true, icon: "external", adminOnly: true }, - { label: "RI", href: "https://_r0.codey.lol", auth: true, icon: "external", + { label: "RI", href: "https://_r0.codey.horse", auth: true, icon: "external", adminOnly: true }, // { label: "Status", href: "https://status.boatson.boats", icon: "external" }, { label: "Login", href: "/login", guestOnly: true }, diff --git a/src/middleware.js b/src/middleware.js index 889a750..025b2bb 100644 --- a/src/middleware.js +++ b/src/middleware.js @@ -26,7 +26,7 @@ if (typeof globalThis.Headers !== 'undefined' && typeof globalThis.Headers.proto } } -const API_URL = "https://api.codey.lol"; +const API_URL = "https://api.codey.horse"; const AUTH_TIMEOUT_MS = 3000; // 3 second timeout for auth requests // Deduplication for concurrent refresh requests (prevents race condition where @@ -392,7 +392,7 @@ export const onRequest = defineMiddleware(async (context, next) => { } } - // Block /subsites/req/* on main domain (codey.lol, local.codey.lol, etc) + // Block /subsites/req/* on main domain (codey.horse, local.codey.horse, etc) const isMainDomain = !wantsSubsite; if (isMainDomain && Object.values(subsites || {}).some(p => context.url.pathname.startsWith(p))) { // Immediately return a 404 for /req on the main domain @@ -460,7 +460,7 @@ export const onRequest = defineMiddleware(async (context, next) => { "font-src 'self' https://fonts.gstatic.com data:", "img-src 'self' data: blob: https: http:", "media-src 'self' blob: https:", - "connect-src 'self' https://api.codey.lol https://*.codey.lol https://*.audio.tidal.com wss:", + "connect-src 'self' https://api.codey.horse https://*.codey.horse https://*.audio.tidal.com wss:", // Allow YouTube for video embeds and Cloudflare for challenges/Turnstile "frame-src 'self' https://www.youtube.com https://www.youtube-nocookie.com https://challenges.cloudflare.com", "object-src 'none'", diff --git a/src/middleware.ts b/src/middleware.ts index 6ec8145..514dc6b 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -64,7 +64,7 @@ if (typeof globalThis.Headers !== 'undefined' && typeof (globalThis.Headers.prot } } -const API_URL: string = "https://api.codey.lol"; +const API_URL: string = "https://api.codey.horse"; const AUTH_TIMEOUT_MS: number = 3000; // 3 second timeout for auth requests // Deduplication for concurrent refresh requests (prevents race condition where @@ -430,7 +430,7 @@ export const onRequest = defineMiddleware(async (context, next) => { } } - // Block /subsites/req/* on main domain (codey.lol, local.codey.lol, etc) + // Block /subsites/req/* on main domain (codey.horse, local.codey.horse, etc) const isMainDomain = !wantsSubsite; if (isMainDomain && Object.values(subsites || {}).some(p => context.url.pathname.startsWith(p))) { // Immediately return a 404 for /req on the main domain @@ -501,7 +501,7 @@ export const onRequest = defineMiddleware(async (context, next) => { "font-src 'self' https://fonts.gstatic.com data:", "img-src 'self' data: blob: https: http:", "media-src 'self' blob: https:", - "connect-src 'self' https://api.codey.lol https://*.codey.lol https://*.audio.tidal.com wss:", + "connect-src 'self' https://api.codey.horse https://*.codey.horse https://*.audio.tidal.com wss:", // Allow YouTube for video embeds and Cloudflare for challenges/Turnstile "frame-src 'self' https://www.youtube.com https://www.youtube-nocookie.com https://challenges.cloudflare.com", "object-src 'none'", diff --git a/src/pages/login.astro b/src/pages/login.astro index 197d0a4..26aa2c1 100644 --- a/src/pages/login.astro +++ b/src/pages/login.astro @@ -23,7 +23,7 @@ const getCookie = (name: string): string | null => { // Detect if returnUrl is external (nginx-protected vhost redirect) // If logged-in user arrives with external returnUrl, nginx denied them - show access denied const returnUrl = Astro.url.searchParams.get('returnUrl') || Astro.url.searchParams.get('redirect'); -const trustedDomains = ['codey.lol', 'boatson.boats']; +const trustedDomains = ['codey.horse', 'boatson.boats']; let isExternalReturn = false; let externalHostname = ''; diff --git a/src/utils/authFetch.ts b/src/utils/authFetch.ts index 82ed4f2..2ab1f94 100644 --- a/src/utils/authFetch.ts +++ b/src/utils/authFetch.ts @@ -39,7 +39,7 @@ export const authFetch = async ( }; // Centralized refresh function that handles deduplication properly -async function doRefresh(): Promise { +export async function doRefresh(): Promise { const now = Date.now(); // If a refresh just succeeded recently, assume we're good From ef15b646cce050be6c36725e6188b0a784e7ac65 Mon Sep 17 00:00:00 2001 From: codey Date: Sun, 22 Feb 2026 13:53:43 -0500 Subject: [PATCH 3/6] feat(Nav): Refactor navigation structure to support nested items and improve visibility logic feat(Radio): - Redesigned Queue modal, added drag & drop capabilities - Added stream quality selector, currently offering: AAC @ 128kbps, AAC @ 320kbps & FLAC (lossless) fix(middleware): Import API_URL from config and remove hardcoded API_URL definition security(api): Enhance discord image and video caching with improved signature verification and error handling, updated image proxy to include production checks for signing secret --- public/scripts/nav-controls.js | 109 ++++- src/assets/scripts/lenisSmoothScroll.ts | 3 + src/assets/styles/nav.css | 92 +++- src/assets/styles/player.css | 193 +++++++- src/components/DiscordLogs.tsx | 8 +- src/components/Footer.astro | 66 ++- src/components/Radio.tsx | 591 ++++++++++++++++++++---- src/layouts/Nav.astro | 234 +++++++--- src/middleware.ts | 3 +- src/pages/api/discord/cached-image.ts | 46 +- src/pages/api/discord/cached-video.ts | 29 +- src/pages/api/image-proxy.ts | 6 +- 12 files changed, 1188 insertions(+), 192 deletions(-) diff --git a/public/scripts/nav-controls.js b/public/scripts/nav-controls.js index 67add06..062fb20 100644 --- a/public/scripts/nav-controls.js +++ b/public/scripts/nav-controls.js @@ -30,6 +30,16 @@ } }; + function setLenisEnabled(enabled) { + const lenis = window.__lenis; + if (!lenis) return; + if (enabled) { + lenis.start(); + } else { + lenis.stop(); + } + } + function initMobileMenu() { const menuBtn = document.getElementById("mobile-menu-btn"); const mobileMenu = document.getElementById("mobile-menu"); @@ -51,10 +61,12 @@ mobileMenu.classList.remove("open"); menuIcon.style.display = "block"; closeIcon.style.display = "none"; + setLenisEnabled(true); } else { mobileMenu.classList.add("open"); menuIcon.style.display = "none"; closeIcon.style.display = "block"; + setLenisEnabled(false); } }); @@ -64,6 +76,7 @@ mobileMenu.classList.remove("open"); menuIcon.style.display = "block"; closeIcon.style.display = "none"; + setLenisEnabled(true); }); }); @@ -73,6 +86,7 @@ mobileMenu.classList.remove("open"); menuIcon.style.display = "block"; closeIcon.style.display = "none"; + setLenisEnabled(true); } } }; @@ -81,7 +95,93 @@ document.addEventListener("click", closeHandler); } - const ready = () => initMobileMenu(); + function initDropdownExternalLinks() { + // Close desktop dropdown when external links are clicked + const dropdownLinks = document.querySelectorAll('.nav-dropdown a[target="_blank"]'); + dropdownLinks.forEach((link) => { + link.addEventListener("click", () => { + // Blur the parent nav item to close the CSS hover-based dropdown + const navItem = link.closest('.nav-item--has-children'); + if (navItem) { + const parentLink = navItem.querySelector(':scope > a'); + if (parentLink) { + parentLink.blur(); + } + // Force close by temporarily disabling pointer events + navItem.style.pointerEvents = 'none'; + setTimeout(() => { + navItem.style.pointerEvents = ''; + }, 100); + } + }); + }); + } + + function initDesktopDropdownToggle() { + // Desktop: click parent to toggle dropdown open/closed + const desktopNav = document.querySelector('nav .hidden.md\\:flex'); + if (!desktopNav) return; + + const parentItems = desktopNav.querySelectorAll('.nav-item--has-children'); + parentItems.forEach((navItem) => { + const parentLink = navItem.querySelector(':scope > a'); + if (!parentLink) return; + + parentLink.addEventListener('click', (e) => { + e.preventDefault(); + const isOpen = navItem.classList.contains('dropdown-open'); + + // Close any other open dropdowns + parentItems.forEach((item) => item.classList.remove('dropdown-open')); + + if (!isOpen) { + navItem.classList.add('dropdown-open'); + } + }); + }); + + // Close dropdown when clicking outside + document.addEventListener('click', (e) => { + if (!desktopNav.contains(e.target)) { + parentItems.forEach((item) => item.classList.remove('dropdown-open')); + } + }); + } + + function initMobileSubmenus() { + const mobileMenu = document.getElementById("mobile-menu"); + if (!mobileMenu) return; + + // Find all parent links that have a sibling mobile-subnav + const parentLinks = mobileMenu.querySelectorAll('a:has(+ .mobile-subnav)'); + + parentLinks.forEach((link) => { + const subnav = link.nextElementSibling; + const caret = link.querySelector('.mobile-caret'); + + if (!subnav || !subnav.classList.contains('mobile-subnav')) return; + + // Clone to remove existing listeners + const newLink = link.cloneNode(true); + link.parentNode.replaceChild(newLink, link); + + const newCaret = newLink.querySelector('.mobile-caret'); + + newLink.addEventListener('click', (e) => { + // Toggle subnav open/closed on click + e.preventDefault(); + subnav.classList.toggle('open'); + if (newCaret) newCaret.classList.toggle('open'); + }); + }); + } + + const ready = () => { + initMobileMenu(); + initDropdownExternalLinks(); + initDesktopDropdownToggle(); + initMobileSubmenus(); + }; if (document.readyState === "loading") { document.addEventListener("DOMContentLoaded", ready, { once: true }); @@ -89,5 +189,10 @@ ready(); } - document.addEventListener("astro:page-load", initMobileMenu); + document.addEventListener("astro:page-load", () => { + initMobileMenu(); + initDropdownExternalLinks(); + initDesktopDropdownToggle(); + initMobileSubmenus(); + }); })(); diff --git a/src/assets/scripts/lenisSmoothScroll.ts b/src/assets/scripts/lenisSmoothScroll.ts index e7c43a9..7602110 100644 --- a/src/assets/scripts/lenisSmoothScroll.ts +++ b/src/assets/scripts/lenisSmoothScroll.ts @@ -11,3 +11,6 @@ function raf(time) { } requestAnimationFrame(raf); + +// Expose lenis instance globally for nav controls +(window as any).__lenis = lenis; diff --git a/src/assets/styles/nav.css b/src/assets/styles/nav.css index 834a90c..27aab25 100644 --- a/src/assets/styles/nav.css +++ b/src/assets/styles/nav.css @@ -29,8 +29,9 @@ } .mobile-menu-dropdown.open { - max-height: none; - overflow: visible; + max-height: calc(100vh - 4rem); + overflow-y: auto; + overscroll-behavior: contain; padding-bottom: 0.75rem; opacity: 1; } @@ -83,6 +84,70 @@ nav { flex-shrink: 0; } +.nav-item { + position: relative; +} + +.nav-item--has-children .nav-caret { + margin-left: 0.25rem; + font-size: 0.85rem; + opacity: 0.7; + transition: transform 0.15s ease; +} + +.nav-item--has-children:hover .nav-caret, +.nav-item--has-children:focus-within .nav-caret { + transform: translateY(1px) rotate(180deg); +} + +.nav-dropdown { + position: absolute; + top: 100%; + margin-top: 0.2rem; + left: 0; + min-width: 12.5rem; + background: rgba(255, 255, 255, 0.98); + border: 1px solid rgba(226, 232, 240, 0.9); + border-radius: 0.75rem; + box-shadow: 0 14px 45px rgba(0, 0, 0, 0.12), 0 10px 18px rgba(0, 0, 0, 0.08); + padding: 0.35rem; + opacity: 0; + transform: translateY(4px); + pointer-events: none; + transition: opacity 0.2s ease, transform 0.2s ease; + z-index: 30; +} + +.nav-item--has-children .nav-dropdown::before { + content: ""; + position: absolute; + top: -10px; + left: 0; + right: 0; + height: 12px; + background: transparent; +} + +[data-theme="dark"] .nav-dropdown { + background: rgba(17, 24, 39, 0.98); + border-color: rgba(75, 85, 99, 0.6); + box-shadow: 0 14px 45px rgba(0, 0, 0, 0.45), 0 10px 18px rgba(0, 0, 0, 0.4); +} + +.nav-dropdown ul { + display: flex; + flex-direction: column; + gap: 0.15rem; +} + +.nav-item--has-children:hover .nav-dropdown, +.nav-item--has-children:focus-within .nav-dropdown, +.nav-item--has-children.dropdown-open .nav-dropdown { + opacity: 1; + transform: translateY(0); + pointer-events: auto; +} + .desktop-nav-list a { white-space: nowrap; padding: 0.375rem 0.625rem; @@ -170,3 +235,26 @@ nav { border-color: rgba(248, 113, 113, 0.6); box-shadow: 0 2px 8px rgba(239, 68, 68, 0.3), inset 0 1px 0 rgba(239, 68, 68, 0.1); } + +.mobile-subnav { + display: none; + flex-direction: column; + gap: 0.15rem; + margin: 0.2rem 0 0.35rem; + padding-left: 0.75rem; +} + +.mobile-subnav.open { + display: flex; +} + +.mobile-caret { + margin-left: auto; + font-size: 0.95rem; + opacity: 0.55; + transition: transform 0.2s ease; +} + +.mobile-caret.open { + transform: rotate(90deg); +} diff --git a/src/assets/styles/player.css b/src/assets/styles/player.css index aed13e3..7141b5b 100644 --- a/src/assets/styles/player.css +++ b/src/assets/styles/player.css @@ -5,6 +5,38 @@ box-sizing: border-box; } +/* Marquee animation for long text */ +@keyframes marquee { + 0% { + transform: translateX(0); + } + 100% { + transform: translateX(-50%); + } +} + +.marquee-wrapper { + overflow: hidden; + position: relative; + width: 100%; +} + +.marquee-content { + display: inline-block; + white-space: nowrap; +} + +.marquee-content.scrolling { + animation: marquee 10s linear infinite; + padding-right: 2rem; +} + +/* Duplicate text for seamless loop */ +.marquee-content.scrolling::after { + content: attr(data-text); + padding-left: 2rem; +} + :root { --lrc-text-color: #666; /* muted text for inactive lines */ --lrc-bg-color: rgba(0, 0, 0, 0.05); @@ -42,6 +74,7 @@ body { overflow-y: hidden; position: relative; display: flex; + flex-wrap: wrap; justify-content: center; align-items: center; box-sizing: border-box; @@ -49,6 +82,11 @@ body { box-shadow: 1px 1px 5px 0 rgba(0, 0, 0, 0.3); } +/* Header - hidden on desktop (shown inline in music-player), visible on mobile */ +.music-container > .music-player__header { + display: none; +} + /* Album cover section */ .album-cover { aspect-ratio: 1 / 1; @@ -213,31 +251,172 @@ body { margin-bottom: 50px !important; overflow: visible !important; padding: 1rem !important; + gap: 0 !important; } + /* Show header on mobile - it's first in DOM order */ + .music-container > .music-player__header { + display: block !important; + width: 100% !important; + text-align: center !important; + font-size: 1.25rem !important; + margin-bottom: 0.5rem !important; + } + + /* Album cover below header */ .album-cover { - flex: none !important; + flex: 0 0 auto !important; + aspect-ratio: unset !important; width: 100% !important; max-width: 100% !important; height: auto !important; - margin-bottom: 1rem !important; + margin: 0 auto !important; min-width: 0 !important; + padding: 0 1rem !important; + box-sizing: border-box !important; } .album-cover > img { - aspect-ratio: unset !important; + aspect-ratio: 1 / 1 !important; height: auto !important; - width: 100% !important; + width: 60% !important; + max-width: 220px !important; + border-radius: 0.75rem !important; + display: block !important; + margin: 0 auto !important; + object-fit: cover !important; } + .music-player__album { + font-size: 0.75rem !important; + margin-bottom: 0.25rem !important; + text-align: center !important; + padding: 0 !important; + word-wrap: break-word !important; + overflow-wrap: break-word !important; + } + + /* Player info below album */ .music-player { - flex: none !important; + flex: 0 0 auto !important; width: 100% !important; max-width: 100% !important; height: auto !important; - padding: 0 !important; + padding: 0 1rem !important; min-width: 0 !important; - flex-shrink: 0 !important; + text-align: center !important; + margin-top: 0.25rem !important; + } + + /* Hide header inside music-player on mobile (using the one outside) */ + .music-player .music-player__header { + display: none !important; + } + + .music-player__title { + font-size: 1rem !important; + margin-bottom: 0.15rem !important; + padding: 0 1rem !important; + } + + .music-player__author { + font-size: 0.85rem !important; + margin-bottom: 0.35rem !important; + padding: 0 1rem !important; + } + + .music-player__genre { + font-size: 0.8rem !important; + margin-bottom: 0.35rem !important; + padding: 0 1rem !important; + } + + .music-player__album { + padding: 0 1rem !important; + } + + .music-time { + margin-top: 0.35rem !important; + font-size: 0.8rem !important; + } + + .music-control { + margin-top: 0.5rem !important; + } + + .music-control__play { + width: 2.5rem !important; + height: 2.5rem !important; + } + + /* Lyrics section */ + .lrc-text { + max-height: 150px !important; + margin-top: 0.5rem !important; + padding: 0.5rem !important; + font-size: 0.75rem !important; + } + + .lrc-line { + font-size: 0.75rem !important; + } + + .lrc-line.active { + font-size: 0.85rem !important; + } + + /* DJ controls */ + .dj-controls { + min-width: 0 !important; + width: 100% !important; + margin-top: 0.75rem !important; + } + + .dj-controls .flex-col { + min-width: 0 !important; + width: 100% !important; + } + + .dj-controls .typeahead-input input, + .dj-controls .p-autocomplete-input { + width: 100% !important; + min-width: 0 !important; + max-width: 100% !important; + } + + .dj-controls .p-autocomplete { + width: 100% !important; + } + + .dj-controls .flex-row { + min-width: 0 !important; + width: 100% !important; + justify-content: center !important; + } + + .station-tabs { + padding-top: 2% !important; + padding-bottom: 2% !important; + } + + /* Next track styling for mobile */ + .next-track { + font-size: 0.7rem !important; + padding: 0.25rem 0.5rem !important; + margin-top: 0.25rem !important; + } +} + +/* Extra small screens */ +@media all and (max-width: 400px) { + .album-cover { + width: 70% !important; + max-width: 200px !important; + } + + .station-tabs button { + padding: 0.35rem 0.6rem !important; + font-size: 0.8rem !important; } } .progress-bar-container { diff --git a/src/components/DiscordLogs.tsx b/src/components/DiscordLogs.tsx index f052118..f6f456b 100644 --- a/src/components/DiscordLogs.tsx +++ b/src/components/DiscordLogs.tsx @@ -2,6 +2,7 @@ import React, { useState, useEffect, useLayoutEffect, useMemo, useCallback, memo import type { AnimationItem } from 'lottie-web'; import { ProgressSpinner } from 'primereact/progressspinner'; import { authFetch } from '@/utils/authFetch'; +import DOMPurify from 'isomorphic-dompurify'; // ============================================================================ // Type Definitions @@ -981,7 +982,12 @@ function parseDiscordMarkdown(text: string | null | undefined, options: ParseOpt // Must be done after all markdown processing parsed = parsed.replace(/\\([_*~`|\\])/g, '$1'); - return parsed; + // Final sanitization pass with DOMPurify to prevent XSS + return DOMPurify.sanitize(parsed, { + ALLOWED_TAGS: ['strong', 'em', 'u', 's', 'span', 'code', 'pre', 'br', 'a', 'img', 'blockquote'], + ALLOWED_ATTR: ['class', 'href', 'target', 'rel', 'src', 'alt', 'title', 'style', 'data-lenis-prevent', 'data-channel-id', 'data-user-id', 'data-role-id'], + ALLOW_DATA_ATTR: true, + }); } catch (err) { try { console.error('parseDiscordMarkdown failed', err); } catch (e) { /* ignore logging errors */ } // Fallback: return a safely-escaped version of the input to avoid crashing the UI diff --git a/src/components/Footer.astro b/src/components/Footer.astro index 27f8581..b9a63f2 100644 --- a/src/components/Footer.astro +++ b/src/components/Footer.astro @@ -23,27 +23,49 @@ const envBadge = ENVIRONMENT === 'Dev' ? 'DEV' : null; {envBadge && } {!envBadge && } - {versionDisplay}:{buildNumber} + {versionDisplay}:{buildNumber} +
+
+ ); +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// ICONS +// ═══════════════════════════════════════════════════════════════════════════════ +const PlayIcon = () => ( + + + +); + +const PauseIcon = () => ( + + + +); + +const ExpandIcon = () => ( + + + +); + +const ChevronUpIcon = () => ( + + + +); + +// ═══════════════════════════════════════════════════════════════════════════════ +// COMPONENT +// ═══════════════════════════════════════════════════════════════════════════════ +export default function MiniRadioPlayer() { + const [station, setStation] = useState(() => { + if (typeof window === "undefined") return "main"; + return getGlobalRadioState().station; + }); + const [isPlaying, setIsPlaying] = useState(() => { + if (typeof window === "undefined") return false; + return getGlobalRadioState().isPlaying; + }); + const [isReady, setIsReady] = useState(false); + const [trackTitle, setTrackTitle] = useState(""); + const [trackArtist, setTrackArtist] = useState(""); + const [trackAlbum, setTrackAlbum] = useState(""); + const [coverArt, setCoverArt] = useState("/images/radio_art_default.jpg"); + const [nextTrack, setNextTrack] = useState<{ artist: string; song: string } | null>(null); + const [qualityLevels, setQualityLevels] = useState([]); + const [selectedQuality, setSelectedQuality] = useState(-1); + const [isHidden, setIsHidden] = useState(() => { + if (typeof window === "undefined") return false; + return localStorage.getItem("miniPlayerHidden") === "true"; + }); + const [stationDropdownOpen, setStationDropdownOpen] = useState(false); + const [qualityDropdownOpen, setQualityDropdownOpen] = useState(false); + const [elapsedTime, setElapsedTime] = useState(0); + const [trackDuration, setTrackDuration] = useState(0); + const [hiddenByMobileNav, setHiddenByMobileNav] = useState(false); + + const wsRef = useRef(null); + const stationDropdownRef = useRef(null); + const qualityDropdownRef = useRef(null); + const stationRef = useRef(station); + const stationChangeSeqRef = useRef(0); + const trackUuidRef = useRef(null); + const originalTitleRef = useRef(""); + const baseTrackElapsed = useRef(0); + const lastUpdateTimestamp = useRef(Date.now()); + const playerRef = useRef(null); + + // Store original page title on mount + useEffect(() => { + if (typeof window !== "undefined") { + originalTitleRef.current = document.title; + } + }, []); + + // Update page title when playing + const updatePageTitle = useCallback((artist: string, title: string, stationName: string) => { + if (typeof window === "undefined") return; + document.title = `${metaData.title} - Radio - ${artist} - ${title} [${stationName}]`; + }, []); + + const restorePageTitle = useCallback(() => { + if (typeof window === "undefined") return; + // Restore to site default, not the captured title (in case user navigated) + document.title = metaData.title; + }, []); + + // Manage page title based on playing state + useEffect(() => { + if (isPlaying && trackArtist && trackTitle && trackTitle !== "serious.FM" && trackTitle !== "Loading...") { + updatePageTitle(trackArtist, trackTitle, STATIONS[station]?.label || station); + } else if (!isPlaying) { + restorePageTitle(); + } + }, [isPlaying, trackArtist, trackTitle, station, updatePageTitle, restorePageTitle]); + + // Restore title when hidden + useEffect(() => { + if (isHidden) { + restorePageTitle(); + } + }, [isHidden, restorePageTitle]); + + // Auto-hide mini player when mobile nav is open, restore on close + useEffect(() => { + if (typeof window === "undefined") return; + + const mobileMenu = document.getElementById("mobile-menu"); + if (!mobileMenu) return; + + const wasExpandedRef = { current: false }; + + const observer = new MutationObserver((mutations) => { + for (const mutation of mutations) { + if (mutation.type === "attributes" && mutation.attributeName === "class") { + const isNavOpen = mobileMenu.classList.contains("open"); + + if (isNavOpen && !isHidden) { + // Nav opened and player is visible - hide it + wasExpandedRef.current = true; + setHiddenByMobileNav(true); + setIsHidden(true); + } else if (!isNavOpen && hiddenByMobileNav) { + // Nav closed and we had hidden the player - restore it + setHiddenByMobileNav(false); + if (wasExpandedRef.current) { + setIsHidden(false); + wasExpandedRef.current = false; + } + } + } + } + }); + + observer.observe(mobileMenu, { attributes: true }); + + return () => observer.disconnect(); + }, [isHidden, hiddenByMobileNav]); + + // Keep ref in sync + useEffect(() => { + stationRef.current = station; + }, [station]); + + // Keep global video element mounted offscreen outside /radio so playback persists across navigation. + useEffect(() => { + if (typeof window === "undefined") return; + + const parkVideoOffscreen = () => { + if (!isVideoStation(stationRef.current)) return; + const video = getOrCreateVideo(); + video.style.position = "fixed"; + video.style.left = "-9999px"; + video.style.top = "-9999px"; + video.style.width = "1px"; + video.style.height = "1px"; + video.style.opacity = "0"; + video.style.pointerEvents = "none"; + video.setAttribute("aria-hidden", "true"); + if (video.parentNode !== document.body) { + document.body.appendChild(video); + } + }; + + const syncVideoMount = () => { + if (!isVideoStation(stationRef.current)) return; + + // Let the full radio page own/render the video element on /radio. + if (window.location.pathname === "/radio") return; + + const video = getOrCreateVideo(); + + // Keep it in DOM but invisible so browsers don't suspend detached element playback. + video.style.position = "fixed"; + video.style.left = "-9999px"; + video.style.top = "-9999px"; + video.style.width = "1px"; + video.style.height = "1px"; + video.style.opacity = "0"; + video.style.pointerEvents = "none"; + video.setAttribute("aria-hidden", "true"); + + if (video.parentNode !== document.body) { + document.body.appendChild(video); + } + }; + + syncVideoMount(); + // Ensure the video element is detached from page-local DOM before Astro swaps pages. + document.addEventListener("astro:before-swap", parkVideoOffscreen as EventListener); + document.addEventListener("astro:page-load", syncVideoMount as EventListener); + window.addEventListener("popstate", syncVideoMount); + + return () => { + // Keep playing across unmount when leaving the current page. + parkVideoOffscreen(); + document.removeEventListener("astro:before-swap", parkVideoOffscreen as EventListener); + document.removeEventListener("astro:page-load", syncVideoMount as EventListener); + window.removeEventListener("popstate", syncVideoMount); + }; + }, [station]); + + // Update elapsed time smoothly (interpolation between WebSocket updates) + // Continue updating even when paused to reflect current stream position + useEffect(() => { + const intervalId = setInterval(() => { + const now = Date.now(); + const deltaSec = (now - lastUpdateTimestamp.current) / 1000; + let liveElapsed = (typeof baseTrackElapsed.current === 'number' && !isNaN(baseTrackElapsed.current) ? baseTrackElapsed.current : 0) + deltaSec; + if (trackDuration && liveElapsed > trackDuration) liveElapsed = trackDuration; + setElapsedTime(liveElapsed); + }, 200); + return () => clearInterval(intervalId); + }, [trackDuration]); + + // Helper to format time as MM:SS + const formatTime = (seconds: number): string => { + if (!seconds || isNaN(seconds) || seconds < 0) return "00:00"; + const mins = String(Math.floor(seconds / 60)).padStart(2, "0"); + const secs = String(Math.floor(seconds % 60)).padStart(2, "0"); + return `${mins}:${secs}`; + }; + + const cleanupWs = useCallback(() => { + if (wsRef.current) { + wsRef.current.onclose = null; + wsRef.current.onerror = null; + wsRef.current.onmessage = null; + wsRef.current.close(); + wsRef.current = null; + } + }, []); + + const fetchNextTrack = useCallback(async (forStation: string) => { + try { + const response = await authFetch(`${API_URL}/radio/get_queue`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ station: forStation, start: 0, length: 1, search: "" }), + }); + const data = await response.json(); + if (data.items && data.items.length > 0) { + setNextTrack({ artist: data.items[0].artist, song: data.items[0].song }); + } else { + setNextTrack(null); + } + } catch { + setNextTrack(null); + } + }, []); + + const connectMetadata = useCallback((nextStation: string) => { + cleanupWs(); + const wsUrl = `${API_URL.replace(/^https?:/, "wss:")}/radio/ws/${nextStation}`; + const ws = new WebSocket(wsUrl); + + ws.onmessage = (event) => { + try { + const data = JSON.parse(event.data); + if (data?.type === "pong" || data?.type === "ping" || data?.type === "status" || data?.type === "error") return; + + const payload = data?.data || data; + const song = payload?.song || payload?.title; + const artist = payload?.artist; + const album = payload?.album; + const uuid = payload?.uuid; + const elapsed = payload?.elapsed; + const duration = payload?.duration; + + // Update duration/elapsed regardless of track change + if (typeof duration === 'number' && !isNaN(duration)) { + setTrackDuration(duration); + } + if (typeof elapsed === 'number' && !isNaN(elapsed)) { + baseTrackElapsed.current = elapsed; + lastUpdateTimestamp.current = Date.now(); + setElapsedTime(elapsed); + } + + if (song || artist) { + const title = song || "Unknown Title"; + const artistName = artist || "Unknown Artist"; + const albumName = album || ""; + + setTrackTitle(title); + setTrackArtist(artistName); + setTrackAlbum(albumName); + + // Update cover art and next track on track change + if (uuid && uuid !== trackUuidRef.current) { + trackUuidRef.current = uuid; + const newCoverArt = `${API_URL}/radio/album_art?station=${stationRef.current}&_=${Date.now()}`; + setCoverArt(newCoverArt); + fetchNextTrack(stationRef.current); + + // Apply subtle one-shot catch-up on track transition (audio stations only). + if (!isVideoStation(stationRef.current)) { + const state = getGlobalRadioState(); + triggerOneShotLiveCatchup(state.hls, getOrCreateAudio()); + } + + // Reset elapsed time on track change + baseTrackElapsed.current = typeof elapsed === 'number' && !isNaN(elapsed) ? elapsed : 0; + lastUpdateTimestamp.current = Date.now(); + setElapsedTime(baseTrackElapsed.current); + + // Update global state for seamless handoff to full Radio page + updateGlobalTrackInfo({ + title, + artist: artistName, + album: albumName, + coverArt: newCoverArt, + uuid, + }); + } + } + } catch { + // no-op + } + }; + + ws.onerror = () => {}; + ws.onclose = () => { + // Reconnect after a delay + setTimeout(() => { + if (stationRef.current === nextStation) { + connectMetadata(nextStation); + } + }, 3000); + }; + + wsRef.current = ws; + }, [cleanupWs, fetchNextTrack]); + + const initStream = useCallback(async (nextStation: string): Promise => { + const isVideo = isVideoStation(nextStation); + const media = isVideo ? getOrCreateVideo() : getOrCreateAudio(); + const state = getGlobalRadioState(); + + // Ensure no carry-over speed adjustment from previous HLS catch-up. + media.playbackRate = 1; + + // Clean up old HLS + destroyGlobalHls(); + + const streamUrl = getStreamUrl(nextStation); + + const { default: Hls } = await import("hls.js"); + + if (Hls.isSupported()) { + const hls = new Hls(SHARED_HLS_CONFIG); + + setGlobalHls(hls); + hls.attachMedia(media); + installOneShotLiveCatchup(hls, media); + + return new Promise((resolve) => { + hls.on(Hls.Events.MEDIA_ATTACHED, () => hls.loadSource(streamUrl)); + hls.on(Hls.Events.MANIFEST_PARSED, (_event, data) => { + const levelsRaw = data?.levels ?? []; + + // For video stations, use video quality labels + if (isVideo) { + const levels: QualityLevel[] = levelsRaw.map((level: any, index: number) => { + const height = level.height || 0; + const name = height ? `${height}p` : `Level ${index + 1}`; + return { index, bitrate: level.bitrate || 0, name, isLossless: false }; + }); + levels.sort((a, b) => b.bitrate - a.bitrate); + setQualityLevels(levels); + setGlobalQualityLevels(levels); + setSelectedQuality(-1); // Auto for video + } else { + const standards = [64, 96, 128, 160, 192, 256, 320]; + + const levels: QualityLevel[] = levelsRaw.map((level: any, index: number) => { + const kbps = (level.bitrate || 0) / 1000; + const codec = level.audioCodec || level.codecs || ""; + const isLossless = codec.toLowerCase().includes("flac") || codec.toLowerCase().includes("alac") || kbps > 500; + let name: string; + if (isLossless) { + name = "Lossless"; + } else if (level.bitrate) { + const friendly = standards.reduce((prev, curr) => Math.abs(curr - kbps) < Math.abs(prev - kbps) ? curr : prev); + name = `${friendly}k`; + } else { + name = `Level ${index + 1}`; + } + return { index, bitrate: level.bitrate || 0, name, isLossless }; + }); + + levels.sort((a, b) => { + if (a.isLossless && !b.isLossless) return -1; + if (!a.isLossless && b.isLossless) return 1; + return b.bitrate - a.bitrate; + }); + + setQualityLevels(levels); + setGlobalQualityLevels(levels); + + // Apply saved quality + const savedQuality = state.qualityLevel; + if (savedQuality !== -1 && levels.some((l) => l.index === savedQuality)) { + hls.currentLevel = savedQuality; + setSelectedQuality(savedQuality); + } else { + setSelectedQuality(-1); + } + } + + setIsReady(true); + resolve(true); + }); + + hls.on(Hls.Events.ERROR, (_e, errData) => { + if (!errData?.fatal) return; + if (errData.type === Hls.ErrorTypes.MEDIA_ERROR) { + try { + hls.recoverMediaError(); + return; + } catch { + // fallthrough + } + } + destroyGlobalHls(); + setIsReady(false); + resolve(false); + }); + }); + } + + // Fallback for Safari + if (media.canPlayType("application/vnd.apple.mpegurl")) { + media.src = streamUrl; + media.load(); + setQualityLevels([]); + setSelectedQuality(-1); + setIsReady(true); + return true; + } + + return false; + }, []); + + const handleQualityChange = useCallback((levelIndex: number) => { + const state = getGlobalRadioState(); + setSelectedQuality(levelIndex); + updateGlobalQuality(levelIndex); + if (state.hls) { + state.hls.currentLevel = levelIndex; + } + emitRadioEvent(RADIO_EVENTS.QUALITY_CHANGED, { quality: levelIndex }); + }, []); + + const handlePlayPause = useCallback(async () => { + const isVideo = isVideoStation(station); + const media = isVideo ? getOrCreateVideo() : getOrCreateAudio(); + const state = getGlobalRadioState(); + const actuallyPlaying = !media.paused && !media.ended; + + if (actuallyPlaying) { + media.pause(); + setIsPlaying(false); + updateGlobalPlayingState(false); + emitRadioEvent(RADIO_EVENTS.PLAYBACK_CHANGED, { isPlaying: false }); + return; + } + + // Passive tab case: another tab is currently playing. Pause globally instead of starting local playback. + if (state.isPlaying) { + requestGlobalPauseAll(); + setIsPlaying(false); + return; + } + + // If playback is currently stopped but another tab still owns the media session, + // ask that owner tab to resume so video does not remain frozen there. + if (requestOwnerTabResume()) { + setIsPlaying(true); + return; + } + + // Initialize stream if needed + if (!isReady || !state.hls) { + const ok = await initStream(station); + if (!ok) { + setTrackTitle("Offline"); + setTrackArtist("Stream unavailable"); + setIsPlaying(false); + updateGlobalPlayingState(false); + return; + } + } + + try { + media.playbackRate = 1; + await media.play(); + setIsPlaying(true); + updateGlobalPlayingState(true); + emitRadioEvent(RADIO_EVENTS.PLAYBACK_CHANGED, { isPlaying: true }); + } catch { + setIsPlaying(false); + updateGlobalPlayingState(false); + } + }, [isReady, station, initStream]); + + const handleStationChange = useCallback(async (newStation: string) => { + // Pause current media (audio or video) + const wasVideo = isVideoStation(station); + const oldMedia = wasVideo ? getOrCreateVideo() : getOrCreateAudio(); + const wasPlaying = !oldMedia.paused && !oldMedia.ended; + const seq = ++stationChangeSeqRef.current; + + // Stop current playback immediately + oldMedia.pause(); + setIsPlaying(false); + updateGlobalPlayingState(false); + + // Update state + setStation(newStation); + updateGlobalStation(newStation); + + // Reset track info + const isVideo = isVideoStation(newStation); + setTrackTitle(""); + setTrackArtist(""); + setTrackAlbum(""); + setCoverArt("/images/radio_art_default.jpg"); + setNextTrack(null); + trackUuidRef.current = null; + + // Connect to new station's metadata + connectMetadata(newStation); + + // Emit station change event + emitRadioEvent(RADIO_EVENTS.STATION_CHANGED, { station: newStation }); + + // Always prepare the new station stream to keep selected station and audio in sync + const ok = await initStream(newStation); + if (!ok) return; + + // Ignore stale async completion if user changed station again quickly + if (seq !== stationChangeSeqRef.current || stationRef.current !== newStation) return; + + // Resume playback only if it was previously playing + if (wasPlaying) { + const newMedia = isVideo ? getOrCreateVideo() : getOrCreateAudio(); + try { + newMedia.playbackRate = 1; + await newMedia.play(); + setIsPlaying(true); + updateGlobalPlayingState(true); + } catch { + // ignore + } + } + }, [connectMetadata, initStream]); + + // On mount: sync with global state and connect metadata + useEffect(() => { + const state = getGlobalRadioState(); + const isVideo = isVideoStation(state.station); + const media = isVideo ? getOrCreateVideo() : getOrCreateAudio(); + + // Sync from global + setStation(state.station); + setIsPlaying(state.isPlaying || (!media.paused && !media.ended)); + setQualityLevels(state.qualityLevels); + setSelectedQuality(state.qualityLevel); + + // If already playing, mark ready + if (state.hls && state.isPlaying) { + setIsReady(true); + } + + // Restore track info from global state if available + if (state.trackTitle) { + setTrackTitle(state.trackTitle); + setTrackArtist(state.trackArtist); + setTrackAlbum(state.trackAlbum); + setCoverArt(state.coverArt); + trackUuidRef.current = state.trackUuid; + } + + // Connect metadata WebSocket + connectMetadata(state.station); + + // Fetch next track and cover art (only for audio stations) + if (!isVideo) { + fetchNextTrack(state.station); + // Initial cover art (only if not restored from global) + if (!state.trackTitle) { + setCoverArt(`${API_URL}/radio/album_art?station=${state.station}&_=${Date.now()}`); + } + } + + // Sync isPlaying state with actual media element state + const audio = getOrCreateAudio(); + const video = getOrCreateVideo(); + const handlePlay = () => { + setIsPlaying(true); + updateGlobalPlayingState(true); + }; + const handlePause = () => { + setIsPlaying(false); + updateGlobalPlayingState(false); + }; + audio.addEventListener('play', handlePlay); + audio.addEventListener('pause', handlePause); + video.addEventListener('play', handlePlay); + video.addEventListener('pause', handlePause); + + // Listen for events from the full Radio page + const unsubPlayback = onRadioEvent(RADIO_EVENTS.PLAYBACK_CHANGED, (data) => { + setIsPlaying(data.isPlaying); + }); + const unsubStation = onRadioEvent(RADIO_EVENTS.STATION_CHANGED, (data) => { + setStation(data.station); + const stationIsVideo = isVideoStation(data.station); + // Reset track info when station changes externally + if (stationIsVideo) { + setTrackTitle(""); + setTrackArtist(""); + setTrackAlbum(""); + setCoverArt("/images/radio_art_default.jpg"); + setElapsedTime(0); + setTrackDuration(0); + baseTrackElapsed.current = 0; + lastUpdateTimestamp.current = Date.now(); + } else { + setTrackTitle(""); + setTrackArtist(""); + setTrackAlbum(""); + setCoverArt("/images/radio_art_default.jpg"); + setElapsedTime(0); + setTrackDuration(0); + baseTrackElapsed.current = 0; + lastUpdateTimestamp.current = Date.now(); + fetchNextTrack(data.station); + } + // Connect metadata for all stations (video included) + connectMetadata(data.station); + }); + const unsubQuality = onRadioEvent(RADIO_EVENTS.QUALITY_CHANGED, (data) => { + setSelectedQuality(data.quality); + }); + const unsubTrack = onRadioEvent(RADIO_EVENTS.TRACK_CHANGED, (data) => { + const isNewTrack = !!data?.uuid && data.uuid !== trackUuidRef.current; + if (data.title) setTrackTitle(data.title); + if (data.artist) setTrackArtist(data.artist); + if (data.album) setTrackAlbum(data.album); + if (data.coverArt) setCoverArt(data.coverArt); + if (data.uuid) trackUuidRef.current = data.uuid; + + // On cross-tab track switch (e.g., skip), clear stale prior-track progress immediately. + if (isNewTrack) { + const nextElapsed = typeof data?.elapsed === "number" && !isNaN(data.elapsed) ? data.elapsed : 0; + const nextDuration = typeof data?.duration === "number" && !isNaN(data.duration) ? data.duration : 0; + baseTrackElapsed.current = nextElapsed; + lastUpdateTimestamp.current = Date.now(); + setElapsedTime(nextElapsed); + setTrackDuration(nextDuration); + } + + fetchNextTrack(stationRef.current); + }); + + return () => { + cleanupWs(); + audio.removeEventListener('play', handlePlay); + audio.removeEventListener('pause', handlePause); + video.removeEventListener('play', handlePlay); + video.removeEventListener('pause', handlePause); + unsubPlayback(); + unsubStation(); + unsubQuality(); + unsubTrack(); + // Restore page title on unmount + restorePageTitle(); + // Note: We do NOT cleanup audio/HLS here - they persist across navigation + }; + }, [connectMetadata, fetchNextTrack, cleanupWs, restorePageTitle]); + + // Reconcile play/pause state with actual media element to avoid stale UI state. + useEffect(() => { + const syncPlaybackState = () => { + const state = getGlobalRadioState(); + const isVideo = isVideoStation(stationRef.current); + const media = isVideo ? getOrCreateVideo() : getOrCreateAudio(); + const actuallyPlaying = !media.paused && !media.ended; + + // If this tab is actively playing, publish that. + if (actuallyPlaying) { + setIsPlaying(true); + if (!state.isPlaying) { + updateGlobalPlayingState(true); + } + return; + } + + // Passive tab: mirror shared playback state without forcing it to false. + setIsPlaying(!!state.isPlaying); + }; + + syncPlaybackState(); + const interval = setInterval(syncPlaybackState, 1000); + document.addEventListener('visibilitychange', syncPlaybackState); + document.addEventListener('astro:page-load', syncPlaybackState as EventListener); + + return () => { + clearInterval(interval); + document.removeEventListener('visibilitychange', syncPlaybackState); + document.removeEventListener('astro:page-load', syncPlaybackState as EventListener); + }; + }, []); + + // Periodic next track refresh + useEffect(() => { + const interval = setInterval(() => { + fetchNextTrack(stationRef.current); + }, 15000); + return () => clearInterval(interval); + }, [fetchNextTrack]); + + // Dynamic spacing: add margin to footer to make room for player + useEffect(() => { + const footer = document.querySelector('.footer') as HTMLElement | null; + if (!footer) return; + + const updateFooterSpacing = () => { + if (isHidden) { + footer.style.marginBottom = ''; + return; + } + const playerHeight = playerRef.current?.offsetHeight || 140; + const gap = 24; // space between footer and player + footer.style.marginBottom = `${playerHeight + gap}px`; + }; + + updateFooterSpacing(); + + // Recalculate when player might resize + window.addEventListener('resize', updateFooterSpacing, { passive: true }); + document.addEventListener('astro:page-load', updateFooterSpacing); + + return () => { + footer.style.marginBottom = ''; + window.removeEventListener('resize', updateFooterSpacing); + document.removeEventListener('astro:page-load', updateFooterSpacing); + }; + }, [isHidden]); + + const stationColor = STATIONS[station]?.color || "#6366f1"; + const displayTrackTitle = trackTitle || (isVideoStation(station) ? "Videos" : "Loading..."); + const displayTrackArtist = trackArtist || ""; + + // Get current quality label + const currentQualityLabel = selectedQuality === -1 + ? "Auto" + : qualityLevels.find(l => l.index === selectedQuality)?.name || "Auto"; + + // Close dropdowns when clicking outside + useEffect(() => { + if (!stationDropdownOpen && !qualityDropdownOpen) return; + const handleClickOutside = (e: MouseEvent) => { + if (stationDropdownOpen && stationDropdownRef.current && !stationDropdownRef.current.contains(e.target as Node)) { + setStationDropdownOpen(false); + } + if (qualityDropdownOpen && qualityDropdownRef.current && !qualityDropdownRef.current.contains(e.target as Node)) { + setQualityDropdownOpen(false); + } + }; + document.addEventListener("mousedown", handleClickOutside); + return () => document.removeEventListener("mousedown", handleClickOutside); + }, [stationDropdownOpen, qualityDropdownOpen]); + + const selectStation = (key: string) => { + handleStationChange(key); + setStationDropdownOpen(false); + }; + + // Select quality from dropdown + const selectQuality = (levelIndex: number) => { + handleQualityChange(levelIndex); + setQualityDropdownOpen(false); + }; + + // Get color for quality level + const getQualityColor = (level: QualityLevel | null) => { + if (!level) return "#6b7280"; // gray for Auto + if (level.isLossless) return "#22c55e"; // green for lossless + if (level.bitrate >= 256000) return "#3b82f6"; // blue for high quality + if (level.bitrate >= 128000) return "#f59e0b"; // amber for medium + return "#ef4444"; // red for low + }; + + const currentQualityLevel = selectedQuality === -1 ? null : qualityLevels.find(l => l.index === selectedQuality) || null; + const currentQualityColor = getQualityColor(currentQualityLevel); + + const toggleHidden = () => { + setIsHidden((prev) => { + const next = !prev; + localStorage.setItem("miniPlayerHidden", next.toString()); + return next; + }); + }; + + // Hidden state - show small button to restore + if (isHidden) { + return ( +
+ +
+ ); + } + + return ( +
+
+
+ {/* Album art */} +
+ Album art { + (e.target as HTMLImageElement).src = "/images/radio_art_default.jpg"; + }} + /> + {isPlaying && ( + + )} +
+ + {/* Track info & controls */} +
+ {/* Top row: station badge + album */} +
+
+ + {stationDropdownOpen && ( +
+ {Object.entries(STATIONS).map(([key, { label, color }]) => ( + + ))} +
+ )} +
+ {trackAlbum && ( + + {trackAlbum} + + )} +
+ + {/* Song title */} +
+ {displayTrackTitle} +
+ + {/* Artist */} +
+ {displayTrackArtist} +
+ + {/* Progress bar */} + {trackDuration > 0 && ( +
+
+ {formatTime(elapsedTime)} + -{formatTime(trackDuration - elapsedTime)} +
+
+
= 90 + ? "bg-red-500 dark:bg-red-400" + : (elapsedTime / trackDuration) * 100 >= 75 || (trackDuration - elapsedTime) <= 20 + ? "bg-yellow-400 dark:bg-yellow-300" + : "bg-blue-500 dark:bg-blue-400" + }`} + style={{ width: `${trackDuration > 0 ? (elapsedTime / trackDuration) * 100 : 0}%` }} + /> +
+
+ )} + + {/* Bottom row: next track + quality */} +
+ {nextTrack ? ( +
+ Next: + +
+ ) : ( + + )} + {qualityLevels.length > 0 && ( +
+ + {qualityDropdownOpen && ( +
+ + {qualityLevels.map((level) => { + const color = getQualityColor(level); + return ( + + ); + })} +
+ )} +
+ )} +
+
+ + {/* Play button */} + + + {/* Full player link */} + + + + + {/* Hide button */} + +
+
+
+ ); +} \ No newline at end of file diff --git a/src/components/Radio.tsx b/src/components/Radio.tsx index 4fad9d3..2bb9d92 100644 --- a/src/components/Radio.tsx +++ b/src/components/Radio.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect, useRef, Suspense, lazy, useMemo, useCallback } from "react"; +import React, { useState, useEffect, useRef, Suspense, lazy, useCallback } from "react"; import type Hls from "hls.js"; import "@styles/player.css"; import "@/components/TRip/RequestManagement.css"; @@ -13,9 +13,31 @@ import { toast } from "react-toastify"; import { ENVIRONMENT } from "../config"; import { API_URL } from "@/config"; import { authFetch } from "@/utils/authFetch"; +import { SHARED_HLS_CONFIG } from "@/utils/hlsConfig"; import { requireAuthHook } from "@/hooks/requireAuthHook"; import { useHtmlThemeAttr } from "@/hooks/useHtmlThemeAttr"; import { useAutoCompleteScrollFix } from "@/hooks/useAutoCompleteScrollFix"; +import { installOneShotLiveCatchup, triggerOneShotLiveCatchup } from "@/utils/liveCatchup"; +import { + getGlobalRadioState, + getOrCreateAudio, + getOrCreateVideo, + updateGlobalStation, + updateGlobalQuality, + updateGlobalPlayingState, + requestGlobalPauseAll, + requestOwnerTabResume, + updateGlobalTrackInfo, + destroyGlobalHls, + setGlobalHls, + setGlobalQualityLevels, + onRadioEvent, + emitRadioEvent, + RADIO_EVENTS, + isVideoStation, + getStreamUrl, + type QualityLevel, +} from "@/utils/radioState"; interface LyricLine { timestamp: number; @@ -38,8 +60,12 @@ const STATIONS = { rap: { label: "Rap" }, electronic: { label: "Electronic" }, pop: { label: "Pop" }, + videos: { label: "Videos" }, }; +// LRC timing calibration: show lyrics slightly ahead (or behind) to better match perceived audio output. +const LRC_TIMING_OFFSET_SEC = -0.04; + // Marquee component for long text that scrolls on mobile function MarqueeText({ text, className = "" }: { text: string; className?: string }) { const containerRef = useRef(null); @@ -145,29 +171,57 @@ export default function Player({ user }: PlayerProps) { }; const theme = useHtmlThemeAttr(); - const [activeStation, setActiveStation] = useState("main"); - const [isPlaying, setIsPlaying] = useState(false); - const [trackTitle, setTrackTitle] = useState(""); - const [trackArtist, setTrackArtist] = useState(""); + // Initialize state from global radio state (to continue playback from mini player) + const [activeStation, setActiveStation] = useState(() => { + if (typeof window === "undefined") return "main"; + return getGlobalRadioState().station; + }); + const [isPlaying, setIsPlaying] = useState(() => { + if (typeof window === "undefined") return false; + return getGlobalRadioState().isPlaying; + }); + const [trackTitle, setTrackTitle] = useState(() => { + if (typeof window === "undefined") return ""; + return getGlobalRadioState().trackTitle || ""; + }); + const [trackArtist, setTrackArtist] = useState(() => { + if (typeof window === "undefined") return ""; + return getGlobalRadioState().trackArtist || ""; + }); const [trackGenre, setTrackGenre] = useState(""); - const [trackAlbum, setTrackAlbum] = useState(""); + const [trackAlbum, setTrackAlbum] = useState(() => { + if (typeof window === "undefined") return ""; + return getGlobalRadioState().trackAlbum || ""; + }); const [nextTrack, setNextTrack] = useState<{ artist: string; song: string } | null>(null); - const [coverArt, setCoverArt] = useState("/images/radio_art_default.jpg"); + const [coverArt, setCoverArt] = useState(() => { + if (typeof window === "undefined") return "/images/radio_art_default.jpg"; + return getGlobalRadioState().coverArt || "/images/radio_art_default.jpg"; + }); const [lyrics, setLyrics] = useState([]); - const [currentLyricIndex, setCurrentLyricIndex] = useState(0); + const [currentLyricIndex, setCurrentLyricIndex] = useState(-1); const [elapsedTime, setElapsedTime] = useState(0); // Update currentLyricIndex as song progresses useEffect(() => { if (!lyrics || lyrics.length === 0) return; - // Find the last lyric whose timestamp is <= elapsedTime - let idx = 0; - for (let i = 0; i < lyrics.length; i++) { - if (lyrics[i].timestamp <= elapsedTime) { - idx = i; + + const adjustedElapsed = Math.max(0, elapsedTime + LRC_TIMING_OFFSET_SEC); + + // Binary search for the last lyric whose timestamp is <= adjustedElapsed + let left = 0; + let right = lyrics.length - 1; + let idx = -1; + + while (left <= right) { + const mid = (left + right) >> 1; + if (lyrics[mid].timestamp <= adjustedElapsed) { + idx = mid; + left = mid + 1; } else { - break; + right = mid - 1; } } + setCurrentLyricIndex(idx); }, [elapsedTime, lyrics]); const [trackDuration, setTrackDuration] = useState(0); @@ -188,9 +242,13 @@ export default function Player({ user }: PlayerProps) { const [requestInputUuid, setRequestInputUuid] = useState(""); const { attachScrollFix: handleTypeaheadShow, cleanupScrollFix: handleTypeaheadHide } = useAutoCompleteScrollFix(); + // Use shared audio/video elements from global state - don't create our own const audioElement = useRef(null); + const videoElement = useRef(null); const hlsInstance = useRef(null); - const currentTrackUuid = useRef(null); + const currentTrackUuid = useRef( + typeof window === "undefined" ? null : getGlobalRadioState().trackUuid + ); const baseTrackElapsed = useRef(0); const lastUpdateTimestamp = useRef(Date.now()); const activeStationRef = useRef(activeStation); @@ -198,19 +256,29 @@ export default function Player({ user }: PlayerProps) { const wsReconnectTimer = useRef | null>(null); const wsConnectionCheckTimer = useRef | null>(null); const wsLastMessageTime = useRef(Date.now()); - const [isStreamReady, setIsStreamReady] = useState(false); + const [isStreamReady, setIsStreamReady] = useState(() => { + if (typeof window === "undefined") return false; + const state = getGlobalRadioState(); + // If we have an existing HLS instance and are playing, we're ready + return !!(state.hls && state.isPlaying); + }); - // Quality selection state - const [qualityLevels, setQualityLevels] = useState<{index: number, bitrate: number, name: string, isLossless?: boolean}[]>([]); - const [selectedQuality, setSelectedQuality] = useState(-1); // -1 = auto + // Flag to track if we took over from mini player + const tookOverFromMiniPlayer = useRef(false); + + // Quality selection state - initialize from global state + const [qualityLevels, setQualityLevels] = useState<{index: number, bitrate: number, name: string, isLossless?: boolean}[]>(() => { + if (typeof window === "undefined") return []; + return getGlobalRadioState().qualityLevels || []; + }); + const [selectedQuality, setSelectedQuality] = useState(() => { + if (typeof window === "undefined") return -1; + return getGlobalRadioState().qualityLevel; + }); const [currentBitrate, setCurrentBitrate] = useState(null); // Stream's declared bitrate const [liveDownloadRate, setLiveDownloadRate] = useState(null); // Actual download speed const [actualPlayingLevel, setActualPlayingLevel] = useState(-1); // Actual HLS.js level being played - useEffect(() => { - return () => {}; - }, []); - const formatTime = (seconds: number): string => { if (!seconds || isNaN(seconds) || seconds < 0) return "00:00"; const mins = String(Math.floor(seconds / 60)).padStart(2, "0"); @@ -221,10 +289,11 @@ export default function Player({ user }: PlayerProps) { // Change stream quality const handleQualityChange = (levelIndex: number) => { setSelectedQuality(levelIndex); - localStorage.setItem('radioQuality', levelIndex.toString()); + updateGlobalQuality(levelIndex); if (hlsInstance.current) { hlsInstance.current.currentLevel = levelIndex; // -1 = auto } + emitRadioEvent(RADIO_EVENTS.QUALITY_CHANGED, { quality: levelIndex }); }; // Format live bitrate for display (shows actual download rate) @@ -241,233 +310,331 @@ export default function Player({ user }: PlayerProps) { document.title = `${metaData.title} - Radio - ${artist} - ${song} [${activeStationRef.current}]`; }; - const isSafari = useMemo(() => { - if (typeof navigator === 'undefined') return false; - const ua = navigator.userAgent; - return /Safari/.test(ua) && !/Chrome|Chromium|CriOS|Edg/.test(ua); - }, []); - - // Initialize or switch HLS stream - const initializeStream = (station) => { - setIsStreamReady(false); + // Initialize or switch HLS stream - uses global audio singleton + const initializeStream = useCallback((station: string, forceNewStream = false) => { + const globalState = getGlobalRadioState(); + const isVideo = isVideoStation(station); + const globalMedia = isVideo ? globalState.video : globalState.audio; - const audio = audioElement.current; - if (!audio) { + // If we already have a stream playing for this station and not forcing new, reuse it + if (!forceNewStream && globalState.hls && globalState.station === station && globalMedia && !!globalMedia.src) { + // Take over existing stream - just update our refs + const hls = globalState.hls; + hlsInstance.current = hls; + const media = globalMedia; + if (isVideo) { + videoElement.current = globalState.video; + audioElement.current = null; + } else { + audioElement.current = globalState.audio; + videoElement.current = null; + } + if (media) { + media.playbackRate = 1; + } + setQualityLevels(globalState.qualityLevels); + setSelectedQuality(globalState.qualityLevel); + // Get actual playing level from HLS instance + if (hls.currentLevel >= 0) { + setActualPlayingLevel(hls.currentLevel); + // Also set current bitrate from the level + const level = hls.levels[hls.currentLevel]; + if (level?.bitrate) { + setCurrentBitrate(level.bitrate); + } + } + + // Re-attach event listeners for live bitrate tracking + // Import Hls to get event constants + import('hls.js').then(({ default: Hls }) => { + hls.on(Hls.Events.LEVEL_SWITCHED, (_event: any, data: any) => { + const level = hls.levels[data.level]; + if (level?.bitrate) { + setCurrentBitrate(level.bitrate); + } + setActualPlayingLevel(data.level); + }); + + hls.on(Hls.Events.FRAG_LOADED, (_event: any, data: any) => { + const stats = data.frag.stats; + if (stats && stats.total && stats.loading) { + const downloadTime = stats.loading.end - stats.loading.start; + if (downloadTime > 0) { + const liveBps = (stats.total * 8 * 1000) / downloadTime; + setLiveDownloadRate(liveBps); + } + } + }); + }); + + setIsStreamReady(true); + const actuallyPlaying = !media.paused && !media.ended; + setIsPlaying(actuallyPlaying || globalState.isPlaying); + if (actuallyPlaying) { + updateGlobalPlayingState(true); + } + tookOverFromMiniPlayer.current = true; return; } + + setIsStreamReady(false); + + // Get or create the shared media element + const media = isVideo ? getOrCreateVideo() : getOrCreateAudio(); + if (isVideo) { + videoElement.current = media as HTMLVideoElement; + audioElement.current = null; + } else { + audioElement.current = media as HTMLAudioElement; + videoElement.current = null; + } + media.playbackRate = 1; // Ensure CORS is set before HLS attaches (Chromium is stricter than Firefox) - audio.crossOrigin = 'anonymous'; + media.crossOrigin = 'anonymous'; - // Clean up previous HLS instance first (synchronous but quick) - if (hlsInstance.current) { - hlsInstance.current.destroy(); - hlsInstance.current = null; - } + // Clean up previous HLS instance via global state + destroyGlobalHls(); + hlsInstance.current = null; - // Pause and reset audio element - audio.pause(); - audio.removeAttribute("src"); + // Pause and reset media element + media.pause(); + media.removeAttribute("src"); - // Defer the blocking audio.load() call + // Defer the blocking load() call setTimeout(() => { - audio.load(); + media.load(); }, 0); - // Handle audio load errors - audio.onerror = () => { + // Handle media load errors + media.onerror = () => { setIsPlaying(false); + updateGlobalPlayingState(false); setIsStreamReady(false); }; // Chromium: ensure CORS + inline playback set before attaching - audio.crossOrigin = 'anonymous'; - audio.setAttribute('playsinline', 'true'); - audio.setAttribute('webkit-playsinline', 'true'); + media.crossOrigin = 'anonymous'; + media.setAttribute('playsinline', 'true'); + media.setAttribute('webkit-playsinline', 'true'); - const streamUrl = `https://stream.codey.horse/hls/${station}/${station}.m3u8`; + const streamUrl = getStreamUrl(station); - // Check for native HLS support (Safari only). Force HLS.js for Chromium so we always get quality levels. - if (isSafari && audio.canPlayType("application/vnd.apple.mpegurl")) { - // Use setTimeout to defer the blocking operations - setTimeout(() => { - audio.src = streamUrl; - audio.load(); - setIsStreamReady(true); - audio.play().then(() => { - setIsPlaying(true); - }).catch(() => { - setTrackTitle("Offline"); - setIsPlaying(false); - }); - }, 0); - return; - } - - // Dynamic import HLS.js for non-Safari browsers + // Dynamic import HLS.js and branch only on Hls.isSupported() import('hls.js').then(({ default: Hls }) => { - // Double-check audio element still exists after async import - if (!audioElement.current) return; + // Double-check media element still exists after async import + if (isVideo ? !videoElement.current : !audioElement.current) return; - if (!Hls.isSupported()) { + if (Hls.isSupported()) { + const hls = new Hls(SHARED_HLS_CONFIG); + + hlsInstance.current = hls; + setGlobalHls(hls); + installOneShotLiveCatchup(hls, media); + + hls.attachMedia(media); + hls.on(Hls.Events.MEDIA_ATTACHED, () => { + hls.loadSource(streamUrl); + }); + hls.on(Hls.Events.MANIFEST_PARSED, (_event, data) => { + const levelsRaw = data?.levels ?? []; + if (!levelsRaw.length) { + setQualityLevels([{ index: -1, bitrate: 0, name: 'Auto' }]); + setSelectedQuality(-1); + setIsStreamReady(true); + media.playbackRate = 1; + media.play().then(() => { + setIsPlaying(true); + updateGlobalPlayingState(true); + }).catch(() => { + setIsPlaying(false); + updateGlobalPlayingState(false); + }); + return; + } + + // For video stations, use resolution-based quality labels + if (isVideo) { + const levels = levelsRaw.map((level, index) => { + const height = level.height || 0; + const name = height ? `${height}p` : `Level ${index + 1}`; + return { index, bitrate: level.bitrate || 0, name, isLossless: false }; + }); + levels.sort((a, b) => b.bitrate - a.bitrate); + setQualityLevels(levels); + setGlobalQualityLevels(levels); + setSelectedQuality(-1); // Auto for video + } else { + const standards = [64, 96, 128, 160, 192, 256, 320]; + const levels = levelsRaw.map((level, index) => { + const kbps = (level.bitrate || 0) / 1000; + const codec = level.audioCodec || level.codecs || ''; + const isLossless = codec.toLowerCase().includes('flac') || codec.toLowerCase().includes('alac') || kbps > 500; + + let name: string; + if (isLossless) { + name = 'Lossless'; + } else if (level.bitrate) { + const friendly = standards.reduce((prev, curr) => Math.abs(curr - kbps) < Math.abs(prev - kbps) ? curr : prev); + name = `${friendly} kbps`; + } else { + name = `Level ${index + 1}`; + } + + return { index, bitrate: level.bitrate || 0, name, isLossless }; + }); + + levels.sort((a, b) => { + if (a.isLossless && !b.isLossless) return -1; + if (!a.isLossless && b.isLossless) return 1; + return b.bitrate - a.bitrate; + }); + + setQualityLevels(levels); + setGlobalQualityLevels(levels); + + // Use global quality setting + const savedQuality = globalState.qualityLevel; + if (savedQuality !== -1 && levels.some(l => l.index === savedQuality)) { + hls.currentLevel = savedQuality; + setSelectedQuality(savedQuality); + } else { + const connection = (navigator as any).connection; + const downlink = connection?.downlink; + const effectiveType = connection?.effectiveType; + const canSupportLossless = !connection || (downlink && downlink >= 2) || (!downlink && effectiveType === '4g'); + const losslessLevel = levels.find(l => l.isLossless); + if (losslessLevel && canSupportLossless) { + hls.currentLevel = losslessLevel.index; + setSelectedQuality(losslessLevel.index); + updateGlobalQuality(losslessLevel.index); + } else { + setSelectedQuality(-1); + } + } + } + + setIsStreamReady(true); + media.playbackRate = 1; + media.play().then(() => { + setIsPlaying(true); + updateGlobalPlayingState(true); + emitRadioEvent(RADIO_EVENTS.PLAYBACK_CHANGED, { isPlaying: true }); + }).catch(() => { + setIsPlaying(false); + updateGlobalPlayingState(false); + }); + }); + + // Track level switches - update current bitrate and actual playing level + hls.on(Hls.Events.LEVEL_SWITCHED, (_event, data) => { + const level = hls.levels[data.level]; + if (level?.bitrate) { + setCurrentBitrate(level.bitrate); + } + setActualPlayingLevel(data.level); + }); + + // Track actual live download bandwidth from fragment downloads + hls.on(Hls.Events.FRAG_LOADED, (_event, data) => { + const stats = data.frag.stats; + if (stats && stats.total && stats.loading) { + const downloadTime = stats.loading.end - stats.loading.start; + if (downloadTime > 0) { + const liveBps = (stats.total * 8 * 1000) / downloadTime; + setLiveDownloadRate(liveBps); + } + } + }); + + hls.on(Hls.Events.ERROR, (_event, data) => { + const details = data?.details || 'unknown'; + + if (!data.fatal) { + if (details === Hls.ErrorDetails.BUFFER_STALLED_ERROR) { + try { hls.startLoad(); } catch (_) { /* ignore */ } + } + return; + } + + switch (data.type) { + case Hls.ErrorTypes.NETWORK_ERROR: { + destroyGlobalHls(); + hlsInstance.current = null; + setIsPlaying(false); + updateGlobalPlayingState(false); + setIsStreamReady(false); + initializeStream(activeStationRef.current, true); + break; + } + case Hls.ErrorTypes.MEDIA_ERROR: { + try { + hls.recoverMediaError(); + } catch (err) { + destroyGlobalHls(); + hlsInstance.current = null; + initializeStream(activeStationRef.current, true); + } + break; + } + default: { + destroyGlobalHls(); + hlsInstance.current = null; + setIsPlaying(false); + updateGlobalPlayingState(false); + setIsStreamReady(false); + initializeStream(activeStationRef.current, true); + } + } + }); + return; } - const hls = new Hls({ - lowLatencyMode: false, - liveSyncDuration: 0.6, - liveMaxLatencyDuration: 3.0, - } as Partial); - - hlsInstance.current = hls; - hls.attachMedia(audio); - hls.on(Hls.Events.MEDIA_ATTACHED, () => { - hls.loadSource(streamUrl); - }); - hls.on(Hls.Events.MANIFEST_PARSED, (_event, data) => { - const levelsRaw = data?.levels ?? []; - if (!levelsRaw.length) { - // Show at least Auto so users know why the selector is missing - setQualityLevels([{ index: -1, bitrate: 0, name: 'Auto' }]); - setSelectedQuality(-1); - setIsStreamReady(true); - audio.play().then(() => setIsPlaying(true)).catch(() => setIsPlaying(false)); - return; - } - - // Capture available quality levels (use friendly bitrate names) - const standards = [64, 96, 128, 160, 192, 256, 320]; - const levels = levelsRaw.map((level, index) => { - const kbps = (level.bitrate || 0) / 1000; - const codec = level.audioCodec || level.codecs || ''; - const isLossless = codec.toLowerCase().includes('flac') || codec.toLowerCase().includes('alac') || kbps > 500; - - let name: string; - if (isLossless) { - name = 'Lossless'; - } else if (level.bitrate) { - const friendly = standards.reduce((prev, curr) => Math.abs(curr - kbps) < Math.abs(prev - kbps) ? curr : prev); - name = `${friendly} kbps`; - } else { - name = `Level ${index + 1}`; - } - - return { index, bitrate: level.bitrate || 0, name, isLossless }; - }); - - levels.sort((a, b) => { - if (a.isLossless && !b.isLossless) return -1; - if (!a.isLossless && b.isLossless) return 1; - return b.bitrate - a.bitrate; - }); - - setQualityLevels(levels); - - const savedQuality = localStorage.getItem('radioQuality'); - if (savedQuality !== null) { - const qualityIndex = parseInt(savedQuality, 10); - hls.currentLevel = qualityIndex; - setSelectedQuality(qualityIndex); - } else { - const connection = (navigator as any).connection; - const downlink = connection?.downlink; - const effectiveType = connection?.effectiveType; - const canSupportLossless = !connection || (downlink && downlink >= 2) || (!downlink && effectiveType === '4g'); - const losslessLevel = levels.find(l => l.isLossless); - if (losslessLevel && canSupportLossless) { - hls.currentLevel = losslessLevel.index; - setSelectedQuality(losslessLevel.index); - } else { - setSelectedQuality(-1); - } - } - + // Fallback for engines without MSE/HLS.js support (e.g., iOS WebKit) + if (media.canPlayType("application/vnd.apple.mpegurl")) { + media.src = streamUrl; + media.load(); + setQualityLevels([]); + setSelectedQuality(-1); + setCurrentBitrate(null); + setLiveDownloadRate(null); + setActualPlayingLevel(-1); setIsStreamReady(true); - audio.play().then(() => { + media.playbackRate = 1; + media.play().then(() => { setIsPlaying(true); + updateGlobalPlayingState(true); }).catch(() => { + setTrackTitle("Offline"); setIsPlaying(false); + updateGlobalPlayingState(false); }); - }); - - // Track level switches - update current bitrate and actual playing level - hls.on(Hls.Events.LEVEL_SWITCHED, (_event, data) => { - const level = hls.levels[data.level]; - if (level?.bitrate) { - setCurrentBitrate(level.bitrate); - } - setActualPlayingLevel(data.level); - }); - - // Track actual live download bandwidth from fragment downloads - hls.on(Hls.Events.FRAG_LOADED, (_event, data) => { - const stats = data.frag.stats; - if (stats && stats.total && stats.loading) { - const downloadTime = stats.loading.end - stats.loading.start; - if (downloadTime > 0) { - const liveBps = (stats.total * 8 * 1000) / downloadTime; - setLiveDownloadRate(liveBps); - } - } - }); - - let mediaRecoveryAttempts = 0; - let networkRecoveryAttempts = 0; - const MAX_RECOVERY_ATTEMPTS = 3; - let networkRecoveryTimeout: ReturnType | null = null; + return; + } - hls.on(Hls.Events.ERROR, (_event, data) => { - const details = data?.details || 'unknown'; - - if (!data.fatal) { - if (details === Hls.ErrorDetails.BUFFER_STALLED_ERROR) { - try { hls.startLoad(); } catch (_) { /* ignore */ } - } - return; - } - - switch (data.type) { - case Hls.ErrorTypes.NETWORK_ERROR: { - hls.destroy(); - hlsInstance.current = null; - setIsPlaying(false); - setIsStreamReady(false); - initializeStream(activeStationRef.current); - break; - } - case Hls.ErrorTypes.MEDIA_ERROR: { - try { - hls.recoverMediaError(); - } catch (err) { - hls.destroy(); - hlsInstance.current = null; - initializeStream(activeStationRef.current); - } - break; - } - default: { - hls.destroy(); - hlsInstance.current = null; - setIsPlaying(false); - setIsStreamReady(false); - initializeStream(activeStationRef.current); - } - } - }); + setTrackTitle("Offline"); + setIsPlaying(false); + updateGlobalPlayingState(false); + setIsStreamReady(false); }); - }; + }, []); - // Update elapsed time smoothly + // Update elapsed time smoothly (skip for video stations) + // Continue updating even when paused to reflect current stream position useEffect(() => { + // Skip elapsed time tracking for video stations + if (isVideoStation(activeStation)) return; + const intervalId = setInterval(() => { const now = Date.now(); const deltaSec = (now - lastUpdateTimestamp.current) / 1000; let liveElapsed = (typeof baseTrackElapsed.current === 'number' && !isNaN(baseTrackElapsed.current) ? baseTrackElapsed.current : 0) + deltaSec; if (trackDuration && liveElapsed > trackDuration) liveElapsed = trackDuration; setElapsedTime(liveElapsed); - }, 200); + }, 100); return () => clearInterval(intervalId); - }, [isPlaying, trackDuration]); + }, [trackDuration, activeStation]); @@ -526,16 +693,103 @@ export default function Player({ user }: PlayerProps) { // Handle station changes: reset and start new stream useEffect(() => { + const globalState = getGlobalRadioState(); + const isVideo = isVideoStation(activeStation); + const mediaForStation = isVideo ? globalState.video : globalState.audio; + + // Check if we're taking over an existing stream from mini player for the same station + const isTakeover = !!(globalState.hls && globalState.station === activeStation && mediaForStation && mediaForStation.src); + + if (isTakeover && !tookOverFromMiniPlayer.current) { + // Taking over from mini player - don't reset state, just sync + tookOverFromMiniPlayer.current = true; + const hls = globalState.hls; + hlsInstance.current = hls; + audioElement.current = globalState.audio; + videoElement.current = globalState.video; + const media = isVideo ? globalState.video : globalState.audio; + if (media) { + media.playbackRate = 1; + } + setQualityLevels(globalState.qualityLevels); + setSelectedQuality(globalState.qualityLevel); + // Get actual playing level from HLS instance + if (hls && hls.currentLevel >= 0) { + setActualPlayingLevel(hls.currentLevel); + // Also set current bitrate from the level + const level = hls.levels[hls.currentLevel]; + if (level?.bitrate) { + setCurrentBitrate(level.bitrate); + } + } + + // Re-attach event listeners for live bitrate tracking + if (hls) { + import('hls.js').then(({ default: Hls }) => { + hls.on(Hls.Events.LEVEL_SWITCHED, (_event: any, data: any) => { + const level = hls.levels[data.level]; + if (level?.bitrate) { + setCurrentBitrate(level.bitrate); + } + setActualPlayingLevel(data.level); + }); + + hls.on(Hls.Events.FRAG_LOADED, (_event: any, data: any) => { + const stats = data.frag.stats; + if (stats && stats.loading) { + const downloadTime = stats.loading.end - stats.loading.start; + if (downloadTime > 0) { + const liveBps = (stats.total * 8 * 1000) / downloadTime; + setLiveDownloadRate(liveBps); + } + } + }); + }); + } + + setIsStreamReady(true); + const actuallyPlaying = !!media && !media.paused && !media.ended; + setIsPlaying(actuallyPlaying || globalState.isPlaying); + if (actuallyPlaying) { + updateGlobalPlayingState(true); + } + // Keep existing track info if available (audio stations only) + if (!isVideo && globalState.trackTitle) { + setTrackTitle(globalState.trackTitle); + setTrackArtist(globalState.trackArtist); + setTrackAlbum(globalState.trackAlbum); + setCoverArt(globalState.coverArt); + currentTrackUuid.current = globalState.trackUuid; + } else if (isVideo) { + setTrackTitle(""); + setTrackArtist(""); + setTrackAlbum(""); + setCoverArt("/images/radio_art_default.jpg"); + } + // Update page title + document.title = `${metaData.title} - Radio [${activeStation}]`; + return; + } + + // Reset for fresh stream (station changed or no existing stream) + tookOverFromMiniPlayer.current = false; + // Batch all state resets to minimize re-renders - // Use startTransition to mark this as a non-urgent update const resetState = () => { - setTrackTitle(""); - setTrackArtist(""); + if (isVideo) { + setTrackTitle(""); + setTrackArtist(""); + setLyrics([]); + setCurrentLyricIndex(-1); + } else { + setTrackTitle(""); + setTrackArtist(""); + setLyrics([]); + setCurrentLyricIndex(-1); + } setTrackGenre(""); setTrackAlbum(""); setCoverArt("/images/radio_art_default.jpg"); - setLyrics([]); - setCurrentLyricIndex(0); setElapsedTime(0); setTrackDuration(0); }; @@ -545,34 +799,32 @@ export default function Player({ user }: PlayerProps) { baseTrackElapsed.current = 0; lastUpdateTimestamp.current = Date.now(); + // Update global station + updateGlobalStation(activeStation); + // Use requestAnimationFrame to defer state updates and stream init // This allows the UI to update the station button immediately requestAnimationFrame(() => { resetState(); // Defer stream initialization to next frame to keep UI responsive requestAnimationFrame(() => { - initializeStream(activeStation); + initializeStream(activeStation, true); // Force new stream for station change }); }); // Update page title to reflect the new station document.title = `${metaData.title} - Radio [${activeStation}]`; - // Cleanup on unmount or station change + // Emit station change event for mini player sync + emitRadioEvent(RADIO_EVENTS.STATION_CHANGED, { station: activeStation }); + + // Cleanup on unmount or station change - but DON'T destroy the global stream + // so mini player can continue playing return () => { - // Destroy HLS instance to stop network requests - if (hlsInstance.current) { - hlsInstance.current.destroy(); - hlsInstance.current = null; - } - // Stop and clear audio element - if (audioElement.current) { - audioElement.current.pause(); - audioElement.current.removeAttribute('src'); - audioElement.current.load(); - } + // We don't destroy HLS here anymore - let the global state manage it + // The mini player can continue using it after we unmount }; - }, [activeStation]); + }, [activeStation, initializeStream]); const parseLrcString = useCallback((lrcString: string | undefined | null): LyricLine[] => { if (!lrcString || typeof lrcString !== 'string') return []; @@ -581,7 +833,7 @@ export default function Player({ user }: PlayerProps) { const parsedLyrics: LyricLine[] = []; for (const line of lines) { - const match = line.match(/\[(\d{2}):(\d{2}\.\d{2})\]\s*(.+)/); + const match = line.match(/\[(\d{2}):(\d{2}(?:\.\d{1,3})?)\]\s*(.+)/); if (match) { const [, mins, secs, text] = match; const timestamp = Number(mins) * 60 + parseFloat(secs); @@ -619,9 +871,14 @@ export default function Player({ user }: PlayerProps) { setTrackMetadata(trackData, requestStation); + // Apply subtle one-shot catch-up on each track transition (audio stations only). + if (!isVideoStation(requestStation)) { + triggerOneShotLiveCatchup(hlsInstance.current, getOrCreateAudio()); + } + // Clear lyrics immediately when new track is detected setLyrics([]); - setCurrentLyricIndex(0); + setCurrentLyricIndex(-1); // Refresh queue and next track when track changes fetchQueue(); @@ -701,13 +958,13 @@ export default function Player({ user }: PlayerProps) { if (data.type === 'track_change') { // Handle track change setLyrics([]); - setCurrentLyricIndex(0); + setCurrentLyricIndex(-1); handleTrackData(data.data); } else if (data.type === 'lrc') { // Handle LRC data const parsedLyrics = parseLrcString(data.data); setLyrics(parsedLyrics); - setCurrentLyricIndex(0); + setCurrentLyricIndex(-1); } else if (data.type === 'now_playing' || data.type === 'initial') { // Explicit now playing message handleTrackData(data.data || data); @@ -770,11 +1027,16 @@ export default function Player({ user }: PlayerProps) { }, [handleTrackData, parseLrcString]); const setTrackMetadata = useCallback((trackData, requestStation) => { - setTrackTitle(trackData.song || 'Unknown Title'); - setTrackArtist(trackData.artist || 'Unknown Artist'); + const title = trackData.song || 'Unknown Title'; + const artist = trackData.artist || 'Unknown Artist'; + const album = trackData.album || 'Unknown Album'; + const newCoverArt = `${API_URL}/radio/album_art?station=${requestStation}&_=${Date.now()}`; + + setTrackTitle(title); + setTrackArtist(artist); setTrackGenre(trackData.genre || ''); - setTrackAlbum(trackData.album || 'Unknown Album'); - setCoverArt(`${API_URL}/radio/album_art?station=${requestStation}&_=${Date.now()}`); + setTrackAlbum(album); + setCoverArt(newCoverArt); const elapsed = typeof trackData.elapsed === 'number' && !isNaN(trackData.elapsed) ? trackData.elapsed : 0; baseTrackElapsed.current = elapsed; @@ -782,7 +1044,27 @@ export default function Player({ user }: PlayerProps) { setElapsedTime(elapsed); setTrackDuration(trackData.duration || 0); - setPageTitle(trackData.artist, trackData.song); + setPageTitle(artist, title); + + // Update global state for mini player sync + updateGlobalTrackInfo({ + title, + artist, + album, + coverArt: newCoverArt, + uuid: trackData.uuid, + }); + + // Emit track change event for mini player + emitRadioEvent(RADIO_EVENTS.TRACK_CHANGED, { + title, + artist, + album, + coverArt: newCoverArt, + uuid: trackData.uuid, + elapsed: typeof trackData.elapsed === 'number' && !isNaN(trackData.elapsed) ? trackData.elapsed : 0, + duration: trackData.duration || 0, + }); }, []); useEffect(() => { @@ -812,9 +1094,55 @@ export default function Player({ user }: PlayerProps) { }; }, [activeStation, initializeWebSocket]); + // Keep button state in sync with shared playback events (same-tab + cross-tab). + useEffect(() => { + const unsubPlayback = onRadioEvent(RADIO_EVENTS.PLAYBACK_CHANGED, (data) => { + setIsPlaying(!!data?.isPlaying); + }); + + return () => { + unsubPlayback(); + }; + }, []); + + // React to station changes coming from other tabs/components (e.g., mini player). + useEffect(() => { + const unsubStation = onRadioEvent(RADIO_EVENTS.STATION_CHANGED, (data) => { + const nextStation = data?.station; + if (typeof nextStation !== "string") return; + setActiveStation((prev) => (prev === nextStation ? prev : nextStation)); + }); + + return () => { + unsubStation(); + }; + }, []); + // Cleanup WebSocket on component unmount useEffect(() => { return () => { + // Preserve active video playback across navigation by keeping the global video + // element mounted in document.body when leaving the full radio page. + try { + const state = getGlobalRadioState(); + if (isVideoStation(state.station) && state.video && state.isPlaying) { + const video = state.video; + video.style.position = "fixed"; + video.style.left = "-9999px"; + video.style.top = "-9999px"; + video.style.width = "1px"; + video.style.height = "1px"; + video.style.opacity = "0"; + video.style.pointerEvents = "none"; + video.setAttribute("aria-hidden", "true"); + if (video.parentNode !== document.body) { + document.body.appendChild(video); + } + } + } catch { + // no-op + } + if (wsReconnectTimer.current) { clearTimeout(wsReconnectTimer.current); wsReconnectTimer.current = null; @@ -834,6 +1162,8 @@ export default function Player({ user }: PlayerProps) { const progress = trackDuration > 0 ? (elapsedTime / trackDuration) * 100 : 0; const remaining = trackDuration - elapsedTime; + const displayTrackTitle = trackTitle || (isVideoStation(activeStation) ? "Videos" : "Loading..."); + const displayTrackArtist = trackArtist || ""; const progressColorClass = progress >= 90 @@ -1223,26 +1553,34 @@ export default function Player({ user }: PlayerProps) { {/* Header - always first */}

serious.FM

- {/* Album cover section */} + {/* Album cover section - hidden for video stations */} + {!isVideoStation(activeStation) && (
{trackAlbum}
Cover Art
+ )} {/* Track info section */} -
- - +
+ + - {/* Next track preview */} - {nextTrack && ( + {/* Next track preview - hidden for video stations */} + {!isVideoStation(activeStation) && nextTrack && (
Up next: {nextTrack.artist} - {nextTrack.song}
)} + {/* Time and progress - hidden for video stations */} + {!isVideoStation(activeStation) && ( + <>

{formatTime(elapsedTime)}

- {formatTime(trackDuration - elapsedTime)}

@@ -1254,6 +1592,8 @@ export default function Player({ user }: PlayerProps) { style={{ width: `${progress}%` }} >
+ + )} {/* Quality selector */} {qualityLevels.length > 0 && ( @@ -1280,6 +1620,107 @@ export default function Player({ user }: PlayerProps) {
)} + {/* Video player - only shown for video stations */} + {isVideoStation(activeStation) && ( +
{ + const target = e.target as HTMLElement; + // Don't trigger play when clicking overlay controls + if (target.closest('button')) return; + + const globalVideo = getOrCreateVideo(); + // Single click toggles playback + if (globalVideo.paused || globalVideo.ended) { + if (!isStreamReady || !globalVideo.src || globalVideo.error) { + initializeStream(activeStation, true); + return; + } + + globalVideo.playbackRate = 1; + globalVideo.play().then(() => { + setIsPlaying(true); + updateGlobalPlayingState(true); + emitRadioEvent(RADIO_EVENTS.PLAYBACK_CHANGED, { isPlaying: true }); + }).catch(() => { + initializeStream(activeStation, true); + }); + } else { + globalVideo.pause(); + setIsPlaying(false); + updateGlobalPlayingState(false); + emitRadioEvent(RADIO_EVENTS.PLAYBACK_CHANGED, { isPlaying: false }); + } + }} + onDoubleClick={() => { + const globalVideo = getOrCreateVideo(); + if (globalVideo) { + if (document.fullscreenElement) { + document.exitFullscreen(); + } else { + globalVideo.requestFullscreen().catch(() => {}); + } + } + }} + > +
{ + if (containerEl) { + const globalVideo = getOrCreateVideo(); + // Apply styles to the global video element + globalVideo.className = "w-full h-full object-contain"; + globalVideo.playsInline = true; + globalVideo.controls = false; + (globalVideo as any).disablePictureInPicture = false; + // Clear offscreen parking styles (used by mini player during navigation) + globalVideo.style.position = ""; + globalVideo.style.left = ""; + globalVideo.style.top = ""; + globalVideo.style.width = ""; + globalVideo.style.height = ""; + globalVideo.style.opacity = ""; + globalVideo.style.pointerEvents = ""; + globalVideo.removeAttribute("aria-hidden"); + // Move into container if not already there + if (globalVideo.parentNode !== containerEl) { + containerEl.innerHTML = ''; + containerEl.appendChild(globalVideo); + } + videoElement.current = globalVideo; + } + }} + className="w-full h-full" + /> + {/* Video controls overlay */} +
+ +
+
+ )} + + {/* LRC/Lyrics container - only shown for audio stations */} + {!isVideoStation(activeStation) && (
))}
+ )} + {/* DJ controls */} {isDJ && (
@@ -1375,24 +1818,43 @@ export default function Player({ user }: PlayerProps) {
{ - const audio = audioElement.current; - if (!audio) return; + const isVideo = isVideoStation(activeStation); + const media = isVideo ? getOrCreateVideo() : getOrCreateAudio(); + const globalState = getGlobalRadioState(); + const actuallyPlaying = !media.paused && !media.ended; + + // Passive tab case: another tab owns playback; pause globally. + if (!actuallyPlaying && globalState.isPlaying) { + requestGlobalPauseAll(); + setIsPlaying(false); + return; + } + + if (!actuallyPlaying && !globalState.isPlaying && requestOwnerTabResume()) { + setIsPlaying(true); + return; + } if (isPlaying) { - audio.pause(); + media.pause(); setIsPlaying(false); + updateGlobalPlayingState(false); + emitRadioEvent(RADIO_EVENTS.PLAYBACK_CHANGED, { isPlaying: false }); } else { // If stream is not ready, reinitialize it - if (!isStreamReady || !audio.src || audio.error) { - initializeStream(activeStation); + if (!isStreamReady || !media.src || media.error) { + initializeStream(activeStation, true); return; } try { - await audio.play(); + media.playbackRate = 1; + await media.play(); setIsPlaying(true); + updateGlobalPlayingState(true); + emitRadioEvent(RADIO_EVENTS.PLAYBACK_CHANGED, { isPlaying: true }); } catch (err) { // Reinitialize stream on playback failure - initializeStream(activeStation); + initializeStream(activeStation, true); } } }} @@ -1406,7 +1868,7 @@ export default function Player({ user }: PlayerProps) {
-
+ + {/* Mobile layout */} +
+
+
+ {isDJ && ( + + + + + + )} + + #{item.pos + 1} + +
+ + {isDJ && ( +
+ {item.pos > 0 && ( + + )} + +
+ )} +
+ +
+
+ {item.artist} - {item.song} +
+
+ {item.trackNo && #{item.trackNo}} + {item.album} + {item.genre && item.genre !== 'N/A' && ( + + {item.genre} + + )} +
+
+ + {isDJ && ( +
+ {item.pos > 0 && ( + + )} + + + +
+ )} +
))}
diff --git a/src/components/RandomMsg.tsx b/src/components/RandomMsg.tsx index 13bb8d2..6312f7e 100644 --- a/src/components/RandomMsg.tsx +++ b/src/components/RandomMsg.tsx @@ -22,9 +22,15 @@ export default function RandomMsg(): React.ReactElement { if (!response.ok) throw new Error(`HTTP ${response.status}`); const data = await response.json(); if (data?.msg) setRandomMsg(data.msg.replace(//gi, "\n")); + if (typeof window !== "undefined") { + window.dispatchEvent(new CustomEvent("api:status", { detail: { reachable: true } })); + } } catch (err) { console.error("Failed to fetch random message:", err); setResponseTime(null); + if (typeof window !== "undefined") { + window.dispatchEvent(new CustomEvent("api:status", { detail: { reachable: false } })); + } } }; diff --git a/src/components/TRip/BreadcrumbNav.tsx b/src/components/TRip/BreadcrumbNav.tsx index 4731b3e..e2778ea 100644 --- a/src/components/TRip/BreadcrumbNav.tsx +++ b/src/components/TRip/BreadcrumbNav.tsx @@ -24,7 +24,6 @@ export default function BreadcrumbNav({ currentPage }: BreadcrumbNavProps): Reac ("music"); const [selectedArtist, setSelectedArtist] = useState(null); const [artistInput, setArtistInput] = useState(""); const [albumInput, setAlbumInput] = useState(""); @@ -118,6 +134,17 @@ export default function MediaRequestForm() { const [showVideoSection, setShowVideoSection] = useState(false); const videoAbortRef = useRef(null); + // Playlist search state + const [playlistSearchQuery, setPlaylistSearchQuery] = useState(""); + const [playlistResults, setPlaylistResults] = useState([]); + const [isPlaylistSearching, setIsPlaylistSearching] = useState(false); + const [selectedPlaylists, setSelectedPlaylists] = useState>(new Set()); + const [showPlaylistSection, setShowPlaylistSection] = useState(false); + const [playlistTracksById, setPlaylistTracksById] = useState>({}); + const [selectedPlaylistTrackIds, setSelectedPlaylistTrackIds] = useState>({}); + const [expandedPlaylists, setExpandedPlaylists] = useState>(new Set()); + const [loadingPlaylistId, setLoadingPlaylistId] = useState(null); + const resetVideoState = () => { videoAbortRef.current?.abort(); if (videoStreamUrl?.startsWith("blob:")) URL.revokeObjectURL(videoStreamUrl); @@ -130,6 +157,17 @@ export default function MediaRequestForm() { setIsVideoLoading(false); }; + const resetPlaylistState = () => { + setPlaylistResults([]); + setSelectedPlaylists(new Set()); + setPlaylistSearchQuery(""); + setIsPlaylistSearching(false); + setPlaylistTracksById({}); + setSelectedPlaylistTrackIds({}); + setExpandedPlaylists(new Set()); + setLoadingPlaylistId(null); + }; + const { attachScrollFix, cleanupScrollFix } = useAutoCompleteScrollFix(); const debounceTimeout = useRef | null>(null); @@ -520,8 +558,12 @@ export default function MediaRequestForm() { toast.dismiss(); setIsSearching(true); resetQueueState(); - const videosWereOpen = showVideoSection; - resetVideoState(); + + if (requestTab === "music") { + resetVideoState(); + resetPlaylistState(); + } + setShuffleAlbums({}); if (audioRef.current) { audioRef.current.pause(); @@ -531,59 +573,80 @@ export default function MediaRequestForm() { setIsAudioPlaying(false); setAudioLoadingTrackId(null); setCurrentTrackId(null); - try { - if (metadataFetchToastId.current) toast.dismiss(metadataFetchToastId.current); - } catch (err) { - } - metadataFetchToastId.current = toast.info("Retrieving metadata...", - { - autoClose: false, - progress: 0, - closeOnClick: false, - } - ); - if (type === "artist") { - if (!selectedArtist) { - toast.error("Please select a valid artist from suggestions."); - setIsSearching(false); - return; - } - - setSelectedItem({ ...selectedArtist, name: selectedArtist.artist || selectedArtist.name || "" }); - + if (requestTab === "music") { try { - const res = await authFetch( - `${API_URL}/trip/get_albums_by_artist_id/${selectedArtist.id}?quality=${quality}` - ); - if (!res.ok) throw new Error("API error"); - const data = await res.json(); - - data.sort((a, b) => - (b.release_date || "").localeCompare(a.release_date || "") - ); - - setAlbums(data); - setShuffleAlbums({}); - setTracksByAlbum({}); - setExpandedAlbums([]); - - // Set selectedTracks for all albums as null (means tracks loading/not loaded) - setSelectedTracks( - data.reduce((acc, album) => { - acc[album.id] = null; - return acc; - }, {}) - ); - - // If the video section was open before, auto-fetch videos for the new artist - if (videosWereOpen && selectedArtist) { - fetchArtistVideos(selectedArtist.id); - } + if (metadataFetchToastId.current) toast.dismiss(metadataFetchToastId.current); } catch (err) { - toast.error("Failed to fetch albums for artist."); - setAlbums([]); - setTracksByAlbum({}); - setSelectedTracks({}); + } + metadataFetchToastId.current = toast.info("Retrieving metadata...", + { + autoClose: false, + progress: 0, + closeOnClick: false, + } + ); + } + if (type === "artist") { + if (requestTab === "music") { + if (!selectedArtist) { + toast.error("Please select a valid artist from suggestions."); + setIsSearching(false); + return; + } + + setSelectedItem({ ...selectedArtist, name: selectedArtist.artist || selectedArtist.name || "" }); + + try { + const res = await authFetch( + `${API_URL}/trip/get_albums_by_artist_id/${selectedArtist.id}?quality=${quality}` + ); + if (!res.ok) throw new Error("API error"); + const data = await res.json(); + + data.sort((a, b) => + (b.release_date || "").localeCompare(a.release_date || "") + ); + + setAlbums(data); + setShuffleAlbums({}); + setTracksByAlbum({}); + setExpandedAlbums([]); + + setSelectedTracks( + data.reduce((acc, album) => { + acc[album.id] = null; + return acc; + }, {}) + ); + } catch (err) { + toast.error("Failed to fetch albums for artist."); + setAlbums([]); + setTracksByAlbum({}); + setSelectedTracks({}); + } + } else if (requestTab === "videos") { + setShowVideoSection(true); + if (selectedArtist) { + await fetchArtistVideos(selectedArtist.id); + } else { + const fallbackQuery = videoSearchQuery.trim() || artistInput.trim(); + if (!fallbackQuery) { + toast.error("Select an artist or enter a video search query."); + setIsSearching(false); + return; + } + await searchVideos(fallbackQuery); + } + } else if (requestTab === "playlists") { + setShowPlaylistSection(true); + const fallbackQuery = playlistSearchQuery.trim() || selectedArtist?.artist || selectedArtist?.name || artistInput.trim(); + if (!fallbackQuery) { + toast.error("Select an artist or enter a playlist search query."); + setIsSearching(false); + return; + } + if (!playlistSearchQuery.trim()) setPlaylistSearchQuery(fallbackQuery); + await searchPlaylists(fallbackQuery); } } else if (type === "album") { if (!artistInput.trim() || !albumInput.trim()) { @@ -750,8 +813,8 @@ export default function MediaRequestForm() { // VIDEO SEARCH & PLAYBACK // ======================== - const searchVideos = async () => { - const query = videoSearchQuery.trim(); + const searchVideos = async (overrideQuery?: string) => { + const query = (overrideQuery ?? videoSearchQuery).trim(); if (!query) { toast.error("Please enter a search query."); return; @@ -801,6 +864,233 @@ export default function MediaRequestForm() { } }; + const normalizePlaylistsResponse = (data: any): Playlist[] => { + if (Array.isArray(data)) return data; + if (Array.isArray(data?.playlists)) return data.playlists; + if (Array.isArray(data?.items)) return data.items; + if (Array.isArray(data?.results)) return data.results; + return []; + }; + + const getPlaylistTitle = (playlist: Playlist) => { + return playlist.title || playlist.name || `Playlist ${playlist.id}`; + }; + + const getPlaylistTrackCount = (playlist: Playlist) => { + return playlist.number_of_tracks ?? playlist.tracks_count ?? playlist.total_tracks ?? null; + }; + + const normalizeApiId = (value: string | number) => { + if (typeof value === "number") return value; + const trimmed = String(value).trim(); + return /^\d+$/.test(trimmed) ? Number(trimmed) : trimmed; + }; + + const searchPlaylists = async (overrideQuery?: string) => { + const query = (overrideQuery ?? playlistSearchQuery).trim(); + if (!query) { + toast.error("Please enter a playlist search query."); + return; + } + + setIsPlaylistSearching(true); + setPlaylistResults([]); + setSelectedPlaylists(new Set()); + + try { + const res = await authFetch(`${API_URL}/trip/playlists/search?q=${encodeURIComponent(query)}&limit=50`); + if (!res.ok) throw new Error("API error"); + const data = await res.json(); + const playlists = normalizePlaylistsResponse(data); + setPlaylistResults(playlists); + setPlaylistTracksById({}); + setExpandedPlaylists(new Set()); + setSelectedPlaylistTrackIds( + playlists.reduce((acc: Record, p: Playlist) => { + acc[p.id] = null; + return acc; + }, {}) + ); + } catch (err) { + toast.error("Failed to search playlists."); + setPlaylistResults([]); + } finally { + setIsPlaylistSearching(false); + } + }; + + const fetchPlaylistTracks = async (playlistId: string | number) => { + if (playlistTracksById[playlistId]) return; + + setLoadingPlaylistId(playlistId); + try { + const res = await authFetch(`${API_URL}/trip/get_tracks_by_playlist_id/${playlistId}`); + if (!res.ok) throw new Error("API error"); + const data = await res.json(); + const tracks: Track[] = Array.isArray(data) ? data : data?.tracks || data?.items || []; + setPlaylistTracksById((prev) => ({ ...prev, [playlistId]: tracks })); + setSelectedPlaylistTrackIds((prev) => ({ + ...prev, + [playlistId]: tracks.map((t) => String(t.id)), + })); + } catch (err) { + toast.error("Failed to fetch playlist tracks."); + setPlaylistTracksById((prev) => ({ ...prev, [playlistId]: [] })); + setSelectedPlaylistTrackIds((prev) => ({ ...prev, [playlistId]: [] })); + } finally { + setLoadingPlaylistId(null); + } + }; + + const togglePlaylistExpanded = async (playlistId: string | number) => { + const isExpanded = expandedPlaylists.has(playlistId); + const next = new Set(expandedPlaylists); + if (isExpanded) { + next.delete(playlistId); + } else { + next.add(playlistId); + } + setExpandedPlaylists(next); + if (!isExpanded) { + await fetchPlaylistTracks(playlistId); + } + }; + + const togglePlaylistTrack = (playlistId: string | number, trackId: string | number) => { + setSelectedPlaylistTrackIds((prev) => { + const current = new Set(prev[playlistId] || []); + const key = String(trackId); + if (current.has(key)) current.delete(key); + else current.add(key); + return { ...prev, [playlistId]: Array.from(current) }; + }); + }; + + const togglePlaylistAllTracks = (playlistId: string | number) => { + const tracks = playlistTracksById[playlistId] || []; + const allIds = tracks.map((t) => String(t.id)); + setSelectedPlaylistTrackIds((prev) => { + const current = prev[playlistId] || []; + const allSelected = current.length === allIds.length && allIds.length > 0; + return { ...prev, [playlistId]: allSelected ? [] : allIds }; + }); + }; + + const toggleAllPlaylistTracks = () => { + const loadedPlaylistIds = playlistResults + .map((p) => p.id) + .filter((id) => Array.isArray(playlistTracksById[id])); + + if (loadedPlaylistIds.length === 0) { + toast.info("Expand a playlist first to load tracks."); + return; + } + + const allSelected = loadedPlaylistIds.every((id) => { + const tracks = playlistTracksById[id] || []; + const selected = selectedPlaylistTrackIds[id] || []; + return tracks.length > 0 && selected.length === tracks.length; + }); + + setSelectedPlaylistTrackIds((prev) => { + const next = { ...prev }; + loadedPlaylistIds.forEach((id) => { + const tracks = playlistTracksById[id] || []; + next[id] = allSelected ? [] : tracks.map((t) => String(t.id)); + }); + return next; + }); + }; + + const togglePlaylistSelection = (playlistId: string | number) => { + setSelectedPlaylists((prev) => { + const next = new Set(prev); + if (next.has(playlistId)) next.delete(playlistId); + else next.add(playlistId); + return next; + }); + }; + + const handleSubmitPlaylistRequest = async () => { + const selectedTrackIds = Object.values(selectedPlaylistTrackIds) + .filter((arr): arr is string[] => Array.isArray(arr)) + .flat(); + + setIsSubmitting(true); + try { + if (selectedTrackIds.length > 0) { + const target = selectedArtist?.artist || selectedArtist?.name || playlistSearchQuery || "Playlists"; + const response = await authFetch(`${API_URL}/trip/bulk_fetch`, { + method: "POST", + headers: { "Content-Type": "application/json; charset=utf-8" }, + body: JSON.stringify({ + track_ids: selectedTrackIds.map((id) => normalizeApiId(id)), + target, + quality, + }), + }); + + if (!response.ok) throw new Error("API error"); + + toast.success(`Playlist request submitted! (${selectedTrackIds.length} tracks)`, { + autoClose: 3000, + onClose: () => { + if (typeof window !== 'undefined') window.location.href = '/TRip/requests'; + } + }); + setSelectedPlaylistTrackIds((prev) => { + const next = { ...prev }; + Object.keys(next).forEach((k) => { + next[k] = []; + }); + return next; + }); + return; + } + + if (selectedPlaylists.size === 0) { + toast.error("Please select at least one playlist."); + return; + } + + const selected = playlistResults.filter((p) => selectedPlaylists.has(p.id)); + if (selected.length === 0) { + toast.error("Selected playlists are no longer available."); + return; + } + + let successCount = 0; + for (const playlist of selected) { + const response = await authFetch(`${API_URL}/trip/playlists/bulk_fetch`, { + method: "POST", + headers: { "Content-Type": "application/json; charset=utf-8" }, + body: JSON.stringify({ + playlist_id: normalizeApiId(playlist.id), + target: getPlaylistTitle(playlist), + quality, + }), + }); + if (response.ok) successCount += 1; + } + + if (successCount > 0) { + toast.success(`Playlist request submitted! (${successCount}/${selected.length})`, { + autoClose: 3000, + onClose: () => { + if (typeof window !== 'undefined') window.location.href = '/TRip/requests'; + } + }); + setSelectedPlaylists(new Set()); + } else { + toast.error("Failed to submit playlist request."); + } + } catch (err) { + toast.error("Failed to submit playlist request."); + } finally { + setIsSubmitting(false); + } + }; + const toggleVideoSelection = (videoId: string | number) => { setSelectedVideos((prev) => { const next = new Set(prev); @@ -1396,10 +1686,38 @@ export default function MediaRequestForm() { )}

New Request

-

Search for an artist to browse and select tracks for download.

+

+ {requestTab === "music" && "Search for an artist to browse and select tracks for download."} + {requestTab === "videos" && "Search and submit music video bulk-fetch requests."} + {requestTab === "playlists" && "Search and submit playlist bulk-fetch requests."} +

+ +
+ + + +
- + - {(type === "album" || type === "track") && ( - - type === "album" ? setAlbumInput(e.target.value) : setTrackInput(e.target.value) - } - placeholder={type === "album" ? "Album" : "Track"} - /> - )}
- {type === "artist" && albums.length > 0 && ( + {type === "artist" && ( <> + {requestTab === "music" && albums.length > 0 && ( + <>
Albums: {totalAlbums} @@ -1682,9 +1991,11 @@ export default function MediaRequestForm() { )}
+ + )} {/* Videos Section - Show when artist is selected or videos are loaded */} - {(selectedArtist || (showVideoSection && videoResults.length > 0)) && ( + {requestTab === "videos" && (

@@ -1737,7 +2048,7 @@ export default function MediaRequestForm() { />

)} + + {/* Playlists Section */} + {requestTab === "playlists" && ( +
+
+

+ + + + Playlists +

+ +
+ + {showPlaylistSection && ( +
+
+ setPlaylistSearchQuery(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && searchPlaylists()} + placeholder="Search playlists..." + className="flex-1 px-3 py-2 rounded border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 text-black dark:text-white" + /> + +
+ + {playlistResults.length > 0 && ( +
+ )} + + {playlistResults.length > 0 ? ( +
+ {playlistResults.map((playlist) => { + const title = getPlaylistTitle(playlist); + const trackCount = getPlaylistTrackCount(playlist); + const coverUrl = playlist.square_image_url || playlist.image || playlist.cover; + return ( +
+
+ togglePlaylistSelection(playlist.id)} + className="mt-1 cursor-pointer" + /> + {coverUrl ? ( + {title} + ) : ( +
+ + + +
+ )} +
+ + {playlist.creator || playlist.owner ? ( +

by {playlist.creator || playlist.owner}

+ ) : null} + {playlist.description ? ( +

{playlist.description}

+ ) : null} +
+ {trackCount !== null ? `${trackCount} tracks` : `ID: ${playlist.id}`} +
+
+
+ + {expandedPlaylists.has(playlist.id) && ( +
+ {loadingPlaylistId === playlist.id && ( +
Loading tracks...
+ )} + + {loadingPlaylistId !== playlist.id && ( + <> +
+ + {(playlistTracksById[playlist.id] || []).length} track{(playlistTracksById[playlist.id] || []).length !== 1 ? 's' : ''} + + { + e.preventDefault(); + togglePlaylistAllTracks(playlist.id); + }} + className="text-xs text-blue-600 hover:underline" + > + Check / Uncheck All + +
+ +
    + {(playlistTracksById[playlist.id] || []).map((track) => ( +
  • + togglePlaylistTrack(playlist.id, track.id)} + className="mt-0.5 cursor-pointer" + /> +
    +
    + {track.artist ? `${track.artist} - ` : ''}{track.title} +
    +
    + ID: {track.id} + {track.duration ? {track.duration} : null} + {track.track_number ? # {track.track_number} : null} +
    +
    +
  • + ))} +
+ + )} +
+ )} +
+ ); + })} +
+ ) : !isPlaylistSearching ? ( +
+

No playlists found. Try a different query.

+
+ ) : null} + + {selectedPlaylists.size > 0 && ( +
+ + {selectedPlaylists.size} playlist{selectedPlaylists.size !== 1 ? "s" : ""} selected + + +
+ )} +
+ )} +
+ )} ) } diff --git a/src/components/TRip/RequestManagement.tsx b/src/components/TRip/RequestManagement.tsx index d6a93a4..391429e 100644 --- a/src/components/TRip/RequestManagement.tsx +++ b/src/components/TRip/RequestManagement.tsx @@ -65,8 +65,8 @@ export default function RequestManagement() { // Check if path is /storage/music/TRIP if (absPath.includes("/storage/music/TRIP/")) { return isVideo - ? `https://_music.codey.horse/TRIP/videos/${filename}` - : `https://_music.codey.horse/TRIP/${filename}`; + ? `https://trip.codey.horse/videos/${filename}` + : `https://trip.codey.horse/${filename}`; } // Otherwise, assume /storage/music2/completed/{quality} format diff --git a/src/config.ts b/src/config.ts index 7ee7a16..2eae7bd 100644 --- a/src/config.ts +++ b/src/config.ts @@ -63,7 +63,7 @@ export const RADIO_API_URL: string = "https://radio-api.codey.horse"; export const socialLinks: Record = { }; -export const MAJOR_VERSION: string = "0.8" +export const MAJOR_VERSION: string = "1.1" export const RELEASE_FLAG: string | null = null; export const ENVIRONMENT: "Dev" | "Prod" = import.meta.env.DEV ? "Dev" : "Prod"; diff --git a/src/layouts/Base.astro b/src/layouts/Base.astro index be981b0..cf91014 100644 --- a/src/layouts/Base.astro +++ b/src/layouts/Base.astro @@ -13,6 +13,7 @@ import { metaData } from "../config"; import Nav from "./Nav.astro"; import SubNav from "./SubNav.astro"; import Footer from "../components/Footer.astro"; +import MiniRadioPlayer from "../components/MiniRadioPlayer.tsx"; import "@fontsource/ibm-plex-sans/500.css"; import "@fontsource/ibm-plex-sans/600.css"; @@ -57,6 +58,8 @@ if (!whitelabel) { // request locals.isSubsite, which we trust here, but as a fallback also use // the presence of a whitelabel mapping or a detected subsite path. const isSubsite = (Astro.request as any)?.locals?.isSubsite ?? Boolean(whitelabel || detectedSubsite); +const normalizedPath = (Astro.url.pathname || '/').replace(/\/+$/, '') || '/'; +const isRadioPage = normalizedPath === '/radio'; // Debug logging if (import.meta.env.DEV) { @@ -94,7 +97,7 @@ if (import.meta.env.DEV) {
+ class="flex-auto min-w-0 mt-6 md:mt-8 flex flex-col px-4 sm:px-6 md:px-0 max-w-3xl w-full pb-28 md:pb-24">
+ {!isSubsite && !isRadioPage && } diff --git a/src/pages/TRip/requests.astro b/src/pages/TRip/requests.astro index 9690aa0..96c95b8 100644 --- a/src/pages/TRip/requests.astro +++ b/src/pages/TRip/requests.astro @@ -8,7 +8,7 @@ const user = Astro.locals.user as any; ---
- +
@@ -17,5 +17,6 @@ const user = Astro.locals.user as any; html:has(.trip-section) main { max-width: 1400px !important; width: 100% !important; + padding-bottom: 7rem !important; } diff --git a/src/utils/hlsConfig.ts b/src/utils/hlsConfig.ts new file mode 100644 index 0000000..d9fc864 --- /dev/null +++ b/src/utils/hlsConfig.ts @@ -0,0 +1,38 @@ +import type { HlsConfig } from "hls.js"; + +// Shared HLS.js tuning for all players. +// Source profile is 0.5s segments with 5 live segments (Liquidsoap), +// so we keep live sync close while allowing enough headroom for network jitter. +export const SHARED_HLS_CONFIG: Partial = { + // Live latency behavior + lowLatencyMode: false, + // Keep slightly more headroom so HLS doesn't need audible rate correction. + liveSyncDurationCount: 2, + // Keep a wider cushion before catch-up behavior is triggered. + liveMaxLatencyDurationCount: 6, + // Avoid "sped up" sounding playback during live edge catch-up. + maxLiveSyncPlaybackRate: 1, + + // Buffer behavior (audio-only stream; keep lean for quicker station swaps) + maxBufferLength: 10, + maxMaxBufferLength: 20, + backBufferLength: 30, + + // ABR smoothing for quick but stable quality adaptation + abrEwmaFastLive: 2, + abrEwmaSlowLive: 7, + abrBandWidthFactor: 0.9, + abrBandWidthUpFactor: 0.65, + + // Loading / retry policy + manifestLoadingTimeOut: 8000, + levelLoadingTimeOut: 8000, + fragLoadingTimeOut: 12000, + manifestLoadingMaxRetry: 4, + levelLoadingMaxRetry: 4, + fragLoadingMaxRetry: 4, + + // Runtime + startLevel: -1, + enableWorker: true, +}; diff --git a/src/utils/liveCatchup.ts b/src/utils/liveCatchup.ts new file mode 100644 index 0000000..22911ac --- /dev/null +++ b/src/utils/liveCatchup.ts @@ -0,0 +1,134 @@ +import type Hls from "hls.js"; + +const CATCHUP_MARKER = "__seriousfm_oneShotCatchupInstalled"; + +type HlsWithMarker = Hls & { + [CATCHUP_MARKER]?: boolean; +}; + +type CatchupController = { + trigger: () => void; + cleanup: () => void; +}; + +const controllers = new WeakMap(); + +/** + * Apply a subtle one-shot live catch-up for a newly created HLS instance. + * + * Why: continuous live-edge catch-up can sound "sped up". We keep HLS auto catch-up + * disabled and do a very small temporary rate bump once, then return to 1.0. + */ +export function installOneShotLiveCatchup(hls: Hls, media: HTMLMediaElement): () => void { + const existing = controllers.get(hls); + if (existing) { + return existing.cleanup; + } + + const tagged = hls as HlsWithMarker; + if (tagged[CATCHUP_MARKER]) { + return () => {}; + } + tagged[CATCHUP_MARKER] = true; + + let intervalId: ReturnType | null = null; + let stopTimerId: ReturnType | null = null; + let running = false; + + const setRate = (rate: number) => { + // Keep pitch stable where supported. + (media as any).preservesPitch = true; + (media as any).mozPreservesPitch = true; + (media as any).webkitPreservesPitch = true; + media.playbackRate = rate; + }; + + const resetRate = () => { + setRate(1); + }; + + const getExcessLatency = (): number | null => { + const latency = (hls as any).latency; + const targetLatency = (hls as any).targetLatency; + if (typeof latency !== "number" || typeof targetLatency !== "number") { + return null; + } + return latency - targetLatency; + }; + + const stopCatchup = () => { + if (intervalId) { + clearInterval(intervalId); + intervalId = null; + } + if (stopTimerId) { + clearTimeout(stopTimerId); + stopTimerId = null; + } + running = false; + resetRate(); + }; + + const tick = () => { + // If user paused/stopped, just stop the one-shot session. + if (media.paused || media.ended) { + stopCatchup(); + return; + } + + const excess = getExcessLatency(); + if (excess === null) { + // Metric unavailable yet, keep waiting inside the bounded one-shot window. + return; + } + + // Close enough to target live latency; stop and return to normal speed. + if (excess <= 0.35) { + stopCatchup(); + return; + } + + // Imperceptible speed-up profile. + if (excess > 6) { + setRate(1.03); + } else if (excess > 3) { + setRate(1.02); + } else { + setRate(1.01); + } + }; + + const trigger = () => { + if (running || media.paused || media.ended) return; + running = true; + + // Slight delay so HLS latency metrics are populated after track transition. + stopTimerId = setTimeout(() => { + tick(); + intervalId = setInterval(tick, 1500); + + // Hard stop so this is always a bounded "one-time" correction. + stopTimerId = setTimeout(() => { + stopCatchup(); + }, 35_000); + }, 900); + }; + + const cleanup = () => { + stopCatchup(); + }; + + controllers.set(hls, { trigger, cleanup }); + + return cleanup; +} + +/** + * Trigger subtle one-shot live catch-up for the current track transition. + */ +export function triggerOneShotLiveCatchup(hls: Hls | null, media: HTMLMediaElement | null): void { + if (!hls || !media) return; + const controller = controllers.get(hls); + if (!controller) return; + controller.trigger(); +} diff --git a/src/utils/radioState.ts b/src/utils/radioState.ts new file mode 100644 index 0000000..dcacdfc --- /dev/null +++ b/src/utils/radioState.ts @@ -0,0 +1,577 @@ +// ═══════════════════════════════════════════════════════════════════════════════ +// GLOBAL RADIO STATE SINGLETON +// Shared between MiniRadioPlayer and the full Radio page to ensure +// playback continuity when navigating between pages. +// ═══════════════════════════════════════════════════════════════════════════════ + +import type Hls from "hls.js"; + +export interface QualityLevel { + index: number; + bitrate: number; + name: string; + isLossless?: boolean; +} + +export interface GlobalRadioState { + audio: HTMLAudioElement | null; + video: HTMLVideoElement | null; + hls: Hls | null; + isPlaying: boolean; + station: string; + qualityLevel: number; + qualityLevels: QualityLevel[]; + // Track metadata for seamless handoff + trackTitle: string; + trackArtist: string; + trackAlbum: string; + coverArt: string; + trackUuid: string | null; +} + +// Station type helpers +export const VIDEO_STATIONS = ["videos"] as const; +export type VideoStation = typeof VIDEO_STATIONS[number]; + +export function isVideoStation(station: string): station is VideoStation { + return VIDEO_STATIONS.includes(station as VideoStation); +} + +export function getStreamUrl(station: string): string { + if (isVideoStation(station)) { + return `https://stream.codey.horse/hls/videos/videos.m3u8`; + } + return `https://stream.codey.horse/hls/${station}/${station}.m3u8`; +} + +const GLOBAL_KEY = "__seriousFmRadio"; +const RADIO_SHARED_STATE_KEY = "__seriousFmRadioSharedState"; +const RADIO_SYNC_PULSE_KEY = "__seriousFmRadioSyncPulse"; +const RADIO_SYNC_CHANNEL = "seriousfm-radio-sync-v1"; + +const TAB_ID = typeof window === "undefined" + ? "server" + : `${Date.now()}-${Math.random().toString(36).slice(2)}`; + +type RadioSyncMessage = { + type: "PLAYBACK" | "STATE"; + tabId: string; + at: number; + payload: Record; +}; + +let syncInitialized = false; +let syncChannel: BroadcastChannel | null = null; +const handledSyncMessageKeys: string[] = []; + +function buildSyncMessageKey(message: RadioSyncMessage): string { + return `${message.tabId}|${message.type}|${message.at}|${JSON.stringify(message.payload || {})}`; +} + +function markSyncMessageHandled(message: RadioSyncMessage): boolean { + const key = buildSyncMessageKey(message); + if (handledSyncMessageKeys.includes(key)) { + return false; + } + handledSyncMessageKeys.push(key); + if (handledSyncMessageKeys.length > 200) { + handledSyncMessageKeys.shift(); + } + return true; +} + +function readSharedSnapshot(): Partial | null { + if (typeof window === "undefined") return null; + try { + const raw = localStorage.getItem(RADIO_SHARED_STATE_KEY); + if (!raw) return null; + return JSON.parse(raw); + } catch { + return null; + } +} + +function writeSharedSnapshot(state: GlobalRadioState): void { + if (typeof window === "undefined") return; + try { + const existingOwner = readPlaybackOwnerTabId(); + const snapshot = { + station: state.station, + qualityLevel: state.qualityLevel, + qualityLevels: state.qualityLevels, + isPlaying: state.isPlaying, + playbackOwnerTabId: existingOwner, + trackTitle: state.trackTitle, + trackArtist: state.trackArtist, + trackAlbum: state.trackAlbum, + coverArt: state.coverArt, + trackUuid: state.trackUuid, + at: Date.now(), + }; + localStorage.setItem(RADIO_SHARED_STATE_KEY, JSON.stringify(snapshot)); + } catch { + // ignore + } +} + +function readPlaybackOwnerTabId(): string | null { + if (typeof window === "undefined") return null; + try { + const raw = localStorage.getItem(RADIO_SHARED_STATE_KEY); + if (!raw) return null; + const parsed = JSON.parse(raw); + return typeof parsed?.playbackOwnerTabId === "string" ? parsed.playbackOwnerTabId : null; + } catch { + return null; + } +} + +function writePlaybackOwnerTabId(ownerTabId: string | null): void { + if (typeof window === "undefined") return; + try { + const raw = localStorage.getItem(RADIO_SHARED_STATE_KEY); + const parsed = raw ? JSON.parse(raw) : {}; + parsed.playbackOwnerTabId = ownerTabId; + parsed.at = Date.now(); + localStorage.setItem(RADIO_SHARED_STATE_KEY, JSON.stringify(parsed)); + } catch { + // ignore + } +} + +function postSyncMessage(message: RadioSyncMessage): void { + if (typeof window === "undefined") return; + try { + if (syncChannel) { + syncChannel.postMessage(message); + } + } catch { + // ignore + } + + // Fallback pulse for browsers/environments without BroadcastChannel support. + try { + localStorage.setItem(RADIO_SYNC_PULSE_KEY, JSON.stringify(message)); + } catch { + // ignore + } +} + +function applyRemoteState(payload: Record): void { + const state = getGlobalRadioState(); + const prevStation = state.station; + const prevQuality = state.qualityLevel; + const prevTrackUuid = state.trackUuid; + + if (typeof payload.station === "string") { + state.station = payload.station; + try { + localStorage.setItem("radioStation", payload.station); + } catch { + // ignore + } + } + + if (typeof payload.qualityLevel === "number") { + state.qualityLevel = payload.qualityLevel; + try { + localStorage.setItem("radioQuality", String(payload.qualityLevel)); + } catch { + // ignore + } + } + + if (Array.isArray(payload.qualityLevels)) state.qualityLevels = payload.qualityLevels; + if (typeof payload.isPlaying === "boolean") { + state.isPlaying = payload.isPlaying; + emitRadioEvent(RADIO_EVENTS.PLAYBACK_CHANGED, { isPlaying: payload.isPlaying }); + } + if (typeof payload.trackTitle === "string") state.trackTitle = payload.trackTitle; + if (typeof payload.trackArtist === "string") state.trackArtist = payload.trackArtist; + if (typeof payload.trackAlbum === "string") state.trackAlbum = payload.trackAlbum; + if (typeof payload.coverArt === "string") state.coverArt = payload.coverArt; + if (typeof payload.trackUuid === "string" || payload.trackUuid === null) state.trackUuid = payload.trackUuid; + + if (state.station !== prevStation) { + emitRadioEvent(RADIO_EVENTS.STATION_CHANGED, { station: state.station }); + } + if (state.qualityLevel !== prevQuality) { + emitRadioEvent(RADIO_EVENTS.QUALITY_CHANGED, { quality: state.qualityLevel }); + } + if (state.trackUuid !== prevTrackUuid) { + emitRadioEvent(RADIO_EVENTS.TRACK_CHANGED, { + title: state.trackTitle, + artist: state.trackArtist, + album: state.trackAlbum, + coverArt: state.coverArt, + uuid: state.trackUuid, + }); + } +} + +function handleSyncMessage(message: RadioSyncMessage): void { + if (!message || message.tabId === TAB_ID) return; + if (!markSyncMessageHandled(message)) return; + + if (message.type === "PLAYBACK") { + const shouldBePlaying = !!message.payload?.isPlaying; + const state = getGlobalRadioState(); + const ownerTabId = typeof message.payload?.ownerTabId === "string" ? message.payload.ownerTabId : null; + const mediaList = [state.audio, state.video].filter(Boolean) as Array; + + if (shouldBePlaying) { + if (ownerTabId && ownerTabId === TAB_ID) { + const media = getActiveMediaElement(); + if (media) { + try { + media.playbackRate = 1; + const playPromise = media.play(); + if (playPromise && typeof (playPromise as Promise).catch === "function") { + (playPromise as Promise).catch(() => { + // ignore autoplay/user-gesture restrictions + }); + } + } catch { + // ignore + } + } + state.isPlaying = true; + writePlaybackOwnerTabId(ownerTabId); + emitRadioEvent(RADIO_EVENTS.PLAYBACK_CHANGED, { isPlaying: true }); + return; + } + + for (const media of mediaList) { + if (media.paused || media.ended) continue; + try { + media.pause(); + } catch { + // ignore + } + } + // Reflect that radio is playing in another tab while keeping this tab paused. + state.isPlaying = true; + if (ownerTabId) { + writePlaybackOwnerTabId(ownerTabId); + } + emitRadioEvent(RADIO_EVENTS.PLAYBACK_CHANGED, { isPlaying: true }); + return; + } + + // Global pause request: pause any local media in this tab as well. + for (const media of mediaList) { + if (media.paused || media.ended) continue; + try { + media.pause(); + } catch { + // ignore + } + } + + if (ownerTabId) { + writePlaybackOwnerTabId(ownerTabId); + } + state.isPlaying = false; + emitRadioEvent(RADIO_EVENTS.PLAYBACK_CHANGED, { isPlaying: false }); + return; + } + + if (message.type === "STATE") { + applyRemoteState(message.payload || {}); + } +} + +function ensureCrossTabSync(): void { + if (typeof window === "undefined" || syncInitialized) return; + syncInitialized = true; + + if (typeof BroadcastChannel !== "undefined") { + try { + syncChannel = new BroadcastChannel(RADIO_SYNC_CHANNEL); + syncChannel.onmessage = (event: MessageEvent) => { + handleSyncMessage(event.data); + }; + } catch { + syncChannel = null; + } + } + + window.addEventListener("storage", (event) => { + if (!event.key) return; + if (event.key === RADIO_SYNC_PULSE_KEY && event.newValue) { + try { + handleSyncMessage(JSON.parse(event.newValue)); + } catch { + // ignore + } + return; + } + + if (event.key === RADIO_SHARED_STATE_KEY && event.newValue) { + try { + const snapshot = JSON.parse(event.newValue); + // Live playback state is coordinated via PLAYBACK messages; do not override + // it opportunistically from snapshot writes that can come from passive tabs. + const { isPlaying: _ignored, playbackOwnerTabId: _ignoredOwner, ...rest } = snapshot || {}; + applyRemoteState(rest); + } catch { + // ignore + } + } + }); +} + +function syncStateAcrossTabs(partial: Record): void { + if (typeof window === "undefined") return; + postSyncMessage({ + type: "STATE", + tabId: TAB_ID, + at: Date.now(), + payload: partial, + }); +} + +function syncPlaybackAcrossTabs(isPlaying: boolean, ownerTabIdOverride?: string | null): void { + if (typeof window === "undefined") return; + const ownerTabId = ownerTabIdOverride !== undefined + ? ownerTabIdOverride + : (isPlaying ? TAB_ID : null); + postSyncMessage({ + type: "PLAYBACK", + tabId: TAB_ID, + at: Date.now(), + payload: { isPlaying, ownerTabId }, + }); +} + +export function getGlobalRadioState(): GlobalRadioState { + if (typeof window === "undefined") { + return { + audio: null, + video: null, + hls: null, + isPlaying: false, + station: "main", + qualityLevel: -1, + qualityLevels: [], + trackTitle: "", + trackArtist: "", + trackAlbum: "", + coverArt: "/images/radio_art_default.jpg", + trackUuid: null, + }; + } + + const w = window as any; + if (!w[GLOBAL_KEY]) { + const snapshot = readSharedSnapshot(); + w[GLOBAL_KEY] = { + audio: null, + video: null, + hls: null, + isPlaying: typeof snapshot?.isPlaying === "boolean" ? snapshot.isPlaying : false, + station: snapshot?.station || localStorage.getItem("radioStation") || "main", + qualityLevel: typeof snapshot?.qualityLevel === "number" + ? snapshot.qualityLevel + : parseInt(localStorage.getItem("radioQuality") || "-1", 10), + qualityLevels: Array.isArray(snapshot?.qualityLevels) ? snapshot.qualityLevels : [], + trackTitle: snapshot?.trackTitle || "", + trackArtist: snapshot?.trackArtist || "", + trackAlbum: snapshot?.trackAlbum || "", + coverArt: snapshot?.coverArt || "/images/radio_art_default.jpg", + trackUuid: snapshot?.trackUuid ?? null, + }; + } + ensureCrossTabSync(); + return w[GLOBAL_KEY]; +} + +export function getOrCreateAudio(): HTMLAudioElement { + const state = getGlobalRadioState(); + if (!state.audio) { + state.audio = document.createElement("audio"); + state.audio.preload = "none"; + state.audio.crossOrigin = "anonymous"; + state.audio.setAttribute("playsinline", "true"); + state.audio.setAttribute("webkit-playsinline", "true"); + } + return state.audio; +} + +export function getOrCreateVideo(): HTMLVideoElement { + const state = getGlobalRadioState(); + if (!state.video) { + state.video = document.createElement("video"); + state.video.preload = "none"; + state.video.crossOrigin = "anonymous"; + state.video.setAttribute("playsinline", "true"); + state.video.setAttribute("webkit-playsinline", "true"); + state.video.muted = false; + } + return state.video; +} + +/** Returns the current media element (audio or video) based on station */ +export function getActiveMediaElement(): HTMLAudioElement | HTMLVideoElement | null { + const state = getGlobalRadioState(); + if (isVideoStation(state.station)) { + return state.video; + } + return state.audio; +} + +export function updateGlobalStation(station: string): void { + const state = getGlobalRadioState(); + state.station = station; + // Reset metadata on station switch to avoid stale info bleeding across stations. + state.trackTitle = ""; + state.trackArtist = ""; + state.trackAlbum = ""; + state.coverArt = "/images/radio_art_default.jpg"; + state.trackUuid = null; + localStorage.setItem("radioStation", station); + writeSharedSnapshot(state); + syncStateAcrossTabs({ + station, + trackTitle: "", + trackArtist: "", + trackAlbum: "", + coverArt: "/images/radio_art_default.jpg", + trackUuid: null, + }); +} + +export function updateGlobalQuality(qualityLevel: number): void { + const state = getGlobalRadioState(); + state.qualityLevel = qualityLevel; + localStorage.setItem("radioQuality", qualityLevel.toString()); + writeSharedSnapshot(state); + syncStateAcrossTabs({ qualityLevel }); +} + +export function updateGlobalPlayingState(isPlaying: boolean): void { + const state = getGlobalRadioState(); + + // Prevent passive tabs from stomping shared playing state owned by another tab. + if (!isPlaying) { + const currentOwner = readPlaybackOwnerTabId(); + if (currentOwner && currentOwner !== TAB_ID) { + return; + } + } + + state.isPlaying = isPlaying; + writeSharedSnapshot(state); + writePlaybackOwnerTabId(isPlaying ? TAB_ID : null); + syncPlaybackAcrossTabs(isPlaying); +} + +/** + * Force pause radio across all tabs/windows. + * Useful when a passive tab (not current owner) requests pause. + */ +export function requestGlobalPauseAll(): void { + const state = getGlobalRadioState(); + const previousOwner = readPlaybackOwnerTabId(); + state.isPlaying = false; + writeSharedSnapshot(state); + // Preserve previous owner so resume can target the same tab/media element. + writePlaybackOwnerTabId(previousOwner); + syncPlaybackAcrossTabs(false, previousOwner); + emitRadioEvent(RADIO_EVENTS.PLAYBACK_CHANGED, { isPlaying: false }); +} + +/** + * If another tab owns playback, request that owner tab to resume. + * Returns true when a remote-owner resume request was sent. + */ +export function requestOwnerTabResume(): boolean { + const ownerTabId = readPlaybackOwnerTabId(); + if (!ownerTabId || ownerTabId === TAB_ID) return false; + + const state = getGlobalRadioState(); + state.isPlaying = true; + writeSharedSnapshot(state); + writePlaybackOwnerTabId(ownerTabId); + syncPlaybackAcrossTabs(true, ownerTabId); + emitRadioEvent(RADIO_EVENTS.PLAYBACK_CHANGED, { isPlaying: true }); + return true; +} + +export function updateGlobalTrackInfo(info: { + title?: string; + artist?: string; + album?: string; + coverArt?: string; + uuid?: string | null; +}): void { + const state = getGlobalRadioState(); + if (info.title !== undefined) state.trackTitle = info.title; + if (info.artist !== undefined) state.trackArtist = info.artist; + if (info.album !== undefined) state.trackAlbum = info.album; + if (info.coverArt !== undefined) state.coverArt = info.coverArt; + if (info.uuid !== undefined) state.trackUuid = info.uuid; + writeSharedSnapshot(state); + syncStateAcrossTabs({ + trackTitle: state.trackTitle, + trackArtist: state.trackArtist, + trackAlbum: state.trackAlbum, + coverArt: state.coverArt, + trackUuid: state.trackUuid, + }); +} + +export function destroyGlobalHls(): void { + const state = getGlobalRadioState(); + if (state.hls) { + state.hls.destroy(); + state.hls = null; + } +} + +export function setGlobalHls(hls: Hls | null): void { + const state = getGlobalRadioState(); + state.hls = hls; +} + +export function setGlobalQualityLevels(levels: QualityLevel[]): void { + const state = getGlobalRadioState(); + state.qualityLevels = levels; + writeSharedSnapshot(state); + syncStateAcrossTabs({ qualityLevels: levels }); +} + +// Event system for cross-component communication +type RadioEventCallback = (data: any) => void; +const listeners: Map> = new Map(); + +export function onRadioEvent(event: string, callback: RadioEventCallback): () => void { + if (!listeners.has(event)) { + listeners.set(event, new Set()); + } + listeners.get(event)!.add(callback); + + // Return unsubscribe function + return () => { + listeners.get(event)?.delete(callback); + }; +} + +export function emitRadioEvent(event: string, data?: any): void { + listeners.get(event)?.forEach(callback => { + try { + callback(data); + } catch (e) { + console.error("Radio event listener error:", e); + } + }); +} + +// Event names +export const RADIO_EVENTS = { + STATION_CHANGED: "station_changed", + QUALITY_CHANGED: "quality_changed", + PLAYBACK_CHANGED: "playback_changed", + TRACK_CHANGED: "track_changed", + PLAYER_TAKEOVER: "player_takeover", // Full player taking over from mini +} as const;