From 94166904f7b2dab57d4bea2d626c7c0efb8916d3 Mon Sep 17 00:00:00 2001 From: codey Date: Wed, 18 Feb 2026 13:34:27 -0500 Subject: [PATCH] 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";