${escapeHtml(main)}
@@ -1220,9 +1808,9 @@ function editUserStation(idx) {
const s = list[idx];
if (!s) return;
if (usTitle) usTitle.value = s.title || s.name || '';
- if (usUrl) usUrl.value = s.url || s.liveAudio || '';
- if (usLogo) usLogo.value = s.logo || '';
- if (usWww) usWww.value = s.www || s.website || '';
+ if (usUrl) usUrl.value = s.url || s.streams?.audio || s.liveAudio || '';
+ if (usLogo) usLogo.value = s.logo || s.assets?.logo || '';
+ if (usWww) usWww.value = s.website || s.www || '';
if (usId) usId.value = s.id || '';
if (usIndex) usIndex.value = String(idx);
}
@@ -1248,10 +1836,17 @@ addStationForm?.addEventListener('submit', (e) => {
const station = {
id: usId?.value || `user-${Date.now()}`,
- title: usTitle?.value.trim() ?? '',
- url: urlValue,
- logo: usLogo?.value.trim() ?? '',
- www: usWww?.value.trim() ?? '',
+ name: usTitle?.value.trim() ?? '',
+ category: 'Custom',
+ country: '',
+ language: '',
+ website: usWww?.value.trim() ?? '',
+ assets: {
+ logo: usLogo?.value.trim() ?? '',
+ },
+ streams: {
+ audio: urlValue,
+ },
enabled: true,
};
@@ -1287,12 +1882,25 @@ function setupEventListeners() {
castOverlay?.addEventListener('click', (e) => { if (e.target === castOverlay) closeCastOverlay(); });
editBtn?.addEventListener('click', openEditorOverlay);
- stationsListBtn?.addEventListener('click', openStationsOverlay);
+ stationsListBtn?.addEventListener('click', toggleStationLibrary);
installAppBtn?.addEventListener('click', promptInstallApp);
castBtn?.addEventListener('click', requestCastSession);
editorCloseBtn?.addEventListener('click', closeEditorOverlay);
+ stationLibraryCloseBtn?.addEventListener('click', closeStationLibrary);
+ stationSearchInput?.addEventListener('input', () => {
+ stationLibraryQuery = stationSearchInput.value || '';
+ renderStationLibrary();
+ });
+ stationCountryFilterEl?.addEventListener('change', () => {
+ stationLibraryCountry = stationCountryFilterEl.value || 'all';
+ stationLibraryCategory = 'all';
+ renderStationLibrary();
+ });
+ stationTabBtns.forEach((btn) => {
+ btn.addEventListener('click', () => setStationLibraryTab(btn.dataset.stationTab || 'all'));
+ });
- artworkPlaceholder?.addEventListener('click', openStationsOverlay);
+ artworkPlaceholder?.addEventListener('click', openStationLibrary);
castOutputBtn?.addEventListener('click', toggleCastBothMode);
window.addEventListener('resize', updateCoverflowTransforms);
@@ -1303,6 +1911,7 @@ function setupEventListeners() {
else if (e.code === 'ArrowRight') playNext();
else if (e.code === 'ArrowLeft') playPrev();
else if (e.code === 'KeyM') toggleMute();
+ else if (e.code === 'Escape') { closeStationLibrary(); closeCastOverlay(); closeEditorOverlay(); }
});
}
diff --git a/src/radio/loadRadioStations.ts b/src/radio/loadRadioStations.ts
new file mode 100644
index 0000000..24321d5
--- /dev/null
+++ b/src/radio/loadRadioStations.ts
@@ -0,0 +1,12 @@
+import type { RadioStation } from './radioTypes.js';
+
+export async function loadRadioStations(): Promise
{
+ const response = await fetch('/data/radio-stations.json');
+
+ if (!response.ok) {
+ throw new Error(`Failed to load radio stations: ${response.status}`);
+ }
+
+ const stations = (await response.json()) as RadioStation[];
+ return Array.isArray(stations) ? stations : [];
+}
\ No newline at end of file
diff --git a/src/radio/radioCountries.ts b/src/radio/radioCountries.ts
new file mode 100644
index 0000000..3ce82bd
--- /dev/null
+++ b/src/radio/radioCountries.ts
@@ -0,0 +1,24 @@
+export const radioCountries = [
+ { name: 'Austria', code: 'AT' },
+ { name: 'Croatia', code: 'HR' },
+ { name: 'Serbia', code: 'RS' },
+ { name: 'Montenegro', code: 'ME' },
+ { name: 'Bosnia & Herzegovina', code: 'BA' },
+ { name: 'Germany', code: 'DE' },
+ { name: 'United Kingdom', code: 'GB' },
+ { name: 'Italy', code: 'IT' },
+ { name: 'France', code: 'FR' },
+ { name: 'Spain', code: 'ES' },
+ { name: 'USA', code: 'US' },
+ { name: 'Canada', code: 'CA' },
+ { name: 'Australia', code: 'AU' },
+ { name: 'Luxembourg', code: 'LU' },
+ { name: 'Netherlands', code: 'NL' },
+ { name: 'Sweden', code: 'SE' },
+ { name: 'Switzerland', code: 'CH' },
+ { name: 'Hungary', code: 'HU' },
+ { name: 'Czechia', code: 'CZ' },
+ { name: 'Poland', code: 'PL' },
+] as const;
+
+export type RadioCountry = (typeof radioCountries)[number];
\ No newline at end of file
diff --git a/src/radio/radioStationNormalizer.ts b/src/radio/radioStationNormalizer.ts
new file mode 100644
index 0000000..2fdfd3e
--- /dev/null
+++ b/src/radio/radioStationNormalizer.ts
@@ -0,0 +1,96 @@
+import type { RadioBrowserStation, RadioStation } from './radioTypes.js';
+
+const MAX_TAGS = 12;
+const OBVIOUSLY_UNSUPPORTED_CODECS = new Set([
+ 'wma',
+ 'wmav2',
+ 'asf',
+ 'ra',
+ 'rm',
+ 'ape',
+ 'alac',
+ 'amr',
+]);
+
+function trimToNull(value: string | null | undefined): string | null {
+ if (typeof value !== 'string') return null;
+ const trimmed = value.trim();
+ return trimmed.length > 0 ? trimmed : null;
+}
+
+function toNumber(value: number | string | null | undefined): number {
+ const parsed = typeof value === 'number' ? value : Number(value ?? 0);
+ return Number.isFinite(parsed) ? parsed : 0;
+}
+
+function toNullableNumber(value: number | string | null | undefined): number | null {
+ const parsed = typeof value === 'number' ? value : Number(value ?? NaN);
+ return Number.isFinite(parsed) && parsed >= 0 ? parsed : null;
+}
+
+function parseTags(tags: string | null | undefined): string[] {
+ if (typeof tags !== 'string' || tags.trim().length === 0) return [];
+
+ const seen = new Set();
+ const parsed: string[] = [];
+
+ for (const rawTag of tags.split(',')) {
+ const tag = rawTag.trim();
+ if (!tag) continue;
+ const normalizedTag = tag.toLowerCase();
+ if (seen.has(normalizedTag)) continue;
+ seen.add(normalizedTag);
+ parsed.push(tag);
+ if (parsed.length >= MAX_TAGS) break;
+ }
+
+ return parsed;
+}
+
+function isHttpsUrl(value: string | null): value is string {
+ if (!value) return false;
+
+ try {
+ const url = new URL(value);
+ return url.protocol === 'https:';
+ } catch {
+ return false;
+ }
+}
+
+export function normalizeRadioBrowserStation(
+ station: RadioBrowserStation,
+ countryName: string,
+): RadioStation | null {
+ const stationUuid = trimToNull(station.stationuuid);
+ if (!stationUuid) return null;
+
+ const name = trimToNull(station.name);
+ if (!name) return null;
+
+ const streamUrl = trimToNull(station.url_resolved) ?? trimToNull(station.url);
+ if (!isHttpsUrl(streamUrl)) return null;
+
+ const codec = trimToNull(station.codec);
+ if (codec && OBVIOUSLY_UNSUPPORTED_CODECS.has(codec.toLowerCase())) {
+ return null;
+ }
+
+ return {
+ id: stationUuid,
+ name,
+ country: trimToNull(station.country) ?? countryName,
+ countryCode: trimToNull(station.countrycode) ?? '',
+ language: trimToNull(station.language),
+ tags: parseTags(station.tags),
+ codec,
+ bitrate: toNullableNumber(station.bitrate),
+ streamUrl,
+ homepage: trimToNull(station.homepage),
+ logoUrl: trimToNull(station.favicon),
+ votes: toNumber(station.votes),
+ clickcount: toNumber(station.clickcount),
+ source: 'radio-browser',
+ sourceStationUuid: stationUuid,
+ };
+}
\ No newline at end of file
diff --git a/src/radio/radioTypes.ts b/src/radio/radioTypes.ts
new file mode 100644
index 0000000..6aaeb37
--- /dev/null
+++ b/src/radio/radioTypes.ts
@@ -0,0 +1,34 @@
+export type RadioBrowserStation = {
+ stationuuid?: string | null;
+ name?: string | null;
+ url?: string | null;
+ url_resolved?: string | null;
+ homepage?: string | null;
+ favicon?: string | null;
+ country?: string | null;
+ countrycode?: string | null;
+ language?: string | null;
+ tags?: string | null;
+ codec?: string | null;
+ bitrate?: number | string | null;
+ votes?: number | string | null;
+ clickcount?: number | string | null;
+};
+
+export type RadioStation = {
+ id: string;
+ name: string;
+ country: string;
+ countryCode: string;
+ language: string | null;
+ tags: string[];
+ codec: string | null;
+ bitrate: number | null;
+ streamUrl: string;
+ homepage: string | null;
+ logoUrl: string | null;
+ votes: number;
+ clickcount: number;
+ source: 'radio-browser';
+ sourceStationUuid: string;
+};
\ No newline at end of file
diff --git a/src/styles.css b/src/styles.css
index 965ecb3..406bd4b 100644
--- a/src/styles.css
+++ b/src/styles.css
@@ -114,6 +114,9 @@ input {
button:focus-visible,
input:focus-visible,
.station-card:focus-visible,
+.library-station:focus-visible,
+.library-tab:focus-visible,
+.category-chip:focus-visible,
.coverflow-item:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 3px;
@@ -141,6 +144,421 @@ input:focus-visible,
place-items: center;
}
+.player-layout {
+ position: relative;
+ isolation: isolate;
+ width: min(1420px, 100%);
+ height: clamp(680px, calc(100vh - 72px), 880px);
+ display: flex;
+ flex-direction: row;
+ align-items: stretch;
+ min-height: 0;
+ overflow: hidden;
+}
+
+.sidebar-wrap {
+ flex-shrink: 0;
+ width: 320px;
+ margin-right: 18px;
+ overflow: hidden;
+ will-change: width, margin-right;
+ transition:
+ width 0.46s cubic-bezier(0.22, 1, 0.36, 1),
+ margin-right 0.46s cubic-bezier(0.22, 1, 0.36, 1);
+}
+
+.player-layout.library-collapsed .sidebar-wrap {
+ width: 0;
+ margin-right: 0;
+}
+
+.player-layout.library-collapsed {
+ /* desktop collapse handled by .sidebar-wrap */
+}
+
+.station-library {
+ position: relative;
+ min-width: 0;
+ width: 100%;
+ height: 100%;
+ min-height: 0;
+ max-height: 100%;
+ display: grid;
+ grid-template-rows: auto auto auto auto auto minmax(0, 1fr);
+ gap: 14px;
+ padding: 20px;
+ border: 1px solid var(--border);
+ border-radius: 28px;
+ background:
+ linear-gradient(180deg, rgba(var(--theme-panel-rgb), 0.84), rgba(255,255,255,0.035)),
+ linear-gradient(140deg, rgba(var(--accent-rgb), 0.1), transparent 52%);
+ box-shadow: 0 24px 64px rgba(0,0,0,0.32);
+ backdrop-filter: blur(24px) saturate(130%);
+ overflow: hidden;
+ transform-origin: left center;
+ will-change: transform, opacity;
+ transition:
+ opacity 0.38s ease,
+ transform 0.46s cubic-bezier(0.22, 1, 0.36, 1),
+ filter 0.38s ease;
+}
+
+.player-layout.library-collapsed .station-library {
+ opacity: 0;
+ pointer-events: none;
+ transform: translateX(-18px) scale(0.97);
+ filter: blur(5px);
+}
+
+.library-top {
+ display: flex;
+ align-items: start;
+ justify-content: space-between;
+ gap: 12px;
+}
+
+.library-eyebrow {
+ margin: 0 0 4px;
+ color: var(--accent);
+ font-size: 0.74rem;
+ font-weight: 850;
+ letter-spacing: 0.04em;
+ text-transform: uppercase;
+}
+
+.library-top h2 {
+ margin: 0;
+ color: var(--text-main);
+ font-size: 1.35rem;
+ line-height: 1;
+}
+
+.library-close {
+ display: inline-flex;
+}
+
+.library-search {
+ min-width: 0;
+ height: 44px;
+ display: flex;
+ align-items: center;
+ gap: 9px;
+ padding: 0 12px;
+ border: 1px solid rgba(255,255,255,0.12);
+ border-radius: 14px;
+ background: rgba(255,255,255,0.065);
+ color: var(--text-muted);
+}
+
+.library-filter-grid {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) 148px;
+ gap: 10px;
+}
+
+.library-select {
+ min-width: 0;
+ display: grid;
+ gap: 6px;
+}
+
+.library-select-label {
+ color: var(--text-soft);
+ font-size: 0.72rem;
+ font-weight: 800;
+ letter-spacing: 0.04em;
+ text-transform: uppercase;
+}
+
+.library-select select {
+ width: 100%;
+ min-width: 0;
+ height: 44px;
+ padding: 0 12px;
+ border: 1px solid rgba(255,255,255,0.12);
+ border-radius: 14px;
+ background: rgba(255,255,255,0.065);
+ color: var(--text-main);
+ font: inherit;
+ appearance: none;
+}
+
+.library-search input {
+ width: 100%;
+ min-width: 0;
+ border: 0;
+ outline: 0;
+ background: transparent;
+ color: var(--text-main);
+ font-size: 0.92rem;
+}
+
+.library-search input::placeholder {
+ color: var(--text-soft);
+}
+
+.library-tabs {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 8px;
+}
+
+.library-tab {
+ min-width: 0;
+ min-height: 36px;
+ padding: 8px 10px;
+ border: 1px solid rgba(255,255,255,0.1);
+ border-radius: 12px;
+ background: rgba(255,255,255,0.055);
+ color: var(--text-muted);
+ font-size: 0.82rem;
+ font-weight: 800;
+ transition: background 0.16s ease, border-color 0.16s ease, color 0.16s ease, transform 0.16s ease;
+}
+
+.library-tab:hover {
+ transform: translateY(-1px);
+ border-color: var(--border-strong);
+ color: var(--text-main);
+}
+
+.library-tab.active {
+ border-color: rgba(var(--accent-rgb), 0.48);
+ background: rgba(var(--accent-rgb), 0.15);
+ color: var(--text-main);
+ box-shadow: 0 10px 24px rgba(var(--accent-rgb), 0.12);
+}
+
+.category-list {
+ display: none;
+ gap: 8px;
+ overflow-x: auto;
+ padding-bottom: 2px;
+}
+
+.station-library.show-categories .category-list {
+ display: flex;
+}
+
+.category-chip {
+ flex: 0 0 auto;
+ min-height: 32px;
+ padding: 7px 11px;
+ border: 1px solid rgba(255,255,255,0.1);
+ border-radius: 999px;
+ background: rgba(255,255,255,0.055);
+ color: var(--text-muted);
+ font-size: 0.78rem;
+ font-weight: 800;
+}
+
+.category-chip.active {
+ border-color: rgba(var(--accent-rgb), 0.5);
+ background: rgba(var(--accent-rgb), 0.16);
+ color: var(--text-main);
+}
+
+.library-summary {
+ min-height: 18px;
+ color: var(--text-soft);
+ font-size: 0.78rem;
+ font-weight: 700;
+}
+
+.library-list {
+ min-height: 0;
+ margin: 0;
+ padding: 2px 8px 2px 0;
+ list-style: none;
+ overflow-y: auto;
+ scrollbar-width: thin;
+ scrollbar-color: rgba(var(--accent-rgb), 0.72) rgba(255,255,255,0.06);
+ mask-image: linear-gradient(to bottom, transparent, #000 12px, #000 calc(100% - 12px), transparent);
+}
+
+.library-list::-webkit-scrollbar {
+ width: 12px;
+}
+
+.library-list::-webkit-scrollbar-track {
+ margin-block: 6px;
+ border-radius: 999px;
+ background:
+ linear-gradient(180deg, rgba(var(--accent-rgb), 0.08), rgba(var(--accent-3-rgb), 0.08)),
+ rgba(255,255,255,0.045);
+ box-shadow: inset 0 0 0 1px rgba(255,255,255,0.06);
+}
+
+.library-list::-webkit-scrollbar-thumb {
+ min-height: 56px;
+ border: 3px solid transparent;
+ border-radius: 999px;
+ background:
+ linear-gradient(180deg, var(--accent), var(--accent-3)) padding-box,
+ rgba(255,255,255,0.12) border-box;
+ box-shadow:
+ 0 0 16px rgba(var(--accent-rgb), 0.24),
+ inset 0 0 0 1px rgba(255,255,255,0.18);
+}
+
+.library-list::-webkit-scrollbar-thumb:hover {
+ background:
+ linear-gradient(180deg, var(--accent), var(--accent-2)) padding-box,
+ rgba(255,255,255,0.18) border-box;
+}
+
+.library-empty {
+ padding: 18px 14px;
+ border: 1px dashed rgba(255,255,255,0.16);
+ border-radius: 16px;
+ color: var(--text-muted);
+ font-size: 0.88rem;
+ line-height: 1.35;
+ background: rgba(255,255,255,0.035);
+}
+
+.library-station {
+ width: 100%;
+ min-width: 0;
+ min-height: 72px;
+ display: grid;
+ grid-template-columns: 48px minmax(0, 1fr) 34px;
+ gap: 11px;
+ align-items: center;
+ margin-bottom: 10px;
+ padding: 10px;
+ border: 1px solid rgba(255,255,255,0.1);
+ border-radius: 17px;
+ background: rgba(255,255,255,0.052);
+ color: var(--text-main);
+ text-align: left;
+ cursor: pointer;
+ transition: transform 0.16s ease, border-color 0.16s ease, background 0.16s ease;
+}
+
+.library-station:hover {
+ transform: translateY(-1px);
+ border-color: var(--border-strong);
+ background: rgba(255,255,255,0.085);
+}
+
+.library-station.current {
+ border-color: rgba(var(--accent-rgb), 0.56);
+ background:
+ linear-gradient(135deg, rgba(var(--accent-rgb), 0.18), rgba(var(--accent-3-rgb), 0.1)),
+ rgba(255,255,255,0.05);
+}
+
+.library-station-logo,
+.library-station-fallback {
+ width: 48px;
+ height: 48px;
+ border-radius: 14px;
+ background: rgba(255,255,255,0.08);
+}
+
+.library-station-logo {
+ object-fit: contain;
+ padding: 7px;
+}
+
+.library-station-fallback {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ color: var(--text-main);
+ font-size: 1rem;
+ font-weight: 850;
+}
+
+.library-station-copy {
+ min-width: 0;
+ display: grid;
+ gap: 6px;
+}
+
+.library-station-title {
+ overflow: hidden;
+ color: var(--text-main);
+ font-size: 0.94rem;
+ font-weight: 850;
+ line-height: 1.12;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.library-station-meta {
+ display: flex;
+ flex-wrap: wrap;
+ min-width: 0;
+ align-items: center;
+ gap: 7px;
+ color: var(--text-muted);
+ font-size: 0.76rem;
+ font-weight: 700;
+}
+
+.library-station-country,
+.library-station-tech {
+ flex: 0 0 auto;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.library-station-tech {
+ color: var(--text-soft);
+}
+
+.library-station-tags {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px;
+}
+
+.library-tag {
+ max-width: 100%;
+ padding: 4px 8px;
+ border: 1px solid rgba(255,255,255,0.1);
+ border-radius: 999px;
+ background: rgba(255,255,255,0.045);
+ color: var(--text-main);
+ font-size: 0.7rem;
+ font-weight: 700;
+ line-height: 1.2;
+}
+
+.library-tag.muted {
+ color: var(--text-soft);
+}
+
+.favorite-btn {
+ width: 34px;
+ height: 34px;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ border: 1px solid rgba(255,255,255,0.11);
+ border-radius: 12px;
+ background: rgba(255,255,255,0.055);
+ color: var(--text-soft);
+ font-size: 1.06rem;
+ line-height: 1;
+ cursor: pointer;
+ transition: transform 0.14s ease, color 0.14s ease, background 0.14s ease, border-color 0.14s ease;
+}
+
+.favorite-btn:hover {
+ transform: scale(1.05);
+ color: var(--text-main);
+}
+
+.favorite-btn.active {
+ border-color: rgba(var(--accent-2-rgb), 0.48);
+ background: rgba(var(--accent-2-rgb), 0.14);
+ color: var(--accent-2);
+}
+
.starfield {
position: absolute;
inset: 0;
@@ -238,12 +656,12 @@ input:focus-visible,
.glass-card {
position: relative;
- z-index: 1;
+ z-index: 2;
overflow: hidden;
- width: min(1060px, 100%);
- max-width: 100%;
+ flex: 1 1 0;
min-width: 0;
- min-height: min(720px, calc(100vh - 48px));
+ height: 100%;
+ min-height: 0;
display: grid;
grid-template-columns: minmax(300px, 0.92fr) minmax(340px, 1.08fr);
grid-template-areas:
@@ -263,14 +681,31 @@ input:focus-visible,
linear-gradient(180deg, rgba(var(--theme-panel-rgb), 0.72), rgba(255,255,255,0.03));
box-shadow: var(--shadow);
backdrop-filter: blur(26px) saturate(135%);
- transition: background 0.45s ease, border-color 0.45s ease;
+ transform: translateY(-4px) scale(0.993);
+ transform-origin: center center;
+ will-change: transform;
+ transition:
+ background 0.45s ease,
+ border-color 0.45s ease,
+ box-shadow 0.46s cubic-bezier(0.22, 1, 0.36, 1),
+ transform 0.46s cubic-bezier(0.22, 1, 0.36, 1);
}
-.glass-card > :not(.starfield) {
+.player-layout.library-collapsed .glass-card {
+ transform: translateY(0) scale(1);
+ box-shadow: 0 24px 56px rgba(0,0,0,0.28);
+}
+
+.glass-card > :not(.starfield):not(.overlay) {
position: relative;
z-index: 2;
}
+.glass-card > .overlay {
+ position: fixed;
+ z-index: 1000;
+}
+
header {
grid-area: header;
}
@@ -1087,7 +1522,6 @@ input[type=range]::-webkit-slider-thumb {
.cast-btn,
.install-btn {
width: auto;
- min-width: 82px;
padding: 0 12px;
gap: 8px;
}
@@ -1109,13 +1543,69 @@ input[type=range]::-webkit-slider-thumb {
background: rgba(143,179,255,0.14);
}
+@media (max-width: 1100px) {
+ body.library-open {
+ overflow: hidden;
+ }
+
+ .player-layout {
+ height: auto;
+ min-height: min(720px, calc(100vh - 48px));
+ width: min(1060px, 100%);
+ }
+
+ .player-layout.library-collapsed {
+ width: min(1060px, 100%);
+ }
+
+ .sidebar-wrap {
+ display: contents;
+ }
+
+ .station-library {
+ position: fixed;
+ left: 12px;
+ right: 12px;
+ bottom: 12px;
+ z-index: 1200;
+ min-height: 0;
+ height: auto;
+ max-height: min(82vh, 720px);
+ grid-template-rows: auto auto auto auto auto minmax(240px, 1fr);
+ padding: 18px;
+ border-radius: 24px;
+ transform: translateY(calc(100% + 24px));
+ opacity: 0;
+ visibility: hidden;
+ pointer-events: none;
+ transition: transform 0.24s ease, opacity 0.24s ease, visibility 0.24s ease;
+ }
+
+ .station-library.open {
+ transform: translateY(0);
+ opacity: 1;
+ visibility: visible;
+ pointer-events: auto;
+ }
+
+ .library-close {
+ display: inline-flex;
+ }
+}
+
@media (max-width: 760px) {
.app-container {
align-items: start;
padding: 10px;
}
+ .player-layout {
+ width: 100%;
+ min-height: 0;
+ }
+
.glass-card {
+ height: auto;
min-height: calc(100vh - 20px);
width: 100%;
grid-template-columns: 1fr;
@@ -1131,6 +1621,58 @@ input[type=range]::-webkit-slider-thumb {
border-radius: 22px;
}
+ .station-library {
+ left: 8px;
+ right: 8px;
+ bottom: 8px;
+ max-height: 86vh;
+ padding: 15px;
+ border-radius: 22px;
+ gap: 11px;
+ }
+
+ .library-top h2 {
+ font-size: 1.18rem;
+ }
+
+ .library-tabs {
+ grid-template-columns: repeat(4, minmax(0, 1fr));
+ gap: 6px;
+ overflow-x: auto;
+ }
+
+ .library-filter-grid {
+ grid-template-columns: 1fr;
+ }
+
+ .library-tab {
+ min-width: 82px;
+ min-height: 34px;
+ padding-inline: 8px;
+ font-size: 0.76rem;
+ }
+
+ .library-station {
+ min-height: 66px;
+ grid-template-columns: 44px minmax(0, 1fr) 32px;
+ gap: 9px;
+ padding: 9px;
+ border-radius: 15px;
+ }
+
+ .library-station-logo,
+ .library-station-fallback {
+ width: 44px;
+ height: 44px;
+ border-radius: 13px;
+ }
+
+ .favorite-btn {
+ width: 32px;
+ height: 32px;
+ border-radius: 11px;
+ }
+
.brand-block {
gap: 9px;
}
diff --git a/tsconfig.json b/tsconfig.json
new file mode 100644
index 0000000..3aa369a
--- /dev/null
+++ b/tsconfig.json
@@ -0,0 +1,16 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "module": "NodeNext",
+ "moduleResolution": "NodeNext",
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
+ "strict": true,
+ "noEmit": true,
+ "skipLibCheck": true,
+ "resolveJsonModule": true,
+ "verbatimModuleSyntax": true,
+ "isolatedModules": true,
+ "forceConsistentCasingInFileNames": true
+ },
+ "include": ["src/**/*.ts", "scripts/**/*.ts"]
+}
\ No newline at end of file