TRip: add video support

This commit is contained in:
2026-02-18 13:34:27 -05:00
parent b52a65ea6b
commit 94166904f7
5 changed files with 574 additions and 57 deletions

View File

@@ -178,27 +178,37 @@ export default function Player({ user }: PlayerProps) {
// Initialize or switch HLS stream // Initialize or switch HLS stream
const initializeStream = (station) => { const initializeStream = (station) => {
setIsStreamReady(false); 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 const audio = audioElement.current;
if (hlsInstance.current) { if (!audio) return;
hlsInstance.current.destroy();
hlsInstance.current = null; // Clean up previous HLS instance first (synchronous but quick)
} if (hlsInstance.current) {
audio.pause(); hlsInstance.current.destroy();
audio.removeAttribute("src"); hlsInstance.current = null;
}
// Pause and reset audio element
audio.pause();
audio.removeAttribute("src");
// Defer the blocking audio.load() call
setTimeout(() => {
audio.load(); audio.load();
}, 0);
// Handle audio load errors // Handle audio load errors
audio.onerror = () => { audio.onerror = () => {
setIsPlaying(false); setIsPlaying(false);
setIsStreamReady(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.src = streamUrl;
audio.load(); audio.load();
setIsStreamReady(true); setIsStreamReady(true);
@@ -206,8 +216,14 @@ export default function Player({ user }: PlayerProps) {
setTrackTitle("Offline"); setTrackTitle("Offline");
setIsPlaying(false); 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()) { if (!Hls.isSupported()) {
console.error("HLS not supported"); console.error("HLS not supported");
@@ -216,15 +232,8 @@ export default function Player({ user }: PlayerProps) {
const hls = new Hls({ const hls = new Hls({
lowLatencyMode: false, lowLatencyMode: false,
// abrEnabled: false, // ABR not in current HLS.js types liveSyncDuration: 0.6,
liveSyncDuration: 0.6, // seconds behind live edge target liveMaxLatencyDuration: 3.0,
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
} as Partial<typeof Hls.DefaultConfig>); } as Partial<typeof Hls.DefaultConfig>);
hlsInstance.current = hls; hlsInstance.current = hls;
@@ -236,14 +245,48 @@ export default function Player({ user }: PlayerProps) {
setIsPlaying(false); setIsPlaying(false);
}); });
}); });
let mediaRecoveryAttempts = 0;
const MAX_RECOVERY_ATTEMPTS = 3;
hls.on(Hls.Events.ERROR, (event, data) => { hls.on(Hls.Events.ERROR, (event, data) => {
console.warn("HLS error:", data); console.warn("HLS error:", data);
if (data.fatal) { if (data.fatal) {
hls.destroy(); switch (data.type) {
hlsInstance.current = null; case Hls.ErrorTypes.MEDIA_ERROR:
setTrackTitle("Offline"); // bufferAppendError and other media errors are recoverable
setIsPlaying(false); if (mediaRecoveryAttempts < MAX_RECOVERY_ATTEMPTS) {
setIsStreamReady(false); 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 // Handle station changes: reset and start new stream
useEffect(() => { useEffect(() => {
// Reset metadata and state when switching stations // Batch all state resets to minimize re-renders
setTrackTitle(""); // Use startTransition to mark this as a non-urgent update
setTrackArtist(""); const resetState = () => {
setTrackGenre(""); setTrackTitle("");
setTrackAlbum(""); setTrackArtist("");
setCoverArt("/images/radio_art_default.jpg"); setTrackGenre("");
setLyrics([]); setTrackAlbum("");
setCurrentLyricIndex(0); setCoverArt("/images/radio_art_default.jpg");
setElapsedTime(0); setLyrics([]);
setTrackDuration(0); setCurrentLyricIndex(0);
setElapsedTime(0);
setTrackDuration(0);
};
// Reset refs
currentTrackUuid.current = null; currentTrackUuid.current = null;
baseTrackElapsed.current = 0; baseTrackElapsed.current = 0;
lastUpdateTimestamp.current = Date.now(); 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 // Update page title to reflect the new station
document.title = `${metaData.title} - Radio [${activeStation}]`; document.title = `${metaData.title} - Radio [${activeStation}]`;

View File

@@ -42,6 +42,23 @@ interface Track {
version?: string; 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 { interface DiskSpaceInfo {
total?: number; total?: number;
used?: number; used?: number;
@@ -89,6 +106,16 @@ export default function MediaRequestForm() {
const [audioProgress, setAudioProgress] = useState<AudioProgress>({ current: 0, duration: 0 }); const [audioProgress, setAudioProgress] = useState<AudioProgress>({ current: 0, duration: 0 });
const [diskSpace, setDiskSpace] = useState<DiskSpaceInfo | null>(null); const [diskSpace, setDiskSpace] = useState<DiskSpaceInfo | null>(null);
// Video search state
const [videoSearchQuery, setVideoSearchQuery] = useState("");
const [videoResults, setVideoResults] = useState<Video[]>([]);
const [isVideoSearching, setIsVideoSearching] = useState(false);
const [selectedVideos, setSelectedVideos] = useState<Set<string | number>>(new Set());
const [videoPreviewId, setVideoPreviewId] = useState<string | number | null>(null);
const [videoStreamUrl, setVideoStreamUrl] = useState<string | null>(null);
const [isVideoLoading, setIsVideoLoading] = useState(false);
const [showVideoSection, setShowVideoSection] = useState(false);
const { attachScrollFix, cleanupScrollFix } = useAutoCompleteScrollFix(); const { attachScrollFix, cleanupScrollFix } = useAutoCompleteScrollFix();
const debounceTimeout = useRef<ReturnType<typeof setTimeout> | null>(null); const debounceTimeout = useRef<ReturnType<typeof setTimeout> | 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(() => { useEffect(() => {
if (typeof Audio === "undefined") { if (typeof Audio === "undefined") {
return undefined; return undefined;
@@ -1387,6 +1595,226 @@ export default function MediaRequestForm() {
)} )}
</Button> </Button>
</div> </div>
{/* Videos Section - Show when artist is selected */}
{selectedArtist && (
<div className="mt-8 pt-6 border-t border-neutral-200 dark:border-neutral-700">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold flex items-center gap-2">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
Music Videos
</h3>
<button
type="button"
className={`px-3 py-1.5 text-sm font-medium rounded-md border transition-colors
${showVideoSection
? 'bg-blue-600 text-white border-blue-600 hover:bg-blue-700'
: 'border-neutral-400 dark:border-neutral-600 text-neutral-700 dark:text-neutral-300 hover:bg-neutral-100 dark:hover:bg-neutral-800'
}
disabled:opacity-50 disabled:cursor-not-allowed`}
onClick={() => {
if (!showVideoSection && selectedArtist) {
fetchArtistVideos(selectedArtist.id);
} else {
setShowVideoSection(!showVideoSection);
}
}}
disabled={isVideoSearching}
>
{isVideoSearching ? (
<span className="flex items-center gap-2">
<InlineSpinner sizeClass="h-3 w-3" />
Loading...
</span>
) : showVideoSection ? (
"Hide Videos"
) : (
"Browse Videos"
)}
</button>
</div>
{showVideoSection && (
<div className="space-y-4">
{/* Video Search Bar */}
<div className="flex gap-2">
<input
type="text"
value={videoSearchQuery}
onChange={(e) => 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"
/>
<button
type="button"
onClick={searchVideos}
disabled={isVideoSearching}
className="px-4 py-2 text-sm font-medium rounded-md bg-blue-600 text-white hover:bg-blue-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{isVideoSearching ? <InlineSpinner sizeClass="h-4 w-4" /> : "Search"}
</button>
</div>
{/* Video Select All / Count */}
{videoResults.length > 0 && (
<div className="flex items-center justify-between">
<span className="text-sm text-neutral-600 dark:text-neutral-400">
<strong>{videoResults.length}</strong> video{videoResults.length !== 1 ? 's' : ''} found
</span>
<a
href="#"
role="button"
onClick={(e) => {
e.preventDefault();
if (selectedVideos.size === videoResults.length) {
setSelectedVideos(new Set());
} else {
setSelectedVideos(new Set(videoResults.map((v) => v.id)));
}
}}
className="text-sm text-blue-600 hover:underline cursor-pointer"
>
{selectedVideos.size === videoResults.length ? 'Uncheck All' : 'Check All'}
</a>
</div>
)}
{/* Video Results Grid */}
{videoResults.length > 0 ? (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{videoResults.map((video) => (
<div
key={video.id}
className={`relative rounded-lg border overflow-hidden transition-all ${
selectedVideos.has(video.id)
? "border-blue-500 ring-2 ring-blue-500/30"
: "border-neutral-200 dark:border-neutral-700"
}`}
>
{/* Video Thumbnail */}
<div
className="relative aspect-video bg-neutral-200 dark:bg-neutral-800 cursor-pointer group"
onClick={() => handleVideoPreview(video)}
>
{video.image || video.imageUrl ? (
<img
src={video.image || video.imageUrl}
alt={video.title}
className="w-full h-full object-cover"
/>
) : (
<div className="w-full h-full flex items-center justify-center text-neutral-400">
<svg className="w-12 h-12" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
</div>
)}
{/* Play overlay */}
<div className="absolute inset-0 flex items-center justify-center bg-black/30 opacity-0 group-hover:opacity-100 transition-opacity">
{isVideoLoading && videoPreviewId === video.id ? (
<InlineSpinner sizeClass="h-8 w-8" />
) : (
<svg className="w-12 h-12 text-white" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z" />
</svg>
)}
</div>
{/* Duration badge */}
{video.duration && (
<span className="absolute bottom-2 right-2 px-1.5 py-0.5 bg-black/70 text-white text-xs rounded">
{formatVideoDuration(video.duration)}
</span>
)}
</div>
{/* Video Info */}
<div className="p-3">
<div className="flex items-start gap-2">
<input
type="checkbox"
checked={selectedVideos.has(video.id)}
onChange={() => toggleVideoSelection(video.id)}
className="mt-1 cursor-pointer"
/>
<div className="flex-1 min-w-0">
<p className="font-medium text-sm truncate" title={video.title}>
{video.title}
</p>
{video.artist && (
<p className="text-xs text-neutral-500 truncate">
{video.artist}
</p>
)}
</div>
</div>
<div className="flex justify-end mt-2">
<button
onClick={() => handleVideoDownload(video)}
className="text-xs text-blue-600 hover:underline"
>
Download
</button>
</div>
</div>
{/* Video Preview Player */}
{videoPreviewId === video.id && videoStreamUrl && (
<div className="border-t border-neutral-200 dark:border-neutral-700">
<video
src={videoStreamUrl}
controls
autoPlay
className="w-full"
onError={() => {
toast.error("Failed to play video");
setVideoPreviewId(null);
setVideoStreamUrl(null);
}}
/>
</div>
)}
</div>
))}
</div>
) : !isVideoSearching && showVideoSection ? (
<div className="text-center py-8 text-neutral-500">
<svg className="w-12 h-12 mx-auto mb-2 opacity-50" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
<p>No videos found. Try searching for something else.</p>
</div>
) : null}
{/* Selected Videos Summary */}
{selectedVideos.size > 0 && (
<div className="flex items-center justify-between p-3 bg-blue-50 dark:bg-blue-900/20 rounded-lg border border-blue-200 dark:border-blue-800">
<span className="text-sm text-neutral-800 dark:text-neutral-200">
<strong>{selectedVideos.size}</strong> video{selectedVideos.size !== 1 ? "s" : ""} selected
</span>
<button
type="button"
onClick={handleSubmitVideoRequest}
disabled={isSubmitting}
className="px-4 py-1.5 text-sm font-medium rounded-md bg-blue-600 text-white hover:bg-blue-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{isSubmitting ? (
<span className="flex items-center gap-2">
<InlineSpinner sizeClass="h-3 w-3" />
Submitting...
</span>
) : (
"Submit Video Request"
)}
</button>
</div>
)}
</div>
)}
</div>
)}
</> </>
) )
} }

View File

@@ -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; } .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 */ /* Container Styles */
.trip-management-container { .trip-management-container {

View File

@@ -54,15 +54,19 @@ export default function RequestManagement() {
const resolveTarballPath = (job: RequestJob) => job.tarball; 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; if (!absPath) return null;
const filename = absPath.split("/").pop(); // get "SOMETHING.tar.gz" const filename = absPath.split("/").pop(); // get "SOMETHING.tar.gz"
// If the backend already stores a fully qualified URL, return as-is // If the backend already stores a fully qualified URL, return as-is
if (/^https?:\/\//i.test(absPath)) return absPath; if (/^https?:\/\//i.test(absPath)) return absPath;
const isVideo = jobType === "video" || absPath.includes("/videos/");
// Check if path is /storage/music/TRIP // Check if path is /storage/music/TRIP
if (absPath.includes("/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 // Otherwise, assume /storage/music2/completed/{quality} format
@@ -436,7 +440,7 @@ export default function RequestManagement() {
</span> </span>
} }
body={(row: RequestJob) => { 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 "—"; if (!url) return "—";
const encodedURL = encodeURI(url); const encodedURL = encodeURI(url);
@@ -464,15 +468,16 @@ export default function RequestManagement() {
<Dialog <Dialog
header="Request Details" header="Request Details"
visible={isDialogVisible} visible={isDialogVisible}
style={{ width: "500px" }} style={{ width: "500px", maxHeight: "90vh" }}
onHide={() => setIsDialogVisible(false)} onHide={() => setIsDialogVisible(false)}
breakpoints={{ "960px": "95vw" }} breakpoints={{ "960px": "95vw" }}
modal modal
dismissableMask dismissableMask
className="dark:bg-neutral-900 dark:text-neutral-100" maximizable
className="dark:bg-neutral-900 dark:text-neutral-100 request-details-dialog"
> >
{selectedRequest ? ( {selectedRequest ? (
<div className="space-y-4 text-sm"> <div className="request-details-content space-y-4 text-sm">
{/* --- Metadata Card --- */} {/* --- Metadata Card --- */}
<div className="p-3 bg-gray-100 dark:bg-neutral-800 rounded-md grid grid-cols-1 sm:grid-cols-2 gap-4"> <div className="p-3 bg-gray-100 dark:bg-neutral-800 rounded-md grid grid-cols-1 sm:grid-cols-2 gap-4">
@@ -552,12 +557,12 @@ export default function RequestManagement() {
<p> <p>
<strong>Tarball:</strong>{" "} <strong>Tarball:</strong>{" "}
<a <a
href={encodeURI(tarballUrl(resolveTarballPath(selectedRequest), selectedRequest.quality) || "")} href={encodeURI(tarballUrl(resolveTarballPath(selectedRequest), selectedRequest.quality, selectedRequest.type) || "")}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="text-blue-500 hover:underline" className="text-blue-500 hover:underline"
> >
{tarballUrl(resolveTarballPath(selectedRequest), selectedRequest.quality)?.split("/").pop()} {tarballUrl(resolveTarballPath(selectedRequest), selectedRequest.quality, selectedRequest.type)?.split("/").pop()}
</a> </a>
</p> </p>
</div> </div>
@@ -566,9 +571,9 @@ export default function RequestManagement() {
{/* --- Track List Card --- */} {/* --- Track List Card --- */}
{selectedRequest.track_list && selectedRequest.track_list.length > 0 && ( {selectedRequest.track_list && selectedRequest.track_list.length > 0 && (
<div className="p-3 bg-gray-100 dark:bg-neutral-800 rounded-md"> <div className="p-3 bg-gray-100 dark:bg-neutral-800 rounded-md track-list-card">
<p className="mb-2"><strong>Tracks ({selectedRequest.track_list.length}):</strong></p> <p className="mb-2 flex-shrink-0"><strong>Tracks ({selectedRequest.track_list.length}):</strong></p>
<div className="max-h-60 overflow-y-auto space-y-2" data-lenis-prevent> <div className="track-list-scrollable space-y-2" data-lenis-prevent>
{selectedRequest.track_list.map((track, idx) => { {selectedRequest.track_list.map((track, idx) => {
const rawStatus = String(track.status || "pending"); const rawStatus = String(track.status || "pending");
const statusNorm = rawStatus.trim().toLowerCase(); const statusNorm = rawStatus.trim().toLowerCase();

View File

@@ -63,7 +63,7 @@ export const RADIO_API_URL: string = "https://radio-api.codey.lol";
export const socialLinks: Record<string, string> = { export const socialLinks: Record<string, string> = {
}; };
export const MAJOR_VERSION: string = "0.7" export const MAJOR_VERSION: string = "0.8"
export const RELEASE_FLAG: string | null = null; export const RELEASE_FLAG: string | null = null;
export const ENVIRONMENT: "Dev" | "Prod" = import.meta.env.DEV ? "Dev" : "Prod"; export const ENVIRONMENT: "Dev" | "Prod" = import.meta.env.DEV ? "Dev" : "Prod";