- Introduced a shared HLS configuration in hlsConfig.ts to standardize playback settings across players.
- Implemented a one-shot live catch-up mechanism in `liveCatchup.ts` to enhance user experience during live streaming. - Created a global radio state management system in `radioState.ts` to maintain playback continuity and metadata across different components and tabs. - Bumped version 1.0 -> 1.1
This commit is contained in:
@@ -178,9 +178,8 @@ blockquote p:first-of-type::after {
|
||||
text-wrap: balance;
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none;
|
||||
}
|
||||
/* Removed .hidden { display: none; } - Tailwind already provides this utility
|
||||
and the custom rule was breaking responsive variants like sm:flex */
|
||||
|
||||
[data-theme="dark"] .astro-code,
|
||||
[data-theme="dark"] .astro-code span {
|
||||
|
||||
@@ -21,8 +21,8 @@ const envBadge = ENVIRONMENT === 'Dev' ? 'DEV' : null;
|
||||
{!whitelabel && <RandomMsg client:only="react" />}
|
||||
<div class="footer-version" data-build-time={buildTime} title={`Built: ${buildTime}`}>
|
||||
<span class="version-pill">
|
||||
{envBadge && <span class="env-dot" title="Development build"></span>}
|
||||
{!envBadge && <span class="version-dot"></span>}
|
||||
{envBadge && <span class="env-dot api-status-dot" title="Development build"></span>}
|
||||
{!envBadge && <span class="version-dot api-status-dot"></span>}
|
||||
<span class="version-text">{versionDisplay}:{buildNumber}</span>
|
||||
<span class="build-time-text" aria-hidden="true"></span>
|
||||
</span>
|
||||
@@ -30,6 +30,34 @@ const envBadge = ENVIRONMENT === 'Dev' ? 'DEV' : null;
|
||||
</div>
|
||||
|
||||
<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() {
|
||||
const el = document.querySelector('.footer-version[data-build-time]');
|
||||
if (!el) return;
|
||||
@@ -60,6 +88,8 @@ const envBadge = ENVIRONMENT === 'Dev' ? 'DEV' : null;
|
||||
}
|
||||
}
|
||||
|
||||
bindApiStatusListener();
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', initBuildTooltip);
|
||||
} else {
|
||||
@@ -144,6 +174,11 @@ const envBadge = ENVIRONMENT === 'Dev' ? 'DEV' : null;
|
||||
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 {
|
||||
width: 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}`);
|
||||
const data = await response.json();
|
||||
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) {
|
||||
console.error("Failed to fetch random message:", err);
|
||||
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}>
|
||||
<a
|
||||
href={href}
|
||||
data-astro-reload
|
||||
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"
|
||||
: "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
|
||||
}
|
||||
|
||||
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 {
|
||||
total?: number;
|
||||
used?: number;
|
||||
@@ -80,6 +95,7 @@ interface SearchArtistsFn {
|
||||
|
||||
export default function MediaRequestForm() {
|
||||
const [type, setType] = useState("artist");
|
||||
const [requestTab, setRequestTab] = useState<"music" | "videos" | "playlists">("music");
|
||||
const [selectedArtist, setSelectedArtist] = useState<Artist | null>(null);
|
||||
const [artistInput, setArtistInput] = useState("");
|
||||
const [albumInput, setAlbumInput] = useState("");
|
||||
@@ -118,6 +134,17 @@ export default function MediaRequestForm() {
|
||||
const [showVideoSection, setShowVideoSection] = useState(false);
|
||||
const videoAbortRef = useRef<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 = () => {
|
||||
videoAbortRef.current?.abort();
|
||||
if (videoStreamUrl?.startsWith("blob:")) URL.revokeObjectURL(videoStreamUrl);
|
||||
@@ -130,6 +157,17 @@ export default function MediaRequestForm() {
|
||||
setIsVideoLoading(false);
|
||||
};
|
||||
|
||||
const resetPlaylistState = () => {
|
||||
setPlaylistResults([]);
|
||||
setSelectedPlaylists(new Set());
|
||||
setPlaylistSearchQuery("");
|
||||
setIsPlaylistSearching(false);
|
||||
setPlaylistTracksById({});
|
||||
setSelectedPlaylistTrackIds({});
|
||||
setExpandedPlaylists(new Set());
|
||||
setLoadingPlaylistId(null);
|
||||
};
|
||||
|
||||
const { attachScrollFix, cleanupScrollFix } = useAutoCompleteScrollFix();
|
||||
|
||||
const debounceTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
@@ -520,8 +558,12 @@ export default function MediaRequestForm() {
|
||||
toast.dismiss();
|
||||
setIsSearching(true);
|
||||
resetQueueState();
|
||||
const videosWereOpen = showVideoSection;
|
||||
resetVideoState();
|
||||
|
||||
if (requestTab === "music") {
|
||||
resetVideoState();
|
||||
resetPlaylistState();
|
||||
}
|
||||
|
||||
setShuffleAlbums({});
|
||||
if (audioRef.current) {
|
||||
audioRef.current.pause();
|
||||
@@ -531,59 +573,80 @@ export default function MediaRequestForm() {
|
||||
setIsAudioPlaying(false);
|
||||
setAudioLoadingTrackId(null);
|
||||
setCurrentTrackId(null);
|
||||
try {
|
||||
if (metadataFetchToastId.current) toast.dismiss(metadataFetchToastId.current);
|
||||
} catch (err) {
|
||||
}
|
||||
metadataFetchToastId.current = toast.info("Retrieving metadata...",
|
||||
{
|
||||
autoClose: false,
|
||||
progress: 0,
|
||||
closeOnClick: false,
|
||||
}
|
||||
);
|
||||
if (type === "artist") {
|
||||
if (!selectedArtist) {
|
||||
toast.error("Please select a valid artist from suggestions.");
|
||||
setIsSearching(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectedItem({ ...selectedArtist, name: selectedArtist.artist || selectedArtist.name || "" });
|
||||
|
||||
if (requestTab === "music") {
|
||||
try {
|
||||
const res = await authFetch(
|
||||
`${API_URL}/trip/get_albums_by_artist_id/${selectedArtist.id}?quality=${quality}`
|
||||
);
|
||||
if (!res.ok) throw new Error("API error");
|
||||
const data = await res.json();
|
||||
|
||||
data.sort((a, b) =>
|
||||
(b.release_date || "").localeCompare(a.release_date || "")
|
||||
);
|
||||
|
||||
setAlbums(data);
|
||||
setShuffleAlbums({});
|
||||
setTracksByAlbum({});
|
||||
setExpandedAlbums([]);
|
||||
|
||||
// Set selectedTracks for all albums as null (means tracks loading/not loaded)
|
||||
setSelectedTracks(
|
||||
data.reduce((acc, album) => {
|
||||
acc[album.id] = null;
|
||||
return acc;
|
||||
}, {})
|
||||
);
|
||||
|
||||
// If the video section was open before, auto-fetch videos for the new artist
|
||||
if (videosWereOpen && selectedArtist) {
|
||||
fetchArtistVideos(selectedArtist.id);
|
||||
}
|
||||
if (metadataFetchToastId.current) toast.dismiss(metadataFetchToastId.current);
|
||||
} catch (err) {
|
||||
toast.error("Failed to fetch albums for artist.");
|
||||
setAlbums([]);
|
||||
setTracksByAlbum({});
|
||||
setSelectedTracks({});
|
||||
}
|
||||
metadataFetchToastId.current = toast.info("Retrieving metadata...",
|
||||
{
|
||||
autoClose: false,
|
||||
progress: 0,
|
||||
closeOnClick: false,
|
||||
}
|
||||
);
|
||||
}
|
||||
if (type === "artist") {
|
||||
if (requestTab === "music") {
|
||||
if (!selectedArtist) {
|
||||
toast.error("Please select a valid artist from suggestions.");
|
||||
setIsSearching(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectedItem({ ...selectedArtist, name: selectedArtist.artist || selectedArtist.name || "" });
|
||||
|
||||
try {
|
||||
const res = await authFetch(
|
||||
`${API_URL}/trip/get_albums_by_artist_id/${selectedArtist.id}?quality=${quality}`
|
||||
);
|
||||
if (!res.ok) throw new Error("API error");
|
||||
const data = await res.json();
|
||||
|
||||
data.sort((a, b) =>
|
||||
(b.release_date || "").localeCompare(a.release_date || "")
|
||||
);
|
||||
|
||||
setAlbums(data);
|
||||
setShuffleAlbums({});
|
||||
setTracksByAlbum({});
|
||||
setExpandedAlbums([]);
|
||||
|
||||
setSelectedTracks(
|
||||
data.reduce((acc, album) => {
|
||||
acc[album.id] = null;
|
||||
return acc;
|
||||
}, {})
|
||||
);
|
||||
} catch (err) {
|
||||
toast.error("Failed to fetch albums for artist.");
|
||||
setAlbums([]);
|
||||
setTracksByAlbum({});
|
||||
setSelectedTracks({});
|
||||
}
|
||||
} else if (requestTab === "videos") {
|
||||
setShowVideoSection(true);
|
||||
if (selectedArtist) {
|
||||
await fetchArtistVideos(selectedArtist.id);
|
||||
} else {
|
||||
const fallbackQuery = videoSearchQuery.trim() || artistInput.trim();
|
||||
if (!fallbackQuery) {
|
||||
toast.error("Select an artist or enter a video search query.");
|
||||
setIsSearching(false);
|
||||
return;
|
||||
}
|
||||
await searchVideos(fallbackQuery);
|
||||
}
|
||||
} else if (requestTab === "playlists") {
|
||||
setShowPlaylistSection(true);
|
||||
const fallbackQuery = playlistSearchQuery.trim() || selectedArtist?.artist || selectedArtist?.name || artistInput.trim();
|
||||
if (!fallbackQuery) {
|
||||
toast.error("Select an artist or enter a playlist search query.");
|
||||
setIsSearching(false);
|
||||
return;
|
||||
}
|
||||
if (!playlistSearchQuery.trim()) setPlaylistSearchQuery(fallbackQuery);
|
||||
await searchPlaylists(fallbackQuery);
|
||||
}
|
||||
} else if (type === "album") {
|
||||
if (!artistInput.trim() || !albumInput.trim()) {
|
||||
@@ -750,8 +813,8 @@ export default function MediaRequestForm() {
|
||||
// VIDEO SEARCH & PLAYBACK
|
||||
// ========================
|
||||
|
||||
const searchVideos = async () => {
|
||||
const query = videoSearchQuery.trim();
|
||||
const searchVideos = async (overrideQuery?: string) => {
|
||||
const query = (overrideQuery ?? videoSearchQuery).trim();
|
||||
if (!query) {
|
||||
toast.error("Please enter a search query.");
|
||||
return;
|
||||
@@ -801,6 +864,233 @@ export default function MediaRequestForm() {
|
||||
}
|
||||
};
|
||||
|
||||
const normalizePlaylistsResponse = (data: any): Playlist[] => {
|
||||
if (Array.isArray(data)) return data;
|
||||
if (Array.isArray(data?.playlists)) return data.playlists;
|
||||
if (Array.isArray(data?.items)) return data.items;
|
||||
if (Array.isArray(data?.results)) return data.results;
|
||||
return [];
|
||||
};
|
||||
|
||||
const getPlaylistTitle = (playlist: Playlist) => {
|
||||
return playlist.title || playlist.name || `Playlist ${playlist.id}`;
|
||||
};
|
||||
|
||||
const getPlaylistTrackCount = (playlist: Playlist) => {
|
||||
return playlist.number_of_tracks ?? playlist.tracks_count ?? playlist.total_tracks ?? null;
|
||||
};
|
||||
|
||||
const normalizeApiId = (value: string | number) => {
|
||||
if (typeof value === "number") return value;
|
||||
const trimmed = String(value).trim();
|
||||
return /^\d+$/.test(trimmed) ? Number(trimmed) : trimmed;
|
||||
};
|
||||
|
||||
const searchPlaylists = async (overrideQuery?: string) => {
|
||||
const query = (overrideQuery ?? playlistSearchQuery).trim();
|
||||
if (!query) {
|
||||
toast.error("Please enter a playlist search query.");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsPlaylistSearching(true);
|
||||
setPlaylistResults([]);
|
||||
setSelectedPlaylists(new Set());
|
||||
|
||||
try {
|
||||
const res = await authFetch(`${API_URL}/trip/playlists/search?q=${encodeURIComponent(query)}&limit=50`);
|
||||
if (!res.ok) throw new Error("API error");
|
||||
const data = await res.json();
|
||||
const playlists = normalizePlaylistsResponse(data);
|
||||
setPlaylistResults(playlists);
|
||||
setPlaylistTracksById({});
|
||||
setExpandedPlaylists(new Set());
|
||||
setSelectedPlaylistTrackIds(
|
||||
playlists.reduce((acc: Record<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) => {
|
||||
setSelectedVideos((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>
|
||||
<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-4">
|
||||
<label htmlFor="artistInput">Artist: </label>
|
||||
<label htmlFor="artistInput">Artist{requestTab === "playlists" ? " (optional)" : ""}: </label>
|
||||
<AutoComplete
|
||||
id="artistInput"
|
||||
ref={autoCompleteRef}
|
||||
@@ -1418,17 +1736,6 @@ export default function MediaRequestForm() {
|
||||
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">
|
||||
<label htmlFor="qualitySelect" className="text-sm font-medium">
|
||||
Quality:
|
||||
@@ -1448,16 +1755,18 @@ export default function MediaRequestForm() {
|
||||
{isSearching ? (
|
||||
<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>
|
||||
Searching...
|
||||
{requestTab === "music" ? "Searching..." : "Loading..."}
|
||||
</span>
|
||||
) : (
|
||||
"Search"
|
||||
requestTab === "music" ? "Search" : requestTab === "videos" ? "Load Videos" : "Load Playlists"
|
||||
)}
|
||||
</Button>
|
||||
</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="text-sm text-neutral-600 dark:text-neutral-400 text-center sm:text-left">
|
||||
<strong className="mr-2">Albums:</strong> {totalAlbums}
|
||||
@@ -1682,9 +1991,11 @@ export default function MediaRequestForm() {
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 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="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold flex items-center gap-2">
|
||||
@@ -1737,7 +2048,7 @@ export default function MediaRequestForm() {
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={searchVideos}
|
||||
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"
|
||||
>
|
||||
@@ -1922,6 +2233,238 @@ export default function MediaRequestForm() {
|
||||
)}
|
||||
</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
|
||||
if (absPath.includes("/storage/music/TRIP/")) {
|
||||
return isVideo
|
||||
? `https://_music.codey.horse/TRIP/videos/${filename}`
|
||||
: `https://_music.codey.horse/TRIP/${filename}`;
|
||||
? `https://trip.codey.horse/videos/${filename}`
|
||||
: `https://trip.codey.horse/${filename}`;
|
||||
}
|
||||
|
||||
// Otherwise, assume /storage/music2/completed/{quality} format
|
||||
|
||||
@@ -63,7 +63,7 @@ export const RADIO_API_URL: string = "https://radio-api.codey.horse";
|
||||
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 ENVIRONMENT: "Dev" | "Prod" = import.meta.env.DEV ? "Dev" : "Prod";
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import { metaData } from "../config";
|
||||
import Nav from "./Nav.astro";
|
||||
import SubNav from "./SubNav.astro";
|
||||
import Footer from "../components/Footer.astro";
|
||||
import MiniRadioPlayer from "../components/MiniRadioPlayer.tsx";
|
||||
|
||||
import "@fontsource/ibm-plex-sans/500.css";
|
||||
import "@fontsource/ibm-plex-sans/600.css";
|
||||
@@ -57,6 +58,8 @@ if (!whitelabel) {
|
||||
// request locals.isSubsite, which we trust here, but as a fallback also use
|
||||
// the presence of a whitelabel mapping or a detected subsite path.
|
||||
const isSubsite = (Astro.request as any)?.locals?.isSubsite ?? Boolean(whitelabel || detectedSubsite);
|
||||
const normalizedPath = (Astro.url.pathname || '/').replace(/\/+$/, '') || '/';
|
||||
const isRadioPage = normalizedPath === '/radio';
|
||||
|
||||
// Debug logging
|
||||
if (import.meta.env.DEV) {
|
||||
@@ -94,7 +97,7 @@ if (import.meta.env.DEV) {
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<div style="background: #f44336; color: white; padding: 1em; text-align: center;">
|
||||
This site requires JavaScript to function. Please enable JavaScript in your browser.
|
||||
@@ -103,6 +106,7 @@ if (import.meta.env.DEV) {
|
||||
<slot />
|
||||
{!hideFooter && <Footer />}
|
||||
</main>
|
||||
{!isSubsite && !isRadioPage && <MiniRadioPlayer client:only="react" />}
|
||||
<style>
|
||||
/* CSS rules for the page scrollbar */
|
||||
.scrollbar-hide::-webkit-scrollbar {
|
||||
|
||||
@@ -26,7 +26,6 @@ const baseNavItems: NavItem[] = [
|
||||
{ label: "Home", href: "/" },
|
||||
{ label: "Radio", href: "/radio" },
|
||||
{ label: "Memes", href: "/memes" },
|
||||
{ label: "Git", href: "https://kode.boatson.boats", icon: "external" },
|
||||
{
|
||||
label: "TRip",
|
||||
href: "/TRip",
|
||||
@@ -53,6 +52,7 @@ const baseNavItems: NavItem[] = [
|
||||
{ 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 },
|
||||
...(isLoggedIn ? [{ label: "Logout", href: "#logout", onclick: "handleLogout()" }] : []),
|
||||
];
|
||||
|
||||
@@ -14,7 +14,9 @@ const user = Astro.locals.user as any;
|
||||
|
||||
<style is:global>
|
||||
/* Override main container width for TRip pages */
|
||||
body:has(.trip-section) main {
|
||||
html:has(.trip-section) main {
|
||||
max-width: 1400px !important;
|
||||
width: 100% !important;
|
||||
padding-bottom: 7rem !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -8,7 +8,7 @@ const user = Astro.locals.user as any;
|
||||
---
|
||||
<Base title="TRip Requests" description="TRip Requests / Status">
|
||||
<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>
|
||||
</Base>
|
||||
|
||||
@@ -17,5 +17,6 @@ const user = Astro.locals.user as any;
|
||||
html:has(.trip-section) main {
|
||||
max-width: 1400px !important;
|
||||
width: 100% !important;
|
||||
padding-bottom: 7rem !important;
|
||||
}
|
||||
</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