dev #6
@@ -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;
|
const audio = audioElement.current;
|
||||||
if (!audio) return;
|
if (!audio) return;
|
||||||
const streamUrl = `https://stream.codey.lol/hls/${station}/${station}.m3u8`;
|
|
||||||
|
// Clean up previous HLS instance first (synchronous but quick)
|
||||||
// Clean up previous HLS
|
if (hlsInstance.current) {
|
||||||
if (hlsInstance.current) {
|
hlsInstance.current.destroy();
|
||||||
hlsInstance.current.destroy();
|
hlsInstance.current = null;
|
||||||
hlsInstance.current = null;
|
}
|
||||||
}
|
|
||||||
audio.pause();
|
// Pause and reset audio element
|
||||||
audio.removeAttribute("src");
|
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,9 +216,15 @@ 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");
|
||||||
return;
|
return;
|
||||||
@@ -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}]`;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user