dev #6
@@ -178,9 +178,8 @@ blockquote p:first-of-type::after {
|
|||||||
text-wrap: balance;
|
text-wrap: balance;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hidden {
|
/* Removed .hidden { display: none; } - Tailwind already provides this utility
|
||||||
display: none;
|
and the custom rule was breaking responsive variants like sm:flex */
|
||||||
}
|
|
||||||
|
|
||||||
[data-theme="dark"] .astro-code,
|
[data-theme="dark"] .astro-code,
|
||||||
[data-theme="dark"] .astro-code span {
|
[data-theme="dark"] .astro-code span {
|
||||||
|
|||||||
@@ -21,8 +21,8 @@ const envBadge = ENVIRONMENT === 'Dev' ? 'DEV' : null;
|
|||||||
{!whitelabel && <RandomMsg client:only="react" />}
|
{!whitelabel && <RandomMsg client:only="react" />}
|
||||||
<div class="footer-version" data-build-time={buildTime} title={`Built: ${buildTime}`}>
|
<div class="footer-version" data-build-time={buildTime} title={`Built: ${buildTime}`}>
|
||||||
<span class="version-pill">
|
<span class="version-pill">
|
||||||
{envBadge && <span class="env-dot" title="Development build"></span>}
|
{envBadge && <span class="env-dot api-status-dot" title="Development build"></span>}
|
||||||
{!envBadge && <span class="version-dot"></span>}
|
{!envBadge && <span class="version-dot api-status-dot"></span>}
|
||||||
<span class="version-text">{versionDisplay}:{buildNumber}</span>
|
<span class="version-text">{versionDisplay}:{buildNumber}</span>
|
||||||
<span class="build-time-text" aria-hidden="true"></span>
|
<span class="build-time-text" aria-hidden="true"></span>
|
||||||
</span>
|
</span>
|
||||||
@@ -30,6 +30,34 @@ const envBadge = ENVIRONMENT === 'Dev' ? 'DEV' : null;
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
function applyApiStatus(reachable) {
|
||||||
|
const dot = document.querySelector('.api-status-dot');
|
||||||
|
if (!dot) return;
|
||||||
|
dot.classList.toggle('api-offline', reachable === false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindApiStatusListener() {
|
||||||
|
const guard = window as any;
|
||||||
|
if (guard.__footerApiStatusBound) return;
|
||||||
|
guard.__footerApiStatusBound = true;
|
||||||
|
|
||||||
|
// Restore last known state across Astro client navigation
|
||||||
|
try {
|
||||||
|
const cached = sessionStorage.getItem('api-reachable');
|
||||||
|
if (cached === '0') applyApiStatus(false);
|
||||||
|
if (cached === '1') applyApiStatus(true);
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
window.addEventListener('api:status', (event) => {
|
||||||
|
const customEvent = event as CustomEvent<{ reachable?: boolean }>;
|
||||||
|
const reachable = Boolean(customEvent?.detail?.reachable);
|
||||||
|
applyApiStatus(reachable);
|
||||||
|
try {
|
||||||
|
sessionStorage.setItem('api-reachable', reachable ? '1' : '0');
|
||||||
|
} catch {}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function initBuildTooltip() {
|
function initBuildTooltip() {
|
||||||
const el = document.querySelector('.footer-version[data-build-time]');
|
const el = document.querySelector('.footer-version[data-build-time]');
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
@@ -60,6 +88,8 @@ const envBadge = ENVIRONMENT === 'Dev' ? 'DEV' : null;
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bindApiStatusListener();
|
||||||
|
|
||||||
if (document.readyState === 'loading') {
|
if (document.readyState === 'loading') {
|
||||||
document.addEventListener('DOMContentLoaded', initBuildTooltip);
|
document.addEventListener('DOMContentLoaded', initBuildTooltip);
|
||||||
} else {
|
} else {
|
||||||
@@ -144,6 +174,11 @@ const envBadge = ENVIRONMENT === 'Dev' ? 'DEV' : null;
|
|||||||
box-shadow: 0 0 4px rgba(34, 197, 94, 0.4);
|
box-shadow: 0 0 4px rgba(34, 197, 94, 0.4);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.api-status-dot.api-offline {
|
||||||
|
background: #ef4444 !important;
|
||||||
|
box-shadow: 0 0 4px rgba(239, 68, 68, 0.45) !important;
|
||||||
|
}
|
||||||
|
|
||||||
.env-dot {
|
.env-dot {
|
||||||
width: 6px;
|
width: 6px;
|
||||||
height: 6px;
|
height: 6px;
|
||||||
|
|||||||
1120
src/components/MiniRadioPlayer.tsx
Normal file
1120
src/components/MiniRadioPlayer.tsx
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -22,9 +22,15 @@ export default function RandomMsg(): React.ReactElement {
|
|||||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
if (data?.msg) setRandomMsg(data.msg.replace(/<br\s*\/?\>/gi, "\n"));
|
if (data?.msg) setRandomMsg(data.msg.replace(/<br\s*\/?\>/gi, "\n"));
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
window.dispatchEvent(new CustomEvent("api:status", { detail: { reachable: true } }));
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to fetch random message:", err);
|
console.error("Failed to fetch random message:", err);
|
||||||
setResponseTime(null);
|
setResponseTime(null);
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
window.dispatchEvent(new CustomEvent("api:status", { detail: { reachable: false } }));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -24,7 +24,6 @@ export default function BreadcrumbNav({ currentPage }: BreadcrumbNavProps): Reac
|
|||||||
<React.Fragment key={key}>
|
<React.Fragment key={key}>
|
||||||
<a
|
<a
|
||||||
href={href}
|
href={href}
|
||||||
data-astro-reload
|
|
||||||
className={`px-3 py-1.5 rounded-full transition-colors ${isActive
|
className={`px-3 py-1.5 rounded-full transition-colors ${isActive
|
||||||
? "bg-neutral-200 dark:bg-neutral-700 font-semibold text-neutral-900 dark:text-white"
|
? "bg-neutral-200 dark:bg-neutral-700 font-semibold text-neutral-900 dark:text-white"
|
||||||
: "text-neutral-500 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-white hover:bg-neutral-100 dark:hover:bg-neutral-800"
|
: "text-neutral-500 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-white hover:bg-neutral-100 dark:hover:bg-neutral-800"
|
||||||
|
|||||||
@@ -59,6 +59,21 @@ interface Video {
|
|||||||
release_date?: string; // snake_case variant
|
release_date?: string; // snake_case variant
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface Playlist {
|
||||||
|
id: string | number;
|
||||||
|
title?: string;
|
||||||
|
name?: string;
|
||||||
|
description?: string;
|
||||||
|
creator?: string;
|
||||||
|
owner?: string;
|
||||||
|
tracks_count?: number;
|
||||||
|
number_of_tracks?: number;
|
||||||
|
total_tracks?: number;
|
||||||
|
square_image_url?: string;
|
||||||
|
image?: string;
|
||||||
|
cover?: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface DiskSpaceInfo {
|
interface DiskSpaceInfo {
|
||||||
total?: number;
|
total?: number;
|
||||||
used?: number;
|
used?: number;
|
||||||
@@ -80,6 +95,7 @@ interface SearchArtistsFn {
|
|||||||
|
|
||||||
export default function MediaRequestForm() {
|
export default function MediaRequestForm() {
|
||||||
const [type, setType] = useState("artist");
|
const [type, setType] = useState("artist");
|
||||||
|
const [requestTab, setRequestTab] = useState<"music" | "videos" | "playlists">("music");
|
||||||
const [selectedArtist, setSelectedArtist] = useState<Artist | null>(null);
|
const [selectedArtist, setSelectedArtist] = useState<Artist | null>(null);
|
||||||
const [artistInput, setArtistInput] = useState("");
|
const [artistInput, setArtistInput] = useState("");
|
||||||
const [albumInput, setAlbumInput] = useState("");
|
const [albumInput, setAlbumInput] = useState("");
|
||||||
@@ -118,6 +134,17 @@ export default function MediaRequestForm() {
|
|||||||
const [showVideoSection, setShowVideoSection] = useState(false);
|
const [showVideoSection, setShowVideoSection] = useState(false);
|
||||||
const videoAbortRef = useRef<AbortController | null>(null);
|
const videoAbortRef = useRef<AbortController | null>(null);
|
||||||
|
|
||||||
|
// Playlist search state
|
||||||
|
const [playlistSearchQuery, setPlaylistSearchQuery] = useState("");
|
||||||
|
const [playlistResults, setPlaylistResults] = useState<Playlist[]>([]);
|
||||||
|
const [isPlaylistSearching, setIsPlaylistSearching] = useState(false);
|
||||||
|
const [selectedPlaylists, setSelectedPlaylists] = useState<Set<string | number>>(new Set());
|
||||||
|
const [showPlaylistSection, setShowPlaylistSection] = useState(false);
|
||||||
|
const [playlistTracksById, setPlaylistTracksById] = useState<Record<string | number, Track[]>>({});
|
||||||
|
const [selectedPlaylistTrackIds, setSelectedPlaylistTrackIds] = useState<Record<string | number, string[] | null>>({});
|
||||||
|
const [expandedPlaylists, setExpandedPlaylists] = useState<Set<string | number>>(new Set());
|
||||||
|
const [loadingPlaylistId, setLoadingPlaylistId] = useState<string | number | null>(null);
|
||||||
|
|
||||||
const resetVideoState = () => {
|
const resetVideoState = () => {
|
||||||
videoAbortRef.current?.abort();
|
videoAbortRef.current?.abort();
|
||||||
if (videoStreamUrl?.startsWith("blob:")) URL.revokeObjectURL(videoStreamUrl);
|
if (videoStreamUrl?.startsWith("blob:")) URL.revokeObjectURL(videoStreamUrl);
|
||||||
@@ -130,6 +157,17 @@ export default function MediaRequestForm() {
|
|||||||
setIsVideoLoading(false);
|
setIsVideoLoading(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const resetPlaylistState = () => {
|
||||||
|
setPlaylistResults([]);
|
||||||
|
setSelectedPlaylists(new Set());
|
||||||
|
setPlaylistSearchQuery("");
|
||||||
|
setIsPlaylistSearching(false);
|
||||||
|
setPlaylistTracksById({});
|
||||||
|
setSelectedPlaylistTrackIds({});
|
||||||
|
setExpandedPlaylists(new Set());
|
||||||
|
setLoadingPlaylistId(null);
|
||||||
|
};
|
||||||
|
|
||||||
const { attachScrollFix, cleanupScrollFix } = useAutoCompleteScrollFix();
|
const { attachScrollFix, cleanupScrollFix } = useAutoCompleteScrollFix();
|
||||||
|
|
||||||
const debounceTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const debounceTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
@@ -520,8 +558,12 @@ export default function MediaRequestForm() {
|
|||||||
toast.dismiss();
|
toast.dismiss();
|
||||||
setIsSearching(true);
|
setIsSearching(true);
|
||||||
resetQueueState();
|
resetQueueState();
|
||||||
const videosWereOpen = showVideoSection;
|
|
||||||
resetVideoState();
|
if (requestTab === "music") {
|
||||||
|
resetVideoState();
|
||||||
|
resetPlaylistState();
|
||||||
|
}
|
||||||
|
|
||||||
setShuffleAlbums({});
|
setShuffleAlbums({});
|
||||||
if (audioRef.current) {
|
if (audioRef.current) {
|
||||||
audioRef.current.pause();
|
audioRef.current.pause();
|
||||||
@@ -531,59 +573,80 @@ export default function MediaRequestForm() {
|
|||||||
setIsAudioPlaying(false);
|
setIsAudioPlaying(false);
|
||||||
setAudioLoadingTrackId(null);
|
setAudioLoadingTrackId(null);
|
||||||
setCurrentTrackId(null);
|
setCurrentTrackId(null);
|
||||||
try {
|
if (requestTab === "music") {
|
||||||
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 || "" });
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await authFetch(
|
if (metadataFetchToastId.current) toast.dismiss(metadataFetchToastId.current);
|
||||||
`${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);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error("Failed to fetch albums for artist.");
|
}
|
||||||
setAlbums([]);
|
metadataFetchToastId.current = toast.info("Retrieving metadata...",
|
||||||
setTracksByAlbum({});
|
{
|
||||||
setSelectedTracks({});
|
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") {
|
} else if (type === "album") {
|
||||||
if (!artistInput.trim() || !albumInput.trim()) {
|
if (!artistInput.trim() || !albumInput.trim()) {
|
||||||
@@ -750,8 +813,8 @@ export default function MediaRequestForm() {
|
|||||||
// VIDEO SEARCH & PLAYBACK
|
// VIDEO SEARCH & PLAYBACK
|
||||||
// ========================
|
// ========================
|
||||||
|
|
||||||
const searchVideos = async () => {
|
const searchVideos = async (overrideQuery?: string) => {
|
||||||
const query = videoSearchQuery.trim();
|
const query = (overrideQuery ?? videoSearchQuery).trim();
|
||||||
if (!query) {
|
if (!query) {
|
||||||
toast.error("Please enter a search query.");
|
toast.error("Please enter a search query.");
|
||||||
return;
|
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<string | number, string[] | null>, 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) => {
|
const toggleVideoSelection = (videoId: string | number) => {
|
||||||
setSelectedVideos((prev) => {
|
setSelectedVideos((prev) => {
|
||||||
const next = new Set(prev);
|
const next = new Set(prev);
|
||||||
@@ -1396,10 +1686,38 @@ export default function MediaRequestForm() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<h2 className="text-2xl sm:text-3xl font-bold tracking-tight mb-2">New Request</h2>
|
<h2 className="text-2xl sm:text-3xl font-bold tracking-tight mb-2">New Request</h2>
|
||||||
<p className="text-neutral-500 dark:text-neutral-400 text-sm mb-6">Search for an artist to browse and select tracks for download.</p>
|
<p className="text-neutral-500 dark:text-neutral-400 text-sm mb-4">
|
||||||
|
{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."}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="mb-6 inline-flex rounded-lg border border-neutral-300 dark:border-neutral-700 overflow-hidden">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setRequestTab("music")}
|
||||||
|
className={`px-4 py-2 text-sm font-medium ${requestTab === "music" ? "bg-blue-600 text-white" : "bg-white dark:bg-neutral-900 text-neutral-700 dark:text-neutral-300 hover:bg-neutral-100 dark:hover:bg-neutral-800"}`}
|
||||||
|
>
|
||||||
|
Music
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setRequestTab("videos")}
|
||||||
|
className={`px-4 py-2 text-sm font-medium border-l border-neutral-300 dark:border-neutral-700 ${requestTab === "videos" ? "bg-blue-600 text-white" : "bg-white dark:bg-neutral-900 text-neutral-700 dark:text-neutral-300 hover:bg-neutral-100 dark:hover:bg-neutral-800"}`}
|
||||||
|
>
|
||||||
|
Videos
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setRequestTab("playlists")}
|
||||||
|
className={`px-4 py-2 text-sm font-medium border-l border-neutral-300 dark:border-neutral-700 ${requestTab === "playlists" ? "bg-blue-600 text-white" : "bg-white dark:bg-neutral-900 text-neutral-700 dark:text-neutral-300 hover:bg-neutral-100 dark:hover:bg-neutral-800"}`}
|
||||||
|
>
|
||||||
|
Playlists
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-6">
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<label htmlFor="artistInput">Artist: </label>
|
<label htmlFor="artistInput">Artist{requestTab === "playlists" ? " (optional)" : ""}: </label>
|
||||||
<AutoComplete
|
<AutoComplete
|
||||||
id="artistInput"
|
id="artistInput"
|
||||||
ref={autoCompleteRef}
|
ref={autoCompleteRef}
|
||||||
@@ -1418,17 +1736,6 @@ export default function MediaRequestForm() {
|
|||||||
itemTemplate={artistItemTemplate}
|
itemTemplate={artistItemTemplate}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{(type === "album" || type === "track") && (
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
className="w-full dark:bg-neutral-800 dark:text-white border border-neutral-300 dark:border-neutral-600 rounded px-3 py-2"
|
|
||||||
value={type === "album" ? albumInput : trackInput}
|
|
||||||
onChange={(e) =>
|
|
||||||
type === "album" ? setAlbumInput(e.target.value) : setTrackInput(e.target.value)
|
|
||||||
}
|
|
||||||
placeholder={type === "album" ? "Album" : "Track"}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<div className="flex items-center gap-4 justify-end mt-2">
|
<div className="flex items-center gap-4 justify-end mt-2">
|
||||||
<label htmlFor="qualitySelect" className="text-sm font-medium">
|
<label htmlFor="qualitySelect" className="text-sm font-medium">
|
||||||
Quality:
|
Quality:
|
||||||
@@ -1448,16 +1755,18 @@ export default function MediaRequestForm() {
|
|||||||
{isSearching ? (
|
{isSearching ? (
|
||||||
<span className="flex items-center gap-2">
|
<span className="flex items-center gap-2">
|
||||||
<span className="animate-spin h-4 w-4 border-2 border-t-2 border-gray-200 border-t-primary rounded-full"></span>
|
<span className="animate-spin h-4 w-4 border-2 border-t-2 border-gray-200 border-t-primary rounded-full"></span>
|
||||||
Searching...
|
{requestTab === "music" ? "Searching..." : "Loading..."}
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
"Search"
|
requestTab === "music" ? "Search" : requestTab === "videos" ? "Load Videos" : "Load Playlists"
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{type === "artist" && albums.length > 0 && (
|
{type === "artist" && (
|
||||||
<>
|
<>
|
||||||
|
{requestTab === "music" && albums.length > 0 && (
|
||||||
|
<>
|
||||||
<div className="flex flex-col gap-2 mb-2 sm:flex-row sm:items-center sm:justify-between">
|
<div className="flex flex-col gap-2 mb-2 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<div className="text-sm text-neutral-600 dark:text-neutral-400 text-center sm:text-left">
|
<div className="text-sm text-neutral-600 dark:text-neutral-400 text-center sm:text-left">
|
||||||
<strong className="mr-2">Albums:</strong> {totalAlbums}
|
<strong className="mr-2">Albums:</strong> {totalAlbums}
|
||||||
@@ -1682,9 +1991,11 @@ export default function MediaRequestForm() {
|
|||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Videos Section - Show when artist is selected or videos are loaded */}
|
{/* Videos Section - Show when artist is selected or videos are loaded */}
|
||||||
{(selectedArtist || (showVideoSection && videoResults.length > 0)) && (
|
{requestTab === "videos" && (
|
||||||
<div className="mt-8 pt-6 border-t border-neutral-200 dark:border-neutral-700">
|
<div className="mt-8 pt-6 border-t border-neutral-200 dark:border-neutral-700">
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<h3 className="text-lg font-semibold flex items-center gap-2">
|
<h3 className="text-lg font-semibold flex items-center gap-2">
|
||||||
@@ -1737,7 +2048,7 @@ export default function MediaRequestForm() {
|
|||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={searchVideos}
|
onClick={() => searchVideos()}
|
||||||
disabled={isVideoSearching}
|
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"
|
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"
|
||||||
>
|
>
|
||||||
@@ -1922,6 +2233,238 @@ export default function MediaRequestForm() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Playlists Section */}
|
||||||
|
{requestTab === "playlists" && (
|
||||||
|
<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="M9 19V6l12-3v13M9 19c0 1.105-1.79 2-4 2s-4-.895-4-2 1.79-2 4-2 4 .895 4 2zm12-3c0 1.105-1.79 2-4 2s-4-.895-4-2 1.79-2 4-2 4 .895 4 2z" />
|
||||||
|
</svg>
|
||||||
|
Playlists
|
||||||
|
</h3>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`px-3 py-1.5 text-sm font-medium rounded-md border transition-colors
|
||||||
|
${showPlaylistSection
|
||||||
|
? '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 (!showPlaylistSection) {
|
||||||
|
const defaultQuery = selectedArtist?.artist || selectedArtist?.name || artistInput;
|
||||||
|
if (defaultQuery && !playlistSearchQuery) setPlaylistSearchQuery(defaultQuery);
|
||||||
|
}
|
||||||
|
setShowPlaylistSection(!showPlaylistSection);
|
||||||
|
}}
|
||||||
|
disabled={isPlaylistSearching}
|
||||||
|
>
|
||||||
|
{showPlaylistSection ? "Hide Playlists" : "Browse Playlists"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showPlaylistSection && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={playlistSearchQuery}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => searchPlaylists()}
|
||||||
|
disabled={isPlaylistSearching}
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
{isPlaylistSearching ? <InlineSpinner sizeClass="h-4 w-4" /> : "Search"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{playlistResults.length > 0 && (
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm text-neutral-600 dark:text-neutral-400">
|
||||||
|
<strong>{playlistResults.length}</strong> playlist{playlistResults.length !== 1 ? 's' : ''} found
|
||||||
|
</span>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<a
|
||||||
|
href="#"
|
||||||
|
role="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (selectedPlaylists.size === playlistResults.length) {
|
||||||
|
setSelectedPlaylists(new Set());
|
||||||
|
} else {
|
||||||
|
setSelectedPlaylists(new Set(playlistResults.map((p) => p.id)));
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="text-sm text-blue-600 hover:underline cursor-pointer"
|
||||||
|
>
|
||||||
|
{selectedPlaylists.size === playlistResults.length ? 'Uncheck All Playlists' : 'Check All Playlists'}
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="#"
|
||||||
|
role="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
toggleAllPlaylistTracks();
|
||||||
|
}}
|
||||||
|
className="text-sm text-blue-600 hover:underline cursor-pointer"
|
||||||
|
>
|
||||||
|
Check / Uncheck Loaded Tracks
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{playlistResults.length > 0 ? (
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||||
|
{playlistResults.map((playlist) => {
|
||||||
|
const title = getPlaylistTitle(playlist);
|
||||||
|
const trackCount = getPlaylistTrackCount(playlist);
|
||||||
|
const coverUrl = playlist.square_image_url || playlist.image || playlist.cover;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={playlist.id}
|
||||||
|
className={`rounded-lg border p-3 transition-all ${
|
||||||
|
selectedPlaylists.has(playlist.id)
|
||||||
|
? "border-blue-500 ring-2 ring-blue-500/30"
|
||||||
|
: "border-neutral-200 dark:border-neutral-700"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selectedPlaylists.has(playlist.id)}
|
||||||
|
onChange={() => togglePlaylistSelection(playlist.id)}
|
||||||
|
className="mt-1 cursor-pointer"
|
||||||
|
/>
|
||||||
|
{coverUrl ? (
|
||||||
|
<img
|
||||||
|
src={coverUrl}
|
||||||
|
alt={title}
|
||||||
|
className="w-14 h-14 rounded-md object-cover border border-neutral-200 dark:border-neutral-700 shrink-0"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="w-14 h-14 rounded-md bg-neutral-200 dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700 shrink-0 flex items-center justify-center text-neutral-500">
|
||||||
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19V6l12-3v13M9 19c0 1.105-1.79 2-4 2s-4-.895-4-2 1.79-2 4-2 4 .895 4 2zm12-3c0 1.105-1.79 2-4 2s-4-.895-4-2 1.79-2 4-2 4 .895 4 2z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => togglePlaylistExpanded(playlist.id)}
|
||||||
|
className="font-medium text-sm text-left w-full truncate hover:text-blue-600"
|
||||||
|
title={title}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</button>
|
||||||
|
{playlist.creator || playlist.owner ? (
|
||||||
|
<p className="text-xs text-neutral-500 truncate">by {playlist.creator || playlist.owner}</p>
|
||||||
|
) : null}
|
||||||
|
{playlist.description ? (
|
||||||
|
<p className="text-xs text-neutral-500 line-clamp-2 mt-1" title={playlist.description}>{playlist.description}</p>
|
||||||
|
) : null}
|
||||||
|
<div className="mt-1 text-xs text-neutral-500">
|
||||||
|
{trackCount !== null ? `${trackCount} tracks` : `ID: ${playlist.id}`}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{expandedPlaylists.has(playlist.id) && (
|
||||||
|
<div className="mt-3 border-t border-neutral-200 dark:border-neutral-700 pt-2 pl-7 sm:pl-0">
|
||||||
|
{loadingPlaylistId === playlist.id && (
|
||||||
|
<div className="text-xs text-neutral-500">Loading tracks...</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loadingPlaylistId !== playlist.id && (
|
||||||
|
<>
|
||||||
|
<div className="mb-2 flex items-center justify-between">
|
||||||
|
<span className="text-xs text-neutral-500">
|
||||||
|
{(playlistTracksById[playlist.id] || []).length} track{(playlistTracksById[playlist.id] || []).length !== 1 ? 's' : ''}
|
||||||
|
</span>
|
||||||
|
<a
|
||||||
|
href="#"
|
||||||
|
role="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
togglePlaylistAllTracks(playlist.id);
|
||||||
|
}}
|
||||||
|
className="text-xs text-blue-600 hover:underline"
|
||||||
|
>
|
||||||
|
Check / Uncheck All
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul className="max-h-56 overflow-y-auto space-y-1 pr-1" data-lenis-prevent>
|
||||||
|
{(playlistTracksById[playlist.id] || []).map((track) => (
|
||||||
|
<li key={track.id} className="flex items-start gap-2 text-xs rounded-md px-2 py-1.5 bg-neutral-100/70 dark:bg-neutral-800/70 border border-neutral-200 dark:border-neutral-700">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={(selectedPlaylistTrackIds[playlist.id] || []).includes(String(track.id))}
|
||||||
|
onChange={() => togglePlaylistTrack(playlist.id, track.id)}
|
||||||
|
className="mt-0.5 cursor-pointer"
|
||||||
|
/>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="text-neutral-800 dark:text-neutral-200 break-words leading-snug">
|
||||||
|
{track.artist ? `${track.artist} - ` : ''}{track.title}
|
||||||
|
</div>
|
||||||
|
<div className="text-[10px] text-neutral-500 mt-0.5 flex flex-wrap gap-2">
|
||||||
|
<span>ID: {track.id}</span>
|
||||||
|
{track.duration ? <span>{track.duration}</span> : null}
|
||||||
|
{track.track_number ? <span># {track.track_number}</span> : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : !isPlaylistSearching ? (
|
||||||
|
<div className="text-center py-8 text-neutral-500">
|
||||||
|
<p>No playlists found. Try a different query.</p>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{selectedPlaylists.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>{selectedPlaylists.size}</strong> playlist{selectedPlaylists.size !== 1 ? "s" : ""} selected
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleSubmitPlaylistRequest}
|
||||||
|
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 Playlist Request"
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,8 +65,8 @@ export default function RequestManagement() {
|
|||||||
// 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 isVideo
|
return isVideo
|
||||||
? `https://_music.codey.horse/TRIP/videos/${filename}`
|
? `https://trip.codey.horse/videos/${filename}`
|
||||||
: `https://_music.codey.horse/TRIP/${filename}`;
|
: `https://trip.codey.horse/${filename}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Otherwise, assume /storage/music2/completed/{quality} format
|
// Otherwise, assume /storage/music2/completed/{quality} format
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ export const RADIO_API_URL: string = "https://radio-api.codey.horse";
|
|||||||
export const socialLinks: Record<string, string> = {
|
export const socialLinks: Record<string, string> = {
|
||||||
};
|
};
|
||||||
|
|
||||||
export const MAJOR_VERSION: string = "0.8"
|
export const MAJOR_VERSION: string = "1.1"
|
||||||
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";
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { metaData } from "../config";
|
|||||||
import Nav from "./Nav.astro";
|
import Nav from "./Nav.astro";
|
||||||
import SubNav from "./SubNav.astro";
|
import SubNav from "./SubNav.astro";
|
||||||
import Footer from "../components/Footer.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/500.css";
|
||||||
import "@fontsource/ibm-plex-sans/600.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
|
// request locals.isSubsite, which we trust here, but as a fallback also use
|
||||||
// the presence of a whitelabel mapping or a detected subsite path.
|
// the presence of a whitelabel mapping or a detected subsite path.
|
||||||
const isSubsite = (Astro.request as any)?.locals?.isSubsite ?? Boolean(whitelabel || detectedSubsite);
|
const isSubsite = (Astro.request as any)?.locals?.isSubsite ?? Boolean(whitelabel || detectedSubsite);
|
||||||
|
const normalizedPath = (Astro.url.pathname || '/').replace(/\/+$/, '') || '/';
|
||||||
|
const isRadioPage = normalizedPath === '/radio';
|
||||||
|
|
||||||
// Debug logging
|
// Debug logging
|
||||||
if (import.meta.env.DEV) {
|
if (import.meta.env.DEV) {
|
||||||
@@ -94,7 +97,7 @@ if (import.meta.env.DEV) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<main
|
<main
|
||||||
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-8">
|
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">
|
||||||
<noscript>
|
<noscript>
|
||||||
<div style="background: #f44336; color: white; padding: 1em; text-align: center;">
|
<div style="background: #f44336; color: white; padding: 1em; text-align: center;">
|
||||||
This site requires JavaScript to function. Please enable JavaScript in your browser.
|
This site requires JavaScript to function. Please enable JavaScript in your browser.
|
||||||
@@ -103,6 +106,7 @@ if (import.meta.env.DEV) {
|
|||||||
<slot />
|
<slot />
|
||||||
{!hideFooter && <Footer />}
|
{!hideFooter && <Footer />}
|
||||||
</main>
|
</main>
|
||||||
|
{!isSubsite && !isRadioPage && <MiniRadioPlayer client:only="react" />}
|
||||||
<style>
|
<style>
|
||||||
/* CSS rules for the page scrollbar */
|
/* CSS rules for the page scrollbar */
|
||||||
.scrollbar-hide::-webkit-scrollbar {
|
.scrollbar-hide::-webkit-scrollbar {
|
||||||
|
|||||||
@@ -26,7 +26,6 @@ const baseNavItems: NavItem[] = [
|
|||||||
{ label: "Home", href: "/" },
|
{ label: "Home", href: "/" },
|
||||||
{ label: "Radio", href: "/radio" },
|
{ label: "Radio", href: "/radio" },
|
||||||
{ label: "Memes", href: "/memes" },
|
{ label: "Memes", href: "/memes" },
|
||||||
{ label: "Git", href: "https://kode.boatson.boats", icon: "external" },
|
|
||||||
{
|
{
|
||||||
label: "TRip",
|
label: "TRip",
|
||||||
href: "/TRip",
|
href: "/TRip",
|
||||||
@@ -53,6 +52,7 @@ const baseNavItems: NavItem[] = [
|
|||||||
{ label: "RI", href: "https://_r0.codey.horse", auth: true, icon: "external", adminOnly: true },
|
{ label: "RI", href: "https://_r0.codey.horse", auth: true, icon: "external", adminOnly: true },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{ label: "Git", href: "https://kode.boatson.boats", icon: "external" },
|
||||||
{ label: "Login", href: "/login", guestOnly: true },
|
{ label: "Login", href: "/login", guestOnly: true },
|
||||||
...(isLoggedIn ? [{ label: "Logout", href: "#logout", onclick: "handleLogout()" }] : []),
|
...(isLoggedIn ? [{ label: "Logout", href: "#logout", onclick: "handleLogout()" }] : []),
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -14,7 +14,9 @@ const user = Astro.locals.user as any;
|
|||||||
|
|
||||||
<style is:global>
|
<style is:global>
|
||||||
/* Override main container width for TRip pages */
|
/* Override main container width for TRip pages */
|
||||||
body:has(.trip-section) main {
|
html:has(.trip-section) main {
|
||||||
max-width: 1400px !important;
|
max-width: 1400px !important;
|
||||||
|
width: 100% !important;
|
||||||
|
padding-bottom: 7rem !important;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ const user = Astro.locals.user as any;
|
|||||||
---
|
---
|
||||||
<Base title="TRip Requests" description="TRip Requests / Status">
|
<Base title="TRip Requests" description="TRip Requests / Status">
|
||||||
<section class="page-section trip-section" transition:animate="none">
|
<section class="page-section trip-section" transition:animate="none">
|
||||||
<Root child="qs2.RequestManagement" client:only="react" transition:persist />
|
<Root child="qs2.RequestManagement" client:only="react" />
|
||||||
</section>
|
</section>
|
||||||
</Base>
|
</Base>
|
||||||
|
|
||||||
@@ -17,5 +17,6 @@ const user = Astro.locals.user as any;
|
|||||||
html:has(.trip-section) main {
|
html:has(.trip-section) main {
|
||||||
max-width: 1400px !important;
|
max-width: 1400px !important;
|
||||||
width: 100% !important;
|
width: 100% !important;
|
||||||
|
padding-bottom: 7rem !important;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
38
src/utils/hlsConfig.ts
Normal file
38
src/utils/hlsConfig.ts
Normal file
@@ -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<HlsConfig> = {
|
||||||
|
// 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,
|
||||||
|
};
|
||||||
134
src/utils/liveCatchup.ts
Normal file
134
src/utils/liveCatchup.ts
Normal file
@@ -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<Hls, CatchupController>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<typeof setInterval> | null = null;
|
||||||
|
let stopTimerId: ReturnType<typeof setTimeout> | 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();
|
||||||
|
}
|
||||||
577
src/utils/radioState.ts
Normal file
577
src/utils/radioState.ts
Normal file
@@ -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<string, any>;
|
||||||
|
};
|
||||||
|
|
||||||
|
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<GlobalRadioState> | 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<string, any>): 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<HTMLMediaElement>;
|
||||||
|
|
||||||
|
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<void>).catch === "function") {
|
||||||
|
(playPromise as Promise<void>).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<RadioSyncMessage>) => {
|
||||||
|
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<string, any>): 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<string, Set<RadioEventCallback>> = 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;
|
||||||
Reference in New Issue
Block a user