dev #6

Merged
codey merged 6 commits from dev into master 2026-02-27 10:43:39 -05:00
16 changed files with 3449 additions and 430 deletions
Showing only changes of commit 4c93a51cc7 - Show all commits

View File

@@ -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 {

View File

@@ -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;

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -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 } }));
}
}
};

View File

@@ -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"

View File

@@ -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>
)}
</>
)
}

View File

@@ -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

View File

@@ -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";

View File

@@ -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 {

View File

@@ -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()" }] : []),
];

View File

@@ -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>

View File

@@ -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
View 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
View 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
View 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;