Compare commits
10 Commits
b52a65ea6b
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| a59074fde8 | |||
| 4c93a51cc7 | |||
| 8263d582a6 | |||
| c0ae5fdebb | |||
| ef15b646cc | |||
| b5bf5fd5a7 | |||
| 94166904f7 | |||
| 319f480f12 | |||
| 433dbadec3 | |||
| 4da8ba5dad |
@@ -1,5 +1,5 @@
|
||||
# codey.lol
|
||||
codey.lol is a web app built with Astro, TypeScript, Tailwind CSS, React, and Vite.
|
||||
# codey.horse
|
||||
codey.horse is a web app built with Astro, TypeScript, Tailwind CSS, React, and Vite.
|
||||
|
||||
## Pages Overview
|
||||
|
||||
|
||||
@@ -30,6 +30,16 @@
|
||||
}
|
||||
};
|
||||
|
||||
function setLenisEnabled(enabled) {
|
||||
const lenis = window.__lenis;
|
||||
if (!lenis) return;
|
||||
if (enabled) {
|
||||
lenis.start();
|
||||
} else {
|
||||
lenis.stop();
|
||||
}
|
||||
}
|
||||
|
||||
function initMobileMenu() {
|
||||
const menuBtn = document.getElementById("mobile-menu-btn");
|
||||
const mobileMenu = document.getElementById("mobile-menu");
|
||||
@@ -51,10 +61,12 @@
|
||||
mobileMenu.classList.remove("open");
|
||||
menuIcon.style.display = "block";
|
||||
closeIcon.style.display = "none";
|
||||
setLenisEnabled(true);
|
||||
} else {
|
||||
mobileMenu.classList.add("open");
|
||||
menuIcon.style.display = "none";
|
||||
closeIcon.style.display = "block";
|
||||
setLenisEnabled(false);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -64,6 +76,7 @@
|
||||
mobileMenu.classList.remove("open");
|
||||
menuIcon.style.display = "block";
|
||||
closeIcon.style.display = "none";
|
||||
setLenisEnabled(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -73,6 +86,7 @@
|
||||
mobileMenu.classList.remove("open");
|
||||
menuIcon.style.display = "block";
|
||||
closeIcon.style.display = "none";
|
||||
setLenisEnabled(true);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -81,7 +95,93 @@
|
||||
document.addEventListener("click", closeHandler);
|
||||
}
|
||||
|
||||
const ready = () => initMobileMenu();
|
||||
function initDropdownExternalLinks() {
|
||||
// Close desktop dropdown when external links are clicked
|
||||
const dropdownLinks = document.querySelectorAll('.nav-dropdown a[target="_blank"]');
|
||||
dropdownLinks.forEach((link) => {
|
||||
link.addEventListener("click", () => {
|
||||
// Blur the parent nav item to close the CSS hover-based dropdown
|
||||
const navItem = link.closest('.nav-item--has-children');
|
||||
if (navItem) {
|
||||
const parentLink = navItem.querySelector(':scope > a');
|
||||
if (parentLink) {
|
||||
parentLink.blur();
|
||||
}
|
||||
// Force close by temporarily disabling pointer events
|
||||
navItem.style.pointerEvents = 'none';
|
||||
setTimeout(() => {
|
||||
navItem.style.pointerEvents = '';
|
||||
}, 100);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function initDesktopDropdownToggle() {
|
||||
// Desktop: click parent to toggle dropdown open/closed
|
||||
const desktopNav = document.querySelector('nav .hidden.md\\:flex');
|
||||
if (!desktopNav) return;
|
||||
|
||||
const parentItems = desktopNav.querySelectorAll('.nav-item--has-children');
|
||||
parentItems.forEach((navItem) => {
|
||||
const parentLink = navItem.querySelector(':scope > a');
|
||||
if (!parentLink) return;
|
||||
|
||||
parentLink.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
const isOpen = navItem.classList.contains('dropdown-open');
|
||||
|
||||
// Close any other open dropdowns
|
||||
parentItems.forEach((item) => item.classList.remove('dropdown-open'));
|
||||
|
||||
if (!isOpen) {
|
||||
navItem.classList.add('dropdown-open');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Close dropdown when clicking outside
|
||||
document.addEventListener('click', (e) => {
|
||||
if (!desktopNav.contains(e.target)) {
|
||||
parentItems.forEach((item) => item.classList.remove('dropdown-open'));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function initMobileSubmenus() {
|
||||
const mobileMenu = document.getElementById("mobile-menu");
|
||||
if (!mobileMenu) return;
|
||||
|
||||
// Find all parent links that have a sibling mobile-subnav
|
||||
const parentLinks = mobileMenu.querySelectorAll('a:has(+ .mobile-subnav)');
|
||||
|
||||
parentLinks.forEach((link) => {
|
||||
const subnav = link.nextElementSibling;
|
||||
const caret = link.querySelector('.mobile-caret');
|
||||
|
||||
if (!subnav || !subnav.classList.contains('mobile-subnav')) return;
|
||||
|
||||
// Clone to remove existing listeners
|
||||
const newLink = link.cloneNode(true);
|
||||
link.parentNode.replaceChild(newLink, link);
|
||||
|
||||
const newCaret = newLink.querySelector('.mobile-caret');
|
||||
|
||||
newLink.addEventListener('click', (e) => {
|
||||
// Toggle subnav open/closed on click
|
||||
e.preventDefault();
|
||||
subnav.classList.toggle('open');
|
||||
if (newCaret) newCaret.classList.toggle('open');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const ready = () => {
|
||||
initMobileMenu();
|
||||
initDropdownExternalLinks();
|
||||
initDesktopDropdownToggle();
|
||||
initMobileSubmenus();
|
||||
};
|
||||
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", ready, { once: true });
|
||||
@@ -89,5 +189,10 @@
|
||||
ready();
|
||||
}
|
||||
|
||||
document.addEventListener("astro:page-load", initMobileMenu);
|
||||
document.addEventListener("astro:page-load", () => {
|
||||
initMobileMenu();
|
||||
initDropdownExternalLinks();
|
||||
initDesktopDropdownToggle();
|
||||
initMobileSubmenus();
|
||||
});
|
||||
})();
|
||||
|
||||
@@ -11,3 +11,6 @@ function raf(time) {
|
||||
}
|
||||
|
||||
requestAnimationFrame(raf);
|
||||
|
||||
// Expose lenis instance globally for nav controls
|
||||
(window as any).__lenis = lenis;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -29,8 +29,9 @@
|
||||
}
|
||||
|
||||
.mobile-menu-dropdown.open {
|
||||
max-height: none;
|
||||
overflow: visible;
|
||||
max-height: calc(100vh - 4rem);
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
padding-bottom: 0.75rem;
|
||||
opacity: 1;
|
||||
}
|
||||
@@ -83,6 +84,70 @@ nav {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.nav-item--has-children .nav-caret {
|
||||
margin-left: 0.25rem;
|
||||
font-size: 0.85rem;
|
||||
opacity: 0.7;
|
||||
transition: transform 0.15s ease;
|
||||
}
|
||||
|
||||
.nav-item--has-children:hover .nav-caret,
|
||||
.nav-item--has-children:focus-within .nav-caret {
|
||||
transform: translateY(1px) rotate(180deg);
|
||||
}
|
||||
|
||||
.nav-dropdown {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
margin-top: 0.2rem;
|
||||
left: 0;
|
||||
min-width: 12.5rem;
|
||||
background: rgba(255, 255, 255, 0.98);
|
||||
border: 1px solid rgba(226, 232, 240, 0.9);
|
||||
border-radius: 0.75rem;
|
||||
box-shadow: 0 14px 45px rgba(0, 0, 0, 0.12), 0 10px 18px rgba(0, 0, 0, 0.08);
|
||||
padding: 0.35rem;
|
||||
opacity: 0;
|
||||
transform: translateY(4px);
|
||||
pointer-events: none;
|
||||
transition: opacity 0.2s ease, transform 0.2s ease;
|
||||
z-index: 30;
|
||||
}
|
||||
|
||||
.nav-item--has-children .nav-dropdown::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: -10px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 12px;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .nav-dropdown {
|
||||
background: rgba(17, 24, 39, 0.98);
|
||||
border-color: rgba(75, 85, 99, 0.6);
|
||||
box-shadow: 0 14px 45px rgba(0, 0, 0, 0.45), 0 10px 18px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.nav-dropdown ul {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.15rem;
|
||||
}
|
||||
|
||||
.nav-item--has-children:hover .nav-dropdown,
|
||||
.nav-item--has-children:focus-within .nav-dropdown,
|
||||
.nav-item--has-children.dropdown-open .nav-dropdown {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.desktop-nav-list a {
|
||||
white-space: nowrap;
|
||||
padding: 0.375rem 0.625rem;
|
||||
@@ -170,3 +235,26 @@ nav {
|
||||
border-color: rgba(248, 113, 113, 0.6);
|
||||
box-shadow: 0 2px 8px rgba(239, 68, 68, 0.3), inset 0 1px 0 rgba(239, 68, 68, 0.1);
|
||||
}
|
||||
|
||||
.mobile-subnav {
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
gap: 0.15rem;
|
||||
margin: 0.2rem 0 0.35rem;
|
||||
padding-left: 0.75rem;
|
||||
}
|
||||
|
||||
.mobile-subnav.open {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.mobile-caret {
|
||||
margin-left: auto;
|
||||
font-size: 0.95rem;
|
||||
opacity: 0.55;
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.mobile-caret.open {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,38 @@
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* Marquee animation for long text */
|
||||
@keyframes marquee {
|
||||
0% {
|
||||
transform: translateX(0);
|
||||
}
|
||||
100% {
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
}
|
||||
|
||||
.marquee-wrapper {
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.marquee-content {
|
||||
display: inline-block;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.marquee-content.scrolling {
|
||||
animation: marquee 10s linear infinite;
|
||||
padding-right: 2rem;
|
||||
}
|
||||
|
||||
/* Duplicate text for seamless loop */
|
||||
.marquee-content.scrolling::after {
|
||||
content: attr(data-text);
|
||||
padding-left: 2rem;
|
||||
}
|
||||
|
||||
:root {
|
||||
--lrc-text-color: #666; /* muted text for inactive lines */
|
||||
--lrc-bg-color: rgba(0, 0, 0, 0.05);
|
||||
@@ -42,6 +74,7 @@ body {
|
||||
overflow-y: hidden;
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
box-sizing: border-box;
|
||||
@@ -49,6 +82,11 @@ body {
|
||||
box-shadow: 1px 1px 5px 0 rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
/* Header - hidden on desktop (shown inline in music-player), visible on mobile */
|
||||
.music-container > .music-player__header {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Album cover section */
|
||||
.album-cover {
|
||||
aspect-ratio: 1 / 1;
|
||||
@@ -213,31 +251,172 @@ body {
|
||||
margin-bottom: 50px !important;
|
||||
overflow: visible !important;
|
||||
padding: 1rem !important;
|
||||
gap: 0 !important;
|
||||
}
|
||||
|
||||
/* Show header on mobile - it's first in DOM order */
|
||||
.music-container > .music-player__header {
|
||||
display: block !important;
|
||||
width: 100% !important;
|
||||
text-align: center !important;
|
||||
font-size: 1.25rem !important;
|
||||
margin-bottom: 0.5rem !important;
|
||||
}
|
||||
|
||||
/* Album cover below header */
|
||||
.album-cover {
|
||||
flex: none !important;
|
||||
flex: 0 0 auto !important;
|
||||
aspect-ratio: unset !important;
|
||||
width: 100% !important;
|
||||
max-width: 100% !important;
|
||||
height: auto !important;
|
||||
margin-bottom: 1rem !important;
|
||||
margin: 0 auto !important;
|
||||
min-width: 0 !important;
|
||||
padding: 0 1rem !important;
|
||||
box-sizing: border-box !important;
|
||||
}
|
||||
|
||||
.album-cover > img {
|
||||
aspect-ratio: unset !important;
|
||||
aspect-ratio: 1 / 1 !important;
|
||||
height: auto !important;
|
||||
width: 100% !important;
|
||||
width: 60% !important;
|
||||
max-width: 220px !important;
|
||||
border-radius: 0.75rem !important;
|
||||
display: block !important;
|
||||
margin: 0 auto !important;
|
||||
object-fit: cover !important;
|
||||
}
|
||||
|
||||
.music-player__album {
|
||||
font-size: 0.75rem !important;
|
||||
margin-bottom: 0.25rem !important;
|
||||
text-align: center !important;
|
||||
padding: 0 !important;
|
||||
word-wrap: break-word !important;
|
||||
overflow-wrap: break-word !important;
|
||||
}
|
||||
|
||||
/* Player info below album */
|
||||
.music-player {
|
||||
flex: none !important;
|
||||
flex: 0 0 auto !important;
|
||||
width: 100% !important;
|
||||
max-width: 100% !important;
|
||||
height: auto !important;
|
||||
padding: 0 !important;
|
||||
padding: 0 1rem !important;
|
||||
min-width: 0 !important;
|
||||
flex-shrink: 0 !important;
|
||||
text-align: center !important;
|
||||
margin-top: 0.25rem !important;
|
||||
}
|
||||
|
||||
/* Hide header inside music-player on mobile (using the one outside) */
|
||||
.music-player .music-player__header {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.music-player__title {
|
||||
font-size: 1rem !important;
|
||||
margin-bottom: 0.15rem !important;
|
||||
padding: 0 1rem !important;
|
||||
}
|
||||
|
||||
.music-player__author {
|
||||
font-size: 0.85rem !important;
|
||||
margin-bottom: 0.35rem !important;
|
||||
padding: 0 1rem !important;
|
||||
}
|
||||
|
||||
.music-player__genre {
|
||||
font-size: 0.8rem !important;
|
||||
margin-bottom: 0.35rem !important;
|
||||
padding: 0 1rem !important;
|
||||
}
|
||||
|
||||
.music-player__album {
|
||||
padding: 0 1rem !important;
|
||||
}
|
||||
|
||||
.music-time {
|
||||
margin-top: 0.35rem !important;
|
||||
font-size: 0.8rem !important;
|
||||
}
|
||||
|
||||
.music-control {
|
||||
margin-top: 0.5rem !important;
|
||||
}
|
||||
|
||||
.music-control__play {
|
||||
width: 2.5rem !important;
|
||||
height: 2.5rem !important;
|
||||
}
|
||||
|
||||
/* Lyrics section */
|
||||
.lrc-text {
|
||||
max-height: 150px !important;
|
||||
margin-top: 0.5rem !important;
|
||||
padding: 0.5rem !important;
|
||||
font-size: 0.75rem !important;
|
||||
}
|
||||
|
||||
.lrc-line {
|
||||
font-size: 0.75rem !important;
|
||||
}
|
||||
|
||||
.lrc-line.active {
|
||||
font-size: 0.85rem !important;
|
||||
}
|
||||
|
||||
/* DJ controls */
|
||||
.dj-controls {
|
||||
min-width: 0 !important;
|
||||
width: 100% !important;
|
||||
margin-top: 0.75rem !important;
|
||||
}
|
||||
|
||||
.dj-controls .flex-col {
|
||||
min-width: 0 !important;
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
.dj-controls .typeahead-input input,
|
||||
.dj-controls .p-autocomplete-input {
|
||||
width: 100% !important;
|
||||
min-width: 0 !important;
|
||||
max-width: 100% !important;
|
||||
}
|
||||
|
||||
.dj-controls .p-autocomplete {
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
.dj-controls .flex-row {
|
||||
min-width: 0 !important;
|
||||
width: 100% !important;
|
||||
justify-content: center !important;
|
||||
}
|
||||
|
||||
.station-tabs {
|
||||
padding-top: 2% !important;
|
||||
padding-bottom: 2% !important;
|
||||
}
|
||||
|
||||
/* Next track styling for mobile */
|
||||
.next-track {
|
||||
font-size: 0.7rem !important;
|
||||
padding: 0.25rem 0.5rem !important;
|
||||
margin-top: 0.25rem !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Extra small screens */
|
||||
@media all and (max-width: 400px) {
|
||||
.album-cover {
|
||||
width: 70% !important;
|
||||
max-width: 200px !important;
|
||||
}
|
||||
|
||||
.station-tabs button {
|
||||
padding: 0.35rem 0.6rem !important;
|
||||
font-size: 0.8rem !important;
|
||||
}
|
||||
}
|
||||
.progress-bar-container {
|
||||
|
||||
@@ -2,6 +2,7 @@ import React, { useState, useEffect, useLayoutEffect, useMemo, useCallback, memo
|
||||
import type { AnimationItem } from 'lottie-web';
|
||||
import { ProgressSpinner } from 'primereact/progressspinner';
|
||||
import { authFetch } from '@/utils/authFetch';
|
||||
import DOMPurify from 'isomorphic-dompurify';
|
||||
|
||||
// ============================================================================
|
||||
// Type Definitions
|
||||
@@ -665,7 +666,7 @@ const ARCHIVE_USERS = {
|
||||
id: '456226577798135808',
|
||||
username: 'slip',
|
||||
displayName: 'poopboy',
|
||||
avatar: 'https://codey.lol/images/456226577798135808.png',
|
||||
avatar: 'https://codey.horse/images/456226577798135808.png',
|
||||
color: null,
|
||||
},
|
||||
};
|
||||
@@ -981,7 +982,12 @@ function parseDiscordMarkdown(text: string | null | undefined, options: ParseOpt
|
||||
// Must be done after all markdown processing
|
||||
parsed = parsed.replace(/\\([_*~`|\\])/g, '$1');
|
||||
|
||||
return parsed;
|
||||
// Final sanitization pass with DOMPurify to prevent XSS
|
||||
return DOMPurify.sanitize(parsed, {
|
||||
ALLOWED_TAGS: ['strong', 'em', 'u', 's', 'span', 'code', 'pre', 'br', 'a', 'img', 'blockquote'],
|
||||
ALLOWED_ATTR: ['class', 'href', 'target', 'rel', 'src', 'alt', 'title', 'style', 'data-lenis-prevent', 'data-channel-id', 'data-user-id', 'data-role-id'],
|
||||
ALLOW_DATA_ATTR: true,
|
||||
});
|
||||
} catch (err) {
|
||||
try { console.error('parseDiscordMarkdown failed', err); } catch (e) { /* ignore logging errors */ }
|
||||
// Fallback: return a safely-escaped version of the input to avoid crashing the UI
|
||||
|
||||
@@ -21,29 +21,81 @@ 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>}
|
||||
{versionDisplay}:{buildNumber}
|
||||
{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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function updateBuildTooltip() {
|
||||
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) {
|
||||
if (!el) return;
|
||||
|
||||
const iso = el.getAttribute('data-build-time');
|
||||
if (iso) {
|
||||
if (!iso) return;
|
||||
|
||||
const local = new Date(iso).toLocaleString(undefined, {
|
||||
dateStyle: 'medium',
|
||||
timeStyle: 'short',
|
||||
});
|
||||
el.setAttribute('title', `Built: ${local}`);
|
||||
|
||||
// Set build time text for mobile tap display
|
||||
const buildTimeText = el.querySelector('.build-time-text');
|
||||
if (buildTimeText) {
|
||||
buildTimeText.textContent = local;
|
||||
}
|
||||
|
||||
// Handle tap on mobile to toggle build time display
|
||||
const pill = el.querySelector('.version-pill') as HTMLElement | null;
|
||||
if (pill && !pill.dataset.initialized) {
|
||||
pill.dataset.initialized = 'true';
|
||||
pill.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
pill.classList.toggle('show-build-time');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
bindApiStatusListener();
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', initBuildTooltip);
|
||||
} else {
|
||||
initBuildTooltip();
|
||||
}
|
||||
updateBuildTooltip();
|
||||
document.addEventListener('astro:page-load', updateBuildTooltip);
|
||||
document.addEventListener('astro:page-load', initBuildTooltip);
|
||||
</script>
|
||||
|
||||
<style is:global>
|
||||
@@ -73,6 +125,24 @@ const envBadge = ENVIRONMENT === 'Dev' ? 'DEV' : null;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.06), inset 0 1px 0 rgba(255,255,255,0.8);
|
||||
user-select: none;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
.build-time-text {
|
||||
display: none;
|
||||
font-size: 0.68rem;
|
||||
opacity: 0.7;
|
||||
padding-left: 0.4rem;
|
||||
border-left: 1px solid rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.version-pill.show-build-time .build-time-text {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .build-time-text {
|
||||
border-left-color: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
.version-pill:hover {
|
||||
@@ -104,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;
|
||||
|
||||
@@ -15,7 +15,7 @@ function clearCookie(name: string): void {
|
||||
}
|
||||
|
||||
// Trusted domains for redirect validation
|
||||
const TRUSTED_DOMAINS = ['codey.lol', 'boatson.boats'];
|
||||
const TRUSTED_DOMAINS = ['codey.horse', 'boatson.boats'];
|
||||
|
||||
/**
|
||||
* Parse and decode the redirect URL from query params
|
||||
|
||||
@@ -8,7 +8,7 @@ import { toast } from 'react-toastify';
|
||||
import { API_URL } from '../config';
|
||||
|
||||
const MEME_API_URL = `${API_URL}/memes/list_memes`;
|
||||
const BASE_IMAGE_URL = "https://codey.lol/meme";
|
||||
const BASE_IMAGE_URL = "https://codey.horse/meme";
|
||||
|
||||
interface MemeImage {
|
||||
id: string;
|
||||
|
||||
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"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -475,6 +475,35 @@
|
||||
}
|
||||
.p-dialog .rm-progress-text { font-size: .75rem; font-weight: 600; color: #e5e7eb !important; margin-left: 0.5rem; white-space: nowrap; }
|
||||
|
||||
/* Request Details Dialog - Responsive Track List */
|
||||
.request-details-dialog .p-dialog-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: 80vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.request-details-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.request-details-content > .track-list-card {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.track-list-scrollable {
|
||||
flex: 1;
|
||||
min-height: 120px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* Container Styles */
|
||||
.trip-management-container {
|
||||
|
||||
@@ -36,7 +36,7 @@ interface RequestJob {
|
||||
}
|
||||
|
||||
const STATUS_OPTIONS = ["Queued", "Started", "Compressing", "Finished", "Failed"];
|
||||
const TAR_BASE_URL = "https://kr.codey.lol"; // configurable prefix
|
||||
const TAR_BASE_URL = "https://kr.codey.horse"; // configurable prefix
|
||||
|
||||
export default function RequestManagement() {
|
||||
const [requests, setRequests] = useState<RequestJob[]>([]);
|
||||
@@ -54,15 +54,19 @@ export default function RequestManagement() {
|
||||
|
||||
const resolveTarballPath = (job: RequestJob) => job.tarball;
|
||||
|
||||
const tarballUrl = (absPath: string | undefined, quality: string) => {
|
||||
const tarballUrl = (absPath: string | undefined, quality: string, jobType?: string) => {
|
||||
if (!absPath) return null;
|
||||
const filename = absPath.split("/").pop(); // get "SOMETHING.tar.gz"
|
||||
// If the backend already stores a fully qualified URL, return as-is
|
||||
if (/^https?:\/\//i.test(absPath)) return absPath;
|
||||
|
||||
const isVideo = jobType === "video" || absPath.includes("/videos/");
|
||||
|
||||
// Check if path is /storage/music/TRIP
|
||||
if (absPath.includes("/storage/music/TRIP/")) {
|
||||
return `https://_music.codey.lol/TRIP/${filename}`;
|
||||
return isVideo
|
||||
? `https://trip.codey.horse/videos/${filename}`
|
||||
: `https://trip.codey.horse/${filename}`;
|
||||
}
|
||||
|
||||
// Otherwise, assume /storage/music2/completed/{quality} format
|
||||
@@ -436,7 +440,7 @@ export default function RequestManagement() {
|
||||
</span>
|
||||
}
|
||||
body={(row: RequestJob) => {
|
||||
const url = tarballUrl(resolveTarballPath(row as RequestJob), row.quality || "FLAC");
|
||||
const url = tarballUrl(resolveTarballPath(row as RequestJob), row.quality || "FLAC", row.type);
|
||||
if (!url) return "—";
|
||||
const encodedURL = encodeURI(url);
|
||||
|
||||
@@ -464,15 +468,16 @@ export default function RequestManagement() {
|
||||
<Dialog
|
||||
header="Request Details"
|
||||
visible={isDialogVisible}
|
||||
style={{ width: "500px" }}
|
||||
style={{ width: "500px", maxHeight: "90vh" }}
|
||||
onHide={() => setIsDialogVisible(false)}
|
||||
breakpoints={{ "960px": "95vw" }}
|
||||
modal
|
||||
dismissableMask
|
||||
className="dark:bg-neutral-900 dark:text-neutral-100"
|
||||
maximizable
|
||||
className="dark:bg-neutral-900 dark:text-neutral-100 request-details-dialog"
|
||||
>
|
||||
{selectedRequest ? (
|
||||
<div className="space-y-4 text-sm">
|
||||
<div className="request-details-content space-y-4 text-sm">
|
||||
|
||||
{/* --- Metadata Card --- */}
|
||||
<div className="p-3 bg-gray-100 dark:bg-neutral-800 rounded-md grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
@@ -552,12 +557,12 @@ export default function RequestManagement() {
|
||||
<p>
|
||||
<strong>Tarball:</strong>{" "}
|
||||
<a
|
||||
href={encodeURI(tarballUrl(resolveTarballPath(selectedRequest), selectedRequest.quality) || "")}
|
||||
href={encodeURI(tarballUrl(resolveTarballPath(selectedRequest), selectedRequest.quality, selectedRequest.type) || "")}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-500 hover:underline"
|
||||
>
|
||||
{tarballUrl(resolveTarballPath(selectedRequest), selectedRequest.quality)?.split("/").pop()}
|
||||
{tarballUrl(resolveTarballPath(selectedRequest), selectedRequest.quality, selectedRequest.type)?.split("/").pop()}
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
@@ -566,9 +571,9 @@ export default function RequestManagement() {
|
||||
|
||||
{/* --- Track List Card --- */}
|
||||
{selectedRequest.track_list && selectedRequest.track_list.length > 0 && (
|
||||
<div className="p-3 bg-gray-100 dark:bg-neutral-800 rounded-md">
|
||||
<p className="mb-2"><strong>Tracks ({selectedRequest.track_list.length}):</strong></p>
|
||||
<div className="max-h-60 overflow-y-auto space-y-2" data-lenis-prevent>
|
||||
<div className="p-3 bg-gray-100 dark:bg-neutral-800 rounded-md track-list-card">
|
||||
<p className="mb-2 flex-shrink-0"><strong>Tracks ({selectedRequest.track_list.length}):</strong></p>
|
||||
<div className="track-list-scrollable space-y-2" data-lenis-prevent>
|
||||
{selectedRequest.track_list.map((track, idx) => {
|
||||
const rawStatus = String(track.status || "pending");
|
||||
const statusNorm = rawStatus.trim().toLowerCase();
|
||||
|
||||
@@ -39,9 +39,9 @@ export interface ProtectedRoute {
|
||||
}
|
||||
|
||||
export const metaData: MetaData = {
|
||||
baseUrl: "https://codey.lol/",
|
||||
baseUrl: "https://codey.horse/",
|
||||
title: "CODEY STUFF",
|
||||
name: "codey.lol",
|
||||
name: "codey.horse",
|
||||
owner: "codey",
|
||||
ogImage: "/images/favicon.png",
|
||||
description: "CODEY STUFF!",
|
||||
@@ -57,13 +57,13 @@ export const metaData: MetaData = {
|
||||
],
|
||||
};
|
||||
|
||||
export const API_URL: string = "https://api.codey.lol";
|
||||
export const RADIO_API_URL: string = "https://radio-api.codey.lol";
|
||||
export const API_URL: string = "https://api.codey.horse";
|
||||
export const RADIO_API_URL: string = "https://radio-api.codey.horse";
|
||||
|
||||
export const socialLinks: Record<string, string> = {
|
||||
};
|
||||
|
||||
export const MAJOR_VERSION: string = "0.7"
|
||||
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 {
|
||||
|
||||
@@ -2,60 +2,121 @@
|
||||
import { metaData, API_URL } from "../config";
|
||||
import { Icon } from "astro-icon/components";
|
||||
import { requireAuthHook } from "@/hooks/requireAuthHook";
|
||||
import { padlockIconSvg, userIconSvg, externalLinkIconSvg } from "@/utils/navAssets";
|
||||
import { userIconSvg, externalLinkIconSvg } from "@/utils/navAssets";
|
||||
import "@/assets/styles/nav.css";
|
||||
|
||||
const user = await requireAuthHook(Astro);
|
||||
const hostHeader = Astro.request?.headers?.get('host') || '';
|
||||
const host = hostHeader.split(':')[0];
|
||||
import { getSubsiteByHost } from '../utils/subsites.js';
|
||||
import { getSubsiteByPath } from '../utils/subsites.js';
|
||||
const isReq = getSubsiteByHost(host)?.short === 'req' || getSubsiteByPath(Astro.url.pathname)?.short === 'req';
|
||||
// Nav is the standard site navigation — whitelabel logic belongs in SubNav
|
||||
const isLoggedIn = Boolean(user);
|
||||
const userDisplayName = user?.user ?? null;
|
||||
const isAdmin = user?.roles?.includes('admin') ?? false;
|
||||
|
||||
const navItems = [
|
||||
type NavItem = {
|
||||
label: string;
|
||||
href: string;
|
||||
icon?: string;
|
||||
auth?: boolean;
|
||||
adminOnly?: boolean;
|
||||
guestOnly?: boolean;
|
||||
onclick?: string;
|
||||
children?: NavItem[];
|
||||
};
|
||||
|
||||
const baseNavItems: NavItem[] = [
|
||||
{ label: "Home", href: "/" },
|
||||
{ label: "Radio", href: "/radio" },
|
||||
{ label: "Memes", href: "/memes" },
|
||||
{ label: "TRip", href: "/TRip", auth: true, icon: "pirate" },
|
||||
{
|
||||
label: "TRip",
|
||||
href: "/TRip",
|
||||
auth: true,
|
||||
icon: "pirate",
|
||||
children: [
|
||||
{ label: "Submit Request", href: "/TRip", auth: true },
|
||||
{ label: "Manage Requests", href: "/TRip/requests", auth: true },
|
||||
],
|
||||
},
|
||||
{ label: "Discord Logs", href: "/discord-logs", auth: true },
|
||||
{
|
||||
label: "Admin",
|
||||
href: "javascript:void(0)",
|
||||
auth: true,
|
||||
adminOnly: true,
|
||||
children: [
|
||||
{ label: "Lighting", href: "/lighting", auth: true, adminOnly: true },
|
||||
{ label: "Glances", href: "https://_gl.codey.horse", auth: true, icon: "external", adminOnly: true },
|
||||
{ label: "PSQL", href: "https://_pg.codey.horse", auth: true, icon: "external", adminOnly: true },
|
||||
{ label: "SQLite", href: "https://_sqlite.codey.horse", auth: true, icon: "external", adminOnly: true },
|
||||
{ label: "qBitTorrent", href: "https://_qb.codey.horse", auth: true, icon: "external", adminOnly: true },
|
||||
{ label: "RQ", href: "https://_rq.codey.horse", auth: true, icon: "external", adminOnly: true },
|
||||
{ label: "RI", href: "https://_r0.codey.horse", auth: true, icon: "external", adminOnly: true },
|
||||
],
|
||||
},
|
||||
{ label: "Git", href: "https://kode.boatson.boats", icon: "external" },
|
||||
{ label: "Glances", href: "https://_gl.codey.lol", auth: true, icon: "external",
|
||||
adminOnly: true },
|
||||
{ label: "PSQL", href: "https://_pg.codey.lol", auth: true, icon: "external",
|
||||
adminOnly: true },
|
||||
{ label: "qBitTorrent", href: "https://_qb.codey.lol", auth: true, icon: "external",
|
||||
adminOnly: true },
|
||||
{ label: "RQ", href: "https://_rq.codey.lol", auth: true, icon: "external",
|
||||
adminOnly: true },
|
||||
{ label: "RI", href: "https://_r0.codey.lol", auth: true, icon: "external",
|
||||
adminOnly: true },
|
||||
// { label: "Status", href: "https://status.boatson.boats", icon: "external" },
|
||||
{ label: "Login", href: "/login", guestOnly: true },
|
||||
...(isLoggedIn ? [{ label: "Logout", href: "#logout", onclick: "handleLogout()" }] : []),
|
||||
];
|
||||
|
||||
const visibleNavItems = navItems.filter((item) => {
|
||||
if (item.auth && !isLoggedIn) {
|
||||
return false;
|
||||
}
|
||||
// Fold any adminOnly root items into the Admin group automatically
|
||||
const adminContainerIndex = baseNavItems.findIndex((item) => item.label === "Admin");
|
||||
const adminContainer: NavItem = adminContainerIndex >= 0
|
||||
? baseNavItems[adminContainerIndex]
|
||||
: { label: "Admin", href: "#admin", auth: true, adminOnly: true, children: [] };
|
||||
|
||||
if (item.adminOnly && !isAdmin) {
|
||||
return false;
|
||||
}
|
||||
const adminChildren: NavItem[] = [...(adminContainer.children ?? [])];
|
||||
const groupedNavItems: NavItem[] = [];
|
||||
|
||||
if (item.guestOnly && isLoggedIn) {
|
||||
return false;
|
||||
}
|
||||
baseNavItems.forEach((item, idx) => {
|
||||
if (item.label === "Admin") return; // defer insertion
|
||||
|
||||
return true;
|
||||
if (item.adminOnly) {
|
||||
adminChildren.push(item);
|
||||
} else {
|
||||
groupedNavItems.push(item);
|
||||
}
|
||||
});
|
||||
|
||||
const currentPath = Astro.url.pathname;
|
||||
if (adminChildren.length > 0) {
|
||||
const adminItem: NavItem = { ...adminContainer, children: adminChildren };
|
||||
// Insert Admin before Login/Logout (which are always last)
|
||||
const loginIdx = groupedNavItems.findIndex((item) => item.label === "Login" || item.label === "Logout");
|
||||
const insertAt = loginIdx >= 0 ? loginIdx : groupedNavItems.length;
|
||||
groupedNavItems.splice(insertAt, 0, adminItem);
|
||||
}
|
||||
|
||||
const isItemVisible = (item: NavItem): boolean => {
|
||||
if (item.auth && !isLoggedIn) return false;
|
||||
if (item.adminOnly && !isAdmin) return false;
|
||||
if (item.guestOnly && isLoggedIn) return false;
|
||||
return true;
|
||||
};
|
||||
|
||||
const visibleNavItems = groupedNavItems
|
||||
.filter(isItemVisible)
|
||||
.map((item): NavItem => {
|
||||
const visibleChildren = item.children?.filter(isItemVisible);
|
||||
return visibleChildren ? { ...item, children: visibleChildren } : item;
|
||||
})
|
||||
.filter((item) => !item.children || item.children.length > 0);
|
||||
|
||||
const normalize = (url: string) => (url || '/').replace(/\/+$/, '') || '/';
|
||||
const normalizedCurrent = normalize(Astro.url.pathname);
|
||||
|
||||
// For parent items: active if exact match OR if path starts with href (prefix matching)
|
||||
const isPathActive = (href: string) => {
|
||||
const normalizedHref = normalize(href);
|
||||
if (!href || href.startsWith('http')) return false;
|
||||
return normalizedHref === '/'
|
||||
? normalizedCurrent === '/'
|
||||
: normalizedCurrent === normalizedHref || normalizedCurrent.startsWith(`${normalizedHref}/`);
|
||||
};
|
||||
|
||||
// For dropdown children: active only on exact match (no prefix matching)
|
||||
const isPathActiveExact = (href: string) => {
|
||||
const normalizedHref = normalize(href);
|
||||
if (!href || href.startsWith('http')) return false;
|
||||
return normalizedCurrent === normalizedHref;
|
||||
};
|
||||
|
||||
---
|
||||
|
||||
@@ -75,40 +136,69 @@ const currentPath = Astro.url.pathname;
|
||||
<div class="desktop-nav flex items-center">
|
||||
<ul class="desktop-nav-list">
|
||||
{visibleNavItems.map((item) => {
|
||||
const hasChildren = Array.isArray(item.children) && item.children.length > 0;
|
||||
const isExternal = item.href?.startsWith("http");
|
||||
const isAuthedPath = item.auth ?? false;
|
||||
const normalize = (url) => (url || '/').replace(/\/+$/, '') || '/';
|
||||
const normalizedCurrent = normalize(currentPath);
|
||||
const normalizedHref = normalize(item.href);
|
||||
const isActive = !isExternal && (
|
||||
normalizedHref === '/'
|
||||
? normalizedCurrent === '/'
|
||||
: normalizedCurrent === normalizedHref || normalizedCurrent.startsWith(normalizedHref + '/')
|
||||
);
|
||||
const childActive = hasChildren && item.children?.some((child) => isPathActive(child.href));
|
||||
const isActive = childActive || (!isExternal && isPathActive(item.href));
|
||||
|
||||
return (
|
||||
<li>
|
||||
<li class:list={["nav-item", hasChildren && "nav-item--has-children"]}>
|
||||
<a
|
||||
href={item.href}
|
||||
class={isActive
|
||||
? "flex items-center gap-0.5 px-3 py-1.5 rounded-lg text-[13px] font-semibold transition-all duration-200 text-white bg-neutral-900 dark:bg-white dark:text-neutral-900 shadow-sm font-['IBM_Plex_Sans',sans-serif]"
|
||||
: "flex items-center gap-0.5 px-3 py-1.5 rounded-lg text-[13px] font-medium transition-all duration-200 text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-white hover:bg-neutral-100 dark:hover:bg-neutral-800/60 font-['IBM_Plex_Sans',sans-serif]"
|
||||
? "flex items-center gap-1 px-3 py-1.5 rounded-lg text-[13px] font-semibold transition-all duration-200 text-white bg-neutral-900 dark:bg-white dark:text-neutral-900 shadow-sm font-['IBM_Plex_Sans',sans-serif]"
|
||||
: "flex items-center gap-1 px-3 py-1.5 rounded-lg text-[13px] font-medium transition-all duration-200 text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-white hover:bg-neutral-100 dark:hover:bg-neutral-800/60 font-['IBM_Plex_Sans',sans-serif]"
|
||||
}
|
||||
target={isExternal ? "_blank" : undefined}
|
||||
rel={(isExternal || isAuthedPath) ? "external" : undefined}
|
||||
onclick={item.onclick}
|
||||
aria-haspopup={hasChildren ? "true" : undefined}
|
||||
aria-expanded={hasChildren ? isActive : undefined}
|
||||
>
|
||||
{item.label}
|
||||
{hasChildren && <span class="nav-caret" aria-hidden="true">▼</span>}
|
||||
{item.icon === "external" && (
|
||||
<span class="inline-flex ml-0.5" aria-hidden="true" set:html={externalLinkIconSvg}></span>
|
||||
)}
|
||||
{item.icon === "padlock" && (
|
||||
<span class="inline-flex" aria-hidden="true" set:html={padlockIconSvg}></span>
|
||||
)}
|
||||
{item.icon === "pirate" && (
|
||||
<span class="inline-flex ml-1" role="img" aria-label="Pirate flag">🏴☠️</span>
|
||||
)}
|
||||
</a>
|
||||
|
||||
{hasChildren && (
|
||||
<div class="nav-dropdown" role="menu">
|
||||
<ul>
|
||||
{item.children?.map((child) => {
|
||||
const childExternal = child.href?.startsWith("http");
|
||||
const childAuthedPath = child.auth ?? false;
|
||||
const childIsActive = !childExternal && isPathActiveExact(child.href);
|
||||
|
||||
return (
|
||||
<li>
|
||||
<a
|
||||
href={child.href}
|
||||
class={childIsActive
|
||||
? "flex items-center gap-1 px-3 py-2 rounded-md text-sm font-semibold transition-all duration-150 text-white bg-neutral-900 dark:text-neutral-900 dark:bg-white"
|
||||
: "flex items-center gap-1 px-3 py-2 rounded-md text-sm font-medium transition-all duration-150 text-neutral-700 dark:text-neutral-200 hover:bg-neutral-100 dark:hover:bg-neutral-800/80"}
|
||||
target={childExternal ? "_blank" : undefined}
|
||||
rel={(childExternal || childAuthedPath) ? "external" : undefined}
|
||||
onclick={childIsActive ? "event.preventDefault()" : undefined}
|
||||
>
|
||||
{child.label}
|
||||
{child.icon === "external" && (
|
||||
<span class="inline-flex ml-0.5" aria-hidden="true" set:html={externalLinkIconSvg}></span>
|
||||
)}
|
||||
{child.icon === "pirate" && (
|
||||
<span class="inline-flex ml-1" role="img" aria-label="Pirate flag">🏴☠️</span>
|
||||
)}
|
||||
</a>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
@@ -170,6 +260,7 @@ const currentPath = Astro.url.pathname;
|
||||
<div
|
||||
id="mobile-menu"
|
||||
class="mobile-menu-dropdown xl:hidden"
|
||||
data-lenis-prevent
|
||||
>
|
||||
{isLoggedIn && userDisplayName && (
|
||||
<div class:list={['nav-user-inline', 'nav-user-inline--mobile', isAdmin && 'nav-user-inline--admin']} title={`Logged in as ${userDisplayName}`}>
|
||||
@@ -179,16 +270,11 @@ const currentPath = Astro.url.pathname;
|
||||
)}
|
||||
<ul class="flex flex-col gap-1 py-4">
|
||||
{visibleNavItems.map((item) => {
|
||||
const hasChildren = Array.isArray(item.children) && item.children.length > 0;
|
||||
const isExternal = item.href?.startsWith("http");
|
||||
const isAuthedPath = item.auth ?? false;
|
||||
const normalize = (url) => (url || '/').replace(/\/+$/, '') || '/';
|
||||
const normalizedCurrent = normalize(currentPath);
|
||||
const normalizedHref = normalize(item.href);
|
||||
const isActive = !isExternal && (
|
||||
normalizedHref === '/'
|
||||
? normalizedCurrent === '/'
|
||||
: normalizedCurrent === normalizedHref || normalizedCurrent.startsWith(normalizedHref + '/')
|
||||
);
|
||||
const childActive = hasChildren && item.children?.some((child) => isPathActive(child.href));
|
||||
const isActive = childActive || (!isExternal && isPathActive(item.href));
|
||||
|
||||
return (
|
||||
<li>
|
||||
@@ -204,16 +290,46 @@ const currentPath = Astro.url.pathname;
|
||||
onclick={item.onclick}
|
||||
>
|
||||
<span style="color:inherit;">{item.label}</span>
|
||||
{hasChildren && <span class="mobile-caret" aria-hidden="true">›</span>}
|
||||
{item.icon === "external" && (
|
||||
<span class="inline-flex ml-0.5" aria-hidden="true" set:html={externalLinkIconSvg}></span>
|
||||
)}
|
||||
{item.icon === "padlock" && (
|
||||
<span class="inline-flex" aria-hidden="true" set:html={padlockIconSvg}></span>
|
||||
)}
|
||||
{item.icon === "pirate" && (
|
||||
<span class="inline-flex ml-1" role="img" aria-label="Pirate flag">🏴☠️</span>
|
||||
)}
|
||||
</a>
|
||||
|
||||
{hasChildren && (
|
||||
<ul class="mobile-subnav" role="menu">
|
||||
{item.children?.map((child) => {
|
||||
const childExternal = child.href?.startsWith("http");
|
||||
const childAuthedPath = child.auth ?? false;
|
||||
const childIsActive = !childExternal && isPathActiveExact(child.href);
|
||||
|
||||
return (
|
||||
<li>
|
||||
<a
|
||||
href={child.href}
|
||||
class={childIsActive
|
||||
? "flex items-center gap-1 px-6 py-2 rounded-lg text-base font-semibold transition-all duration-200 text-white bg-neutral-900 dark:bg-white dark:text-neutral-900"
|
||||
: "flex items-center gap-1 px-6 py-2 rounded-lg text-base font-medium transition-all duration-200 text-neutral-700 dark:text-neutral-300 hover:bg-neutral-100 dark:hover:bg-neutral-800"}
|
||||
target={childExternal ? "_blank" : undefined}
|
||||
rel={(childExternal || childAuthedPath) ? "external" : undefined}
|
||||
onclick={childIsActive ? "event.preventDefault()" : undefined}
|
||||
>
|
||||
<span style="color:inherit;">{child.label}</span>
|
||||
{child.icon === "external" && (
|
||||
<span class="inline-flex ml-0.5" aria-hidden="true" set:html={externalLinkIconSvg}></span>
|
||||
)}
|
||||
{child.icon === "pirate" && (
|
||||
<span class="inline-flex ml-1" role="img" aria-label="Pirate flag">🏴☠️</span>
|
||||
)}
|
||||
</a>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -26,7 +26,7 @@ if (typeof globalThis.Headers !== 'undefined' && typeof globalThis.Headers.proto
|
||||
}
|
||||
}
|
||||
|
||||
const API_URL = "https://api.codey.lol";
|
||||
const API_URL = "https://api.codey.horse";
|
||||
const AUTH_TIMEOUT_MS = 3000; // 3 second timeout for auth requests
|
||||
|
||||
// Deduplication for concurrent refresh requests (prevents race condition where
|
||||
@@ -392,7 +392,7 @@ export const onRequest = defineMiddleware(async (context, next) => {
|
||||
}
|
||||
}
|
||||
|
||||
// Block /subsites/req/* on main domain (codey.lol, local.codey.lol, etc)
|
||||
// Block /subsites/req/* on main domain (codey.horse, local.codey.horse, etc)
|
||||
const isMainDomain = !wantsSubsite;
|
||||
if (isMainDomain && Object.values(subsites || {}).some(p => context.url.pathname.startsWith(p))) {
|
||||
// Immediately return a 404 for /req on the main domain
|
||||
@@ -460,7 +460,7 @@ export const onRequest = defineMiddleware(async (context, next) => {
|
||||
"font-src 'self' https://fonts.gstatic.com data:",
|
||||
"img-src 'self' data: blob: https: http:",
|
||||
"media-src 'self' blob: https:",
|
||||
"connect-src 'self' https://api.codey.lol https://*.codey.lol https://*.audio.tidal.com wss:",
|
||||
"connect-src 'self' https://api.codey.horse https://*.codey.horse https://*.audio.tidal.com wss:",
|
||||
// Allow YouTube for video embeds and Cloudflare for challenges/Turnstile
|
||||
"frame-src 'self' https://www.youtube.com https://www.youtube-nocookie.com https://challenges.cloudflare.com",
|
||||
"object-src 'none'",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { defineMiddleware } from 'astro:middleware';
|
||||
import { SUBSITES, PROTECTED_ROUTES, PUBLIC_ROUTES, type ProtectedRoute } from './config.ts';
|
||||
import { SUBSITES, PROTECTED_ROUTES, PUBLIC_ROUTES, API_URL, type ProtectedRoute } from './config.ts';
|
||||
import { getSubsiteByHost, getSubsiteFromSignal, type SubsiteInfo } from './utils/subsites.ts';
|
||||
|
||||
declare module 'astro' {
|
||||
@@ -64,7 +64,6 @@ if (typeof globalThis.Headers !== 'undefined' && typeof (globalThis.Headers.prot
|
||||
}
|
||||
}
|
||||
|
||||
const API_URL: string = "https://api.codey.lol";
|
||||
const AUTH_TIMEOUT_MS: number = 3000; // 3 second timeout for auth requests
|
||||
|
||||
// Deduplication for concurrent refresh requests (prevents race condition where
|
||||
@@ -430,7 +429,7 @@ export const onRequest = defineMiddleware(async (context, next) => {
|
||||
}
|
||||
}
|
||||
|
||||
// Block /subsites/req/* on main domain (codey.lol, local.codey.lol, etc)
|
||||
// Block /subsites/req/* on main domain (codey.horse, local.codey.horse, etc)
|
||||
const isMainDomain = !wantsSubsite;
|
||||
if (isMainDomain && Object.values(subsites || {}).some(p => context.url.pathname.startsWith(p))) {
|
||||
// Immediately return a 404 for /req on the main domain
|
||||
@@ -501,7 +500,7 @@ export const onRequest = defineMiddleware(async (context, next) => {
|
||||
"font-src 'self' https://fonts.gstatic.com data:",
|
||||
"img-src 'self' data: blob: https: http:",
|
||||
"media-src 'self' blob: https:",
|
||||
"connect-src 'self' https://api.codey.lol https://*.codey.lol https://*.audio.tidal.com wss:",
|
||||
"connect-src 'self' https://api.codey.horse https://*.codey.horse https://*.audio.tidal.com wss:",
|
||||
// Allow YouTube for video embeds and Cloudflare for challenges/Turnstile
|
||||
"frame-src 'self' https://www.youtube.com https://www.youtube-nocookie.com https://challenges.cloudflare.com",
|
||||
"object-src 'none'",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -15,8 +15,10 @@ import type { APIContext } from 'astro';
|
||||
|
||||
// Secret for signing image IDs - prevents enumeration attacks
|
||||
const IMAGE_CACHE_SECRET = import.meta.env.IMAGE_CACHE_SECRET;
|
||||
if (!IMAGE_CACHE_SECRET) {
|
||||
console.error('CRITICAL: IMAGE_CACHE_SECRET environment variable is not set!');
|
||||
if (!IMAGE_CACHE_SECRET && import.meta.env.PROD) {
|
||||
throw new Error('CRITICAL: IMAGE_CACHE_SECRET environment variable is not set in production!');
|
||||
} else if (!IMAGE_CACHE_SECRET) {
|
||||
console.error('WARNING: IMAGE_CACHE_SECRET environment variable is not set!');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -30,7 +32,21 @@ export function signImageId(imageId: string | number): string {
|
||||
}
|
||||
const hmac = crypto.createHmac('sha256', IMAGE_CACHE_SECRET);
|
||||
hmac.update(String(imageId));
|
||||
return hmac.digest('hex').substring(0, 16); // Short signature is sufficient
|
||||
return hmac.digest('hex').substring(0, 32); // 128-bit signature
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate HMAC signature for a source URL
|
||||
* @param sourceUrl - The URL to sign
|
||||
* @returns The hex signature
|
||||
*/
|
||||
export function signSourceUrl(sourceUrl: string): string {
|
||||
if (!IMAGE_CACHE_SECRET) {
|
||||
throw new Error('IMAGE_CACHE_SECRET not configured');
|
||||
}
|
||||
const hmac = crypto.createHmac('sha256', IMAGE_CACHE_SECRET);
|
||||
hmac.update(sourceUrl);
|
||||
return hmac.digest('hex').substring(0, 32); // 128-bit signature
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -50,6 +66,23 @@ function verifyImageSignature(imageId: string | number, signature: string | null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify HMAC signature for a source URL
|
||||
* @param sourceUrl - The URL
|
||||
* @param signature - The signature to verify
|
||||
* @returns Whether signature is valid
|
||||
*/
|
||||
function verifyUrlSignature(sourceUrl: string, signature: string | null): boolean {
|
||||
if (!IMAGE_CACHE_SECRET || !signature) return false;
|
||||
const expected = signSourceUrl(sourceUrl);
|
||||
// Timing-safe comparison
|
||||
try {
|
||||
return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET({ request }: APIContext): Promise<Response> {
|
||||
// Rate limit check - higher limit for images but still protected
|
||||
const rateCheck = checkRateLimit(request, {
|
||||
@@ -98,8 +131,11 @@ export async function GET({ request }: APIContext): Promise<Response> {
|
||||
WHERE image_id = ${imageId}
|
||||
`;
|
||||
image = result[0];
|
||||
} else {
|
||||
// Look up by source_url - no signature needed as URL itself is the identifier
|
||||
} else if (sourceUrl) {
|
||||
// Require signature for URL-based lookups to prevent enumeration
|
||||
if (!verifyUrlSignature(sourceUrl, signature)) {
|
||||
return new Response('Invalid or missing signature for URL lookup', { status: 403 });
|
||||
}
|
||||
const result = await sql`
|
||||
SELECT image_data, content_type, source_url
|
||||
FROM image_cache
|
||||
|
||||
@@ -11,7 +11,9 @@ import { checkRateLimit, recordRequest } from '../../../utils/rateLimit.ts';
|
||||
import type { APIContext } from 'astro';
|
||||
|
||||
const VIDEO_CACHE_SECRET = import.meta.env.IMAGE_CACHE_SECRET; // share same secret for simplicity
|
||||
if (!VIDEO_CACHE_SECRET) {
|
||||
if (!VIDEO_CACHE_SECRET && import.meta.env.PROD) {
|
||||
throw new Error('CRITICAL: IMAGE_CACHE_SECRET environment variable is not set in production!');
|
||||
} else if (!VIDEO_CACHE_SECRET) {
|
||||
console.error('WARNING: IMAGE_CACHE_SECRET not set, video signing may be unavailable');
|
||||
}
|
||||
|
||||
@@ -19,7 +21,14 @@ export function signVideoId(videoId: string | number): string {
|
||||
if (!VIDEO_CACHE_SECRET) throw new Error('VIDEO_CACHE_SECRET not configured');
|
||||
const hmac = crypto.createHmac('sha256', VIDEO_CACHE_SECRET);
|
||||
hmac.update(String(videoId));
|
||||
return hmac.digest('hex').substring(0, 16);
|
||||
return hmac.digest('hex').substring(0, 32); // 128-bit signature
|
||||
}
|
||||
|
||||
export function signVideoUrl(sourceUrl: string): string {
|
||||
if (!VIDEO_CACHE_SECRET) throw new Error('VIDEO_CACHE_SECRET not configured');
|
||||
const hmac = crypto.createHmac('sha256', VIDEO_CACHE_SECRET);
|
||||
hmac.update(sourceUrl);
|
||||
return hmac.digest('hex').substring(0, 32); // 128-bit signature
|
||||
}
|
||||
|
||||
function verifySignature(id: string | number, signature: string | null): boolean {
|
||||
@@ -32,6 +41,16 @@ function verifySignature(id: string | number, signature: string | null): boolean
|
||||
}
|
||||
}
|
||||
|
||||
function verifyUrlSignature(sourceUrl: string, signature: string | null): boolean {
|
||||
if (!VIDEO_CACHE_SECRET || !signature) return false;
|
||||
const expected = signVideoUrl(sourceUrl);
|
||||
try {
|
||||
return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
interface StreamResult {
|
||||
status: number;
|
||||
stream?: ReadableStream;
|
||||
@@ -87,7 +106,11 @@ export async function GET({ request }: APIContext): Promise<Response> {
|
||||
if (id) {
|
||||
const r = await sql`SELECT video_id, file_path, content_type, file_size FROM video_cache WHERE video_id = ${id}`;
|
||||
row = r[0];
|
||||
} else {
|
||||
} else if (sourceUrl) {
|
||||
// Require signature for URL-based lookups to prevent enumeration
|
||||
if (!verifyUrlSignature(sourceUrl, signature)) {
|
||||
return new Response('Invalid or missing signature for URL lookup', { status: 403 });
|
||||
}
|
||||
const r = await sql`SELECT video_id, file_path, content_type, file_size FROM video_cache WHERE source_url = ${sourceUrl} LIMIT 1`;
|
||||
row = r[0];
|
||||
}
|
||||
|
||||
@@ -14,8 +14,10 @@ import {
|
||||
|
||||
// Secret key for signing URLs - MUST be set in production
|
||||
const SIGNING_SECRET = import.meta.env.IMAGE_PROXY_SECRET;
|
||||
if (!SIGNING_SECRET) {
|
||||
console.error('CRITICAL: IMAGE_PROXY_SECRET environment variable is not set!');
|
||||
if (!SIGNING_SECRET && import.meta.env.PROD) {
|
||||
throw new Error('CRITICAL: IMAGE_PROXY_SECRET environment variable is not set in production!');
|
||||
} else if (!SIGNING_SECRET) {
|
||||
console.error('WARNING: IMAGE_PROXY_SECRET environment variable is not set!');
|
||||
}
|
||||
|
||||
// Private IP ranges to block (SSRF protection)
|
||||
|
||||
@@ -23,7 +23,7 @@ const getCookie = (name: string): string | null => {
|
||||
// Detect if returnUrl is external (nginx-protected vhost redirect)
|
||||
// If logged-in user arrives with external returnUrl, nginx denied them - show access denied
|
||||
const returnUrl = Astro.url.searchParams.get('returnUrl') || Astro.url.searchParams.get('redirect');
|
||||
const trustedDomains = ['codey.lol', 'boatson.boats'];
|
||||
const trustedDomains = ['codey.horse', 'boatson.boats'];
|
||||
let isExternalReturn = false;
|
||||
let externalHostname = '';
|
||||
|
||||
|
||||
@@ -39,7 +39,7 @@ export const authFetch = async (
|
||||
};
|
||||
|
||||
// Centralized refresh function that handles deduplication properly
|
||||
async function doRefresh(): Promise<boolean> {
|
||||
export async function doRefresh(): Promise<boolean> {
|
||||
const now = Date.now();
|
||||
|
||||
// If a refresh just succeeded recently, assume we're good
|
||||
|
||||
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