Add anonymous listening support and redesign web-player UI

Backend:
- Expose GET /config/playback so the frontend can detect whether
  anonymous listening is enabled without requiring auth
- Wire anonymous access through track streaming and YouTube resolve flows

Frontend:
- Allow browsing/listening without a session when the backend enables it
- Redesign Inicio, Sidebar and top bar with a new dark theme, typography,
  and layout (top bar with live quick-search, sticky/blurred on scroll)
- Populate Top música, Popular and Recomendados with real Musify +
  YouTube Music data instead of placeholders
- Various shape, spacing, color and animation refinements across the
  redesigned components
This commit is contained in:
2026-09-11 01:29:37 +02:00
parent 10766bbb47
commit 389b3b9244
40 changed files with 1172 additions and 452 deletions
+42
View File
@@ -177,6 +177,23 @@ export interface paths {
patch?: never;
trace?: never;
};
"/config/playback": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Get The Public Playback Configuration (Whether Anonymous Listening Is Allowed). Works Anonymously. */
get: operations["GetPlaybackPublicConfig"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/mixes": {
parameters: {
query?: never;
@@ -742,6 +759,11 @@ export interface components {
hasNextPage: boolean;
hasPreviousPage: boolean;
};
PlaybackPublicConfigResponse: {
allowAnonymousListening: boolean;
/** Format: int32 */
anonymousFragmentSeconds: number | string;
};
PlayListApplicationResponse: {
/** Format: uuid */
id: string;
@@ -1423,6 +1445,26 @@ export interface operations {
};
};
};
GetPlaybackPublicConfig: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["PlaybackPublicConfigResponse"];
};
};
};
};
GetMixes: {
parameters: {
query?: never;
@@ -3,10 +3,11 @@
import Upload from '@lucide/svelte/icons/upload';
import Folder from '@lucide/svelte/icons/folder';
import LogOut from '@lucide/svelte/icons/log-out';
import LogIn from '@lucide/svelte/icons/log-in';
import MenuItem from './ui/MenuItem.svelte';
interface Props {
user: SessionUser;
user: SessionUser | null;
}
let { user }: Props = $props();
@@ -25,43 +26,54 @@
<a href="/" class="font-display text-lg font-semibold tracking-tight text-fg">Musify</a>
<div class="relative" bind:this={menuRef}>
<button
type="button"
onclick={() => (menuOpen = !menuOpen)}
aria-label="Tu cuenta"
aria-expanded={menuOpen}
class="flex items-center rounded-full"
>
{#if user.picture}
<img src={user.picture} alt="" class="h-9 w-9 rounded-full object-cover" />
{:else}
<span class="grid h-9 w-9 place-items-center rounded-full bg-surface-2 text-sm uppercase">
{user.name.charAt(0)}
</span>
{/if}
</button>
{#if menuOpen}
<div
class="animate-pop absolute top-full right-0 mt-2 w-56 overflow-hidden rounded-panel border border-line bg-elevated p-1.5 shadow-menu"
{#if user}
<button
type="button"
onclick={() => (menuOpen = !menuOpen)}
aria-label="Tu cuenta"
aria-expanded={menuOpen}
class="flex items-center rounded-full"
>
<MenuItem
icon={Folder}
label="Canciones subidas"
href="/library"
onclick={() => (menuOpen = false)}
/>
<MenuItem
icon={Upload}
label="Subir música"
href="/upload"
onclick={() => (menuOpen = false)}
/>
<div class="-mx-1.5 my-1.5 border-t border-line"></div>
<form method="POST" action="/logout" data-sveltekit-reload>
<MenuItem icon={LogOut} label="Salir" type="submit" />
</form>
</div>
{#if user.picture}
<img src={user.picture} alt="" class="h-9 w-9 rounded-full object-cover" />
{:else}
<span class="grid h-9 w-9 place-items-center rounded-full bg-surface-2 text-sm uppercase">
{user.name.charAt(0)}
</span>
{/if}
</button>
{#if menuOpen}
<div
class="animate-pop absolute top-full right-0 mt-2 w-56 overflow-hidden rounded-panel border border-line bg-elevated p-1.5 shadow-menu"
>
<MenuItem
icon={Folder}
label="Canciones subidas"
href="/library"
onclick={() => (menuOpen = false)}
/>
<MenuItem
icon={Upload}
label="Subir música"
href="/upload"
onclick={() => (menuOpen = false)}
/>
<div class="-mx-1.5 my-1.5 border-t border-line"></div>
<form method="POST" action="/logout" data-sveltekit-reload>
<MenuItem icon={LogOut} label="Salir" type="submit" />
</form>
</div>
{/if}
{:else}
<a
href="/auth/login"
data-sveltekit-reload
aria-label="Iniciar sesión"
class="flex items-center rounded-full p-1.5 text-fg-3 transition hover:bg-hover hover:text-fg"
>
<LogIn class="h-6 w-6" strokeWidth={2} />
</a>
{/if}
</div>
</header>
+16 -5
View File
@@ -1,17 +1,28 @@
<script lang="ts">
import { page } from '$app/state';
import { isSectionActive } from '$lib/navigation.svelte';
import type { SessionUser } from '$lib/types';
import Home from '@lucide/svelte/icons/house';
import Search from '@lucide/svelte/icons/search';
import Music from '@lucide/svelte/icons/music';
import Folder from '@lucide/svelte/icons/folder';
const links = [
interface Props {
user: SessionUser | null;
}
let { user }: Props = $props();
const links = $derived([
{ href: '/', label: 'Inicio', icon: Home },
{ href: '/explore', label: 'Buscar', icon: Search },
{ href: '/playlists', label: 'Listas', icon: Music },
{ href: '/library', label: 'Subidas', icon: Folder }
];
...(user
? [
{ href: '/playlists', label: 'Listas', icon: Music },
{ href: '/library', label: 'Subidas', icon: Folder }
]
: [])
]);
function isActive(href: string) {
return isSectionActive(href, page.url.pathname, page.data.section);
@@ -22,7 +33,7 @@
class="fixed inset-x-0 bottom-0 z-50 border-t border-line bg-bg/95 backdrop-blur-md md:hidden"
style="padding-bottom:var(--mf-safe-b)"
>
<ul class="grid h-[var(--mf-nav-h)] grid-cols-4">
<ul class="grid h-[var(--mf-nav-h)]" style="grid-template-columns:repeat({links.length}, 1fr)">
{#each links as link (link.href)}
{@const active = isActive(link.href)}
<li>
+44 -68
View File
@@ -3,44 +3,37 @@
import { isSectionActive } from '$lib/navigation.svelte';
import type { SessionUser } from '$lib/types';
import Home from '@lucide/svelte/icons/house';
import Search from '@lucide/svelte/icons/search';
import Folder from '@lucide/svelte/icons/folder';
import Disc from '@lucide/svelte/icons/disc-2';
import Music from '@lucide/svelte/icons/music';
import Upload from '@lucide/svelte/icons/upload';
import LogOut from '@lucide/svelte/icons/log-out';
import MenuItem from './ui/MenuItem.svelte';
interface Props {
user: SessionUser;
user: SessionUser | null;
}
let { user }: Props = $props();
let menuOpen = $state(false);
let menuRef: HTMLDivElement | undefined = $state();
let navBox: HTMLElement | undefined = $state();
let pillTop = $state(0);
let pillHeight = $state(0);
let pillVisible = $state(false);
let animate = $state(false);
function onDocumentClick(event: MouseEvent) {
if (menuRef && !menuRef.contains(event.target as Node)) menuOpen = false;
}
const mainLinks = [
const mainLinks = $derived([
{ href: '/', label: 'Inicio', icon: Home },
{ href: '/explore', label: 'Buscar', icon: Search },
{ href: '/playlists', label: 'Listas', icon: Music }
];
...(user ? [{ href: '/playlists', label: 'Listas', icon: Music }] : [])
]);
const secondaryLinks = [
{ href: '/albums', label: 'Mis álbumes', icon: Disc },
{ href: '/library', label: 'Canciones subidas', icon: Folder },
{ href: '/upload', label: 'Subir música', icon: Upload }
];
const secondaryLinks = $derived(
user
? [
{ href: '/albums', label: 'Mis álbumes', icon: Disc },
{ href: '/library', label: 'Canciones subidas', icon: Folder },
{ href: '/upload', label: 'Subir música', icon: Upload }
]
: []
);
function isActive(href: string) {
return isSectionActive(href, page.url.pathname, page.data.section);
@@ -69,24 +62,31 @@
<a
href={link.href}
data-active={active}
class="relative flex items-center gap-4 rounded-control px-4 py-3 transition-colors duration-100 {active
class="relative flex items-center gap-3.5 rounded-control px-4 py-2.5 text-sm transition-colors duration-100 {active
? 'text-fg'
: 'text-fg-3 hover:bg-hover hover:text-fg'}"
: 'text-fg-2 hover:bg-hover hover:text-fg'}"
>
<link.icon class="h-6 w-6 shrink-0 {active ? 'text-accent-soft' : ''}" />
<link.icon class="h-5 w-5 shrink-0 {active ? 'text-accent-soft' : ''}" />
{link.label}
</a>
{/snippet}
{#snippet groupLabel(label: string)}
<p class="mb-4 px-4 text-sm font-semibold tracking-[0.12em] text-fg-2 uppercase">
{label}
</p>
{/snippet}
<aside
class="sticky top-0 hidden h-screen shrink-0 flex-col gap-7 border-r border-line px-4.5 py-6.5 md:flex"
class="sticky top-0 hidden h-screen shrink-0 flex-col border-r border-line bg-bg md:flex"
style="width:var(--mf-sidebar-w)"
>
<a href="/" class="px-3 font-display text-xl font-semibold tracking-tight text-fg">Musify</a>
<div class="relative flex flex-col gap-7" bind:this={navBox}>
<div
class="relative flex flex-1 flex-col gap-6 overflow-y-auto px-4.5 py-6 md:pt-8"
bind:this={navBox}
>
<div
class="pointer-events-none absolute inset-x-0 z-0 overflow-hidden rounded-control bg-surface-hover {animate
class="pointer-events-none absolute inset-x-4.5 z-0 overflow-hidden rounded-control border border-line bg-surface {animate
? 'transition-[top,height] duration-200 ease-in-out'
: ''}"
style="top:{pillTop}px; height:{pillHeight}px; opacity:{pillVisible ? 1 : 0}"
@@ -94,48 +94,24 @@
<span class="absolute inset-y-0 left-0 w-0.75 rounded-r-full bg-accent-soft"></span>
</div>
<nav class="relative z-10 flex flex-col gap-1">
{#each mainLinks as link (link.href)}
{@render navLink(link)}
{/each}
</nav>
<div>
{@render groupLabel('Navegación')}
<nav class="relative z-10 flex flex-col gap-2">
{#each mainLinks as link (link.href)}
{@render navLink(link)}
{/each}
</nav>
</div>
<div class="mx-2 border-t border-line"></div>
<nav class="relative z-10 flex flex-col gap-1">
{#each secondaryLinks as link (link.href)}
{@render navLink(link)}
{/each}
</nav>
</div>
<div class="relative mt-auto" bind:this={menuRef}>
{#if menuOpen}
<div
class="animate-pop absolute bottom-full left-0 mb-2 w-full overflow-hidden rounded-panel border border-line bg-elevated p-1.5 shadow-menu"
>
<form method="POST" action="/logout" data-sveltekit-reload>
<MenuItem icon={LogOut} label="Salir" type="submit" />
</form>
{#if secondaryLinks.length > 0}
<div>
{@render groupLabel('Tu música')}
<nav class="relative z-10 flex flex-col gap-1">
{#each secondaryLinks as link (link.href)}
{@render navLink(link)}
{/each}
</nav>
</div>
{/if}
<button
type="button"
onclick={() => (menuOpen = !menuOpen)}
class="flex w-full items-center gap-2.5 rounded-control px-3 py-2.5 text-left transition hover:bg-hover"
>
{#if user.picture}
<img src={user.picture} alt="" class="h-9 w-9 rounded-full object-cover" />
{:else}
<span
class="grid h-9 w-9 shrink-0 place-items-center rounded-full bg-surface-2 text-sm font-semibold uppercase"
>
{user.name.charAt(0)}
</span>
{/if}
<span class="truncate leading-[1.35] text-fg-3">{user.name}</span>
</button>
</div>
</aside>
<svelte:window onclick={onDocumentClick} />
+225
View File
@@ -0,0 +1,225 @@
<script lang="ts">
import { goto } from '$app/navigation';
import type { SessionUser, YouTubeSong } from '$lib/types';
import { player } from '$lib/player/player.svelte';
import {
queueItemForTarget,
targetArtist,
targetCoverSrc,
targetExplicit,
targetId,
targetTitle,
type TrackTarget
} from '$lib/tracks';
import { SEARCH_DEBOUNCE_MS, SEARCH_MIN_LENGTH } from '$lib/config';
import Search from '@lucide/svelte/icons/search';
import X from '@lucide/svelte/icons/x';
import LogOut from '@lucide/svelte/icons/log-out';
import MenuItem from './ui/MenuItem.svelte';
import Cover from './ui/Cover.svelte';
import ExplicitBadge from './ui/ExplicitBadge.svelte';
interface Props {
user: SessionUser | null;
}
const QUICK_RESULTS_LIMIT = 5;
interface QuickSearchTrack {
id: string;
title: string;
artist?: string | null;
isExplicit?: boolean;
}
interface QuickSearchResponse {
items: { track?: QuickSearchTrack | null; youTubeSong?: YouTubeSong | null }[];
}
let { user }: Props = $props();
let query = $state('');
let results = $state<TrackTarget[]>([]);
let resultsOpen = $state(false);
let searching = $state(false);
let menuOpen = $state(false);
let menuRef: HTMLDivElement | undefined = $state();
let searchRef: HTMLDivElement | undefined = $state();
let searchInput: HTMLInputElement | undefined = $state();
let searchTimeout: ReturnType<typeof setTimeout> | undefined;
let searchToken = 0;
function clearQuery() {
query = '';
results = [];
resultsOpen = false;
searchInput?.focus();
}
function onInput() {
clearTimeout(searchTimeout);
const term = query.trim();
if (term.length < SEARCH_MIN_LENGTH) {
results = [];
resultsOpen = false;
return;
}
resultsOpen = true;
searchTimeout = setTimeout(() => runQuickSearch(term), SEARCH_DEBOUNCE_MS);
}
async function runQuickSearch(term: string) {
const token = ++searchToken;
searching = true;
try {
const params = new URLSearchParams({ name: term, pageSize: String(QUICK_RESULTS_LIMIT) });
const res = await fetch(`/api/tracks?${params}`);
if (!res.ok) throw new Error(String(res.status));
const data = (await res.json()) as QuickSearchResponse;
if (token !== searchToken) return;
results = data.items
.flatMap((item): TrackTarget[] => {
if (item.track) return [{ kind: 'local', track: item.track }];
if (item.youTubeSong) return [{ kind: 'youtube', song: item.youTubeSong }];
return [];
})
.slice(0, QUICK_RESULTS_LIMIT);
} catch {
if (token === searchToken) results = [];
} finally {
if (token === searchToken) searching = false;
}
}
function playResult(index: number) {
player.playOrToggle(results.map(queueItemForTarget), index);
resultsOpen = false;
}
function onSubmit(event: SubmitEvent) {
event.preventDefault();
clearTimeout(searchTimeout);
resultsOpen = false;
const q = query.trim();
goto(q ? `/explore?q=${encodeURIComponent(q)}` : '/explore');
}
function onDocumentClick(event: MouseEvent) {
if (menuRef && !menuRef.contains(event.target as Node)) menuOpen = false;
if (searchRef && !searchRef.contains(event.target as Node)) resultsOpen = false;
}
</script>
<header
class="sticky top-0 z-30 hidden grid-cols-[1fr_minmax(0,3fr)_1fr] items-center gap-4 border-b border-line bg-bg/70 px-8 py-4 backdrop-blur-lg md:grid"
>
<div></div>
<div class="relative mx-auto w-full max-w-2xl" bind:this={searchRef}>
<form onsubmit={onSubmit}>
<label
class="flex w-full items-center gap-2.5 rounded-full border border-line bg-surface px-4 py-2.5 transition focus-within:border-accent-soft/60"
>
<Search class="h-4 w-4 shrink-0 text-fg-2" />
<input
type="text"
bind:this={searchInput}
bind:value={query}
oninput={onInput}
onfocus={() => (resultsOpen = results.length > 0)}
placeholder="Buscar"
class="w-full bg-transparent text-sm text-fg placeholder:text-fg-2 focus:outline-none"
/>
{#if query}
<button
type="button"
onclick={clearQuery}
aria-label="Borrar búsqueda"
class="grid h-5 w-5 shrink-0 place-items-center rounded-full text-fg-2 transition hover:text-fg"
>
<X class="h-3.5 w-3.5" />
</button>
{/if}
</label>
</form>
{#if resultsOpen && (results.length > 0 || searching)}
<div
class="animate-pop absolute top-full right-0 left-0 z-20 mt-2 overflow-hidden rounded-panel border border-line bg-elevated p-1.5 shadow-menu"
>
{#if searching && results.length === 0}
<p class="px-3 py-2.5 text-sm text-fg-2">Buscando…</p>
{:else}
{#each results as target, i (targetId(target))}
<button
type="button"
onclick={() => playResult(i)}
class="group/result flex w-full min-w-0 items-center gap-3 rounded-control p-2 text-left transition hover:bg-hover"
>
<Cover
trackId={targetId(target)}
src={targetCoverSrc(target)}
size="small"
alt={targetTitle(target)}
class="h-9 w-9 shrink-0 rounded-control shadow-art"
/>
<div class="min-w-0 flex-1">
<div class="flex min-w-0 items-center gap-1.5">
{#if targetExplicit(target)}
<ExplicitBadge />
{/if}
<span class="truncate text-sm text-fg">{targetTitle(target)}</span>
</div>
{#if targetArtist(target)}
<div class="truncate text-xs text-fg-2">{targetArtist(target)}</div>
{/if}
</div>
</button>
{/each}
{/if}
</div>
{/if}
</div>
<div class="flex items-center justify-end gap-6">
{#if user}
<div class="relative" bind:this={menuRef}>
<button
type="button"
onclick={() => (menuOpen = !menuOpen)}
aria-label="Tu cuenta"
class="block shrink-0 rounded-full transition active:scale-95"
>
{#if user.picture}
<img src={user.picture} alt="" class="h-9 w-9 rounded-full object-cover" />
{:else}
<span
class="grid h-9 w-9 place-items-center rounded-full bg-surface-2 text-sm font-semibold text-fg uppercase"
>
{user.name.charAt(0)}
</span>
{/if}
</button>
{#if menuOpen}
<div
class="animate-pop absolute top-full right-0 z-20 mt-2 w-40 overflow-hidden rounded-panel border border-line bg-elevated p-1.5 shadow-menu"
>
<form method="POST" action="/logout" data-sveltekit-reload>
<MenuItem icon={LogOut} label="Salir" type="submit" />
</form>
</div>
{/if}
</div>
{:else}
<a
href="/auth/login"
data-sveltekit-reload
class="shrink-0 rounded-full bg-accent-soft px-4 py-2 text-sm font-semibold text-on-accent transition hover:brightness-110"
>
Iniciar sesión
</a>
{/if}
</div>
</header>
<svelte:window onclick={onDocumentClick} />
@@ -36,7 +36,7 @@
const variants = {
primary: 'bg-accent-soft text-on-accent active:scale-[0.97]',
secondary: 'bg-surface-2 text-fg hover:bg-accent/12',
secondary: 'bg-bg text-fg ring-1 ring-line hover:bg-surface',
subtle: 'bg-white/10 text-fg hover:bg-accent/18',
danger: 'bg-danger text-on-accent hover:brightness-110'
};
@@ -13,17 +13,22 @@
let { icon: Icon, title, description, class: klass = '', actions }: Props = $props();
</script>
<div class="flex flex-col items-center px-6 py-14 text-center {klass}">
<div
class="flex flex-col items-center rounded-panel border border-dashed border-line px-6 py-16 text-center {klass}"
>
{#if Icon}
<span class="grid h-14 w-14 place-items-center rounded-full bg-accent-soft text-on-accent">
<Icon class="h-6 w-6" />
<span
class="grid h-16 w-16 place-items-center rounded-full text-on-accent"
style="background:radial-gradient(circle at 30% 30%, color-mix(in oklch, var(--color-accent), white 15%), var(--color-accent))"
>
<Icon class="h-7 w-7" />
</span>
{/if}
{#if title}
<h3 class="mt-5 font-display text-2xl font-semibold tracking-tight text-fg">{title}</h3>
<h3 class="mt-6 font-display text-2xl font-medium tracking-tight text-fg">{title}</h3>
{/if}
{#if description}
<p class="mt-2 text-fg-2">{description}</p>
<p class="mt-2 max-w-sm text-fg-2">{description}</p>
{/if}
{#if actions}
<div class="mt-6 flex items-center gap-3">{@render actions()}</div>
@@ -24,11 +24,13 @@
<a
href="/mixes/{mix.id}"
class="group/tile animate-enter relative block overflow-hidden rounded-art ring-1 ring-line transition duration-300 ease-out [clip-path:inset(0_round_var(--radius-art))] ring-inset focus-visible:ring-2 focus-visible:ring-accent focus-visible:outline-none {klass}"
class="group/tile animate-enter relative block overflow-hidden rounded-art shadow-art ring-1 ring-line transition duration-300 ease-out [clip-path:inset(0_round_var(--radius-art))] ring-inset hover:shadow-art-lg focus-visible:ring-2 focus-visible:ring-accent focus-visible:outline-none {klass}"
style="animation-delay:{Math.min(index, 10) * 45}ms"
>
{#if tiles.length > 0}
<div class="absolute inset-0 flex transition duration-300 ease-out">
<div
class="absolute inset-0 flex scale-105 transition duration-500 ease-out group-hover/tile:scale-110"
>
{#each tiles as item, i (i)}
<Cover
trackId={item.trackId ?? item.videoId ?? ''}
+7 -1
View File
@@ -7,9 +7,15 @@
}
let { class: klass = '', children }: Props = $props();
let track: HTMLDivElement | undefined = $state();
export function scrollByPage(direction: 1 | -1) {
track?.scrollBy({ left: direction * track.clientWidth * 0.85, behavior: 'smooth' });
}
</script>
<div class="no-scrollbar -mx-1 flex gap-4 overflow-x-auto px-1 pb-2 {klass}">
<div bind:this={track} class="no-scrollbar -mx-1 flex gap-4 overflow-x-auto px-1 pb-2 {klass}">
{@render children()}
</div>
@@ -11,14 +11,16 @@
</script>
{#if actions}
<div class="mb-3 flex items-baseline justify-between gap-4 sm:mb-4 {klass}">
<h2 class="font-display font-medium tracking-tight text-fg sm:text-xl">
<div class="mb-4 flex items-end justify-between gap-4 sm:mb-5 {klass}">
<h2 class="font-display text-xl font-medium tracking-tight text-fg sm:text-[1.75rem]">
{title}
</h2>
{@render actions()}
</div>
{:else}
<h2 class="mb-3 font-display font-medium tracking-tight text-fg sm:mb-4 sm:text-xl {klass}">
<h2
class="mb-4 font-display text-xl font-medium tracking-tight text-fg sm:mb-5 sm:text-[1.75rem] {klass}"
>
{title}
</h2>
{/if}
+4
View File
@@ -13,4 +13,8 @@ export const HOME_ALBUMS_LIMIT = 12;
export const HOME_MIXES_BENTO = 5;
export const HOME_SHELF_LIMIT = 6;
export const YOUTUBE_FILLER_LIMIT = 27;
export const HOME_TOP_MUSIC_FILLER_LIMIT = 10;
export const HOME_POPULAR_FILLER_LIMIT = 10;
export const HOME_POPULAR_MAX = 5;
export const HOME_ALBUM_FILLER_LIMIT = 8;
export const PLAYLIST_PICKER_PAGE_SIZE = 50;
+9 -3
View File
@@ -23,9 +23,15 @@ export function requireUser(locals: App.Locals, url: URL): SessionUser {
return locals.user;
}
export function requireAccessToken(locals: App.Locals, message = 'Inicia sesión.'): string {
if (!locals.accessToken) error(401, message);
return locals.accessToken;
export function optionalUser(
locals: App.Locals,
url: URL,
allowAnonymousListening: boolean
): SessionUser | null {
if (locals.user) return locals.user;
if (!allowAnonymousListening)
redirect(302, `/login?returnTo=${encodeURIComponent(url.pathname + url.search)}`);
return null;
}
export function requireAccessTokenAction(
@@ -0,0 +1,22 @@
import { createApiClient } from '$lib/server/api';
const CACHE_TTL_MS = 30_000;
let cached: { allowAnonymousListening: boolean; expiresAt: number } | null = null;
export async function getAllowAnonymousListening(fetchFn: typeof fetch): Promise<boolean> {
if (cached && cached.expiresAt > Date.now()) return cached.allowAnonymousListening;
try {
const api = createApiClient({ fetch: fetchFn });
const { data } = await api.GET('/config/playback');
const allowAnonymousListening = data?.allowAnonymousListening ?? false;
cached = { allowAnonymousListening, expiresAt: Date.now() + CACHE_TTL_MS };
return allowAnonymousListening;
} catch {
// The API being unreachable shouldn't take down every page in the app;
// fail closed (as if anonymous listening were off) and retry next time.
return false;
}
}
+32 -5
View File
@@ -1,5 +1,5 @@
import type { createApiClient } from '$lib/server/api';
import type { YouTubeSong } from '$lib/types';
import type { YouTubeAlbumResult, YouTubeSong } from '$lib/types';
const YOUTUBE_FILLER_QUERIES = [
'pop',
@@ -24,6 +24,11 @@ export interface YoutubeFiller {
continuationToken: string;
}
export interface YoutubeAlbumFiller {
query: string;
items: YouTubeAlbumResult[];
}
function pickRandomFillerQuery(): string {
return YOUTUBE_FILLER_QUERIES[Math.floor(Math.random() * YOUTUBE_FILLER_QUERIES.length)];
}
@@ -36,9 +41,9 @@ function hash(text: string): number {
return Math.abs(h);
}
function pickFillerQueryForUser(userId: string, date: Date): string {
function pickFillerQueryForUser(userId: string, date: Date, variant = 0): string {
const dayKey = date.toISOString().slice(0, 10);
const index = hash(`${userId}:${dayKey}`) % YOUTUBE_FILLER_QUERIES.length;
const index = (hash(`${userId}:${dayKey}`) + variant) % YOUTUBE_FILLER_QUERIES.length;
return YOUTUBE_FILLER_QUERIES[index];
}
@@ -47,12 +52,13 @@ export async function fetchYoutubeFiller(
accessToken: string | null | undefined,
limit: number,
seedQuery?: string,
userId?: string
userId?: string,
variant = 0
): Promise<YoutubeFiller> {
if (!accessToken) return { query: '', items: [], continuationToken: '' };
const query =
seedQuery?.trim() ||
(userId ? pickFillerQueryForUser(userId, new Date()) : pickRandomFillerQuery());
(userId ? pickFillerQueryForUser(userId, new Date(), variant) : pickRandomFillerQuery());
const { data } = await api.GET('/tracks', { params: { query: { name: query, pageSize: 1 } } });
const items = (data?.items ?? [])
.map((item) => item.youTubeSong)
@@ -63,3 +69,24 @@ export async function fetchYoutubeFiller(
continuationToken: data?.nextYoutubeContinuationToken ?? ''
};
}
export async function fetchYoutubeAlbumFiller(
api: ReturnType<typeof createApiClient>,
accessToken: string | null | undefined,
limit: number,
seedQuery?: string,
userId?: string,
variant = 0
): Promise<YoutubeAlbumFiller> {
if (!accessToken) return { query: '', items: [] };
const query =
seedQuery?.trim() ||
(userId ? pickFillerQueryForUser(userId, new Date(), variant) : pickRandomFillerQuery());
const { data } = await api.GET('/albums', {
params: { query: { title: query, pageNumber: 1, pageSize: limit + 5 } }
});
const items = (data?.items ?? [])
.map((item) => item.youTubeAlbum)
.filter((album): album is NonNullable<typeof album> => album !== null && album !== undefined);
return { query, items: (items as YouTubeAlbumResult[]).slice(0, limit) };
}
+16 -12
View File
@@ -1,25 +1,29 @@
:root {
--mf-bg: rgb(9, 9, 9);
--mf-elevated: #0b0b0d;
--mf-bg: oklch(12.5% 0.004 95);
--mf-elevated: oklch(16% 0.005 95);
--mf-text: #c7c6c5;
--mf-text-2: #a5a4a3;
--mf-text-3: #8c8b8a;
--mf-text-4: rgb(120, 120, 120);
--mf-text: oklch(93% 0.004 95);
--mf-text-2: oklch(78% 0.006 95);
--mf-text-3: oklch(62% 0.007 95);
--mf-text-4: oklch(46% 0.006 95);
--mf-surface: rgb(15, 15, 15);
--mf-surface-2: rgba(255, 255, 255, 0.06);
--mf-surface-hover: rgba(255, 255, 255, 0.09);
--mf-surface: oklch(15.5% 0.005 95);
--mf-surface-2: rgba(255, 255, 255, 0.05);
--mf-surface-hover: rgba(255, 255, 255, 0.08);
--mf-border: rgba(255, 255, 255, 0.08);
--mf-border-strong: rgba(255, 255, 255, 0.12);
--mf-border: rgba(255, 255, 255, 0.07);
--mf-border-strong: rgba(255, 255, 255, 0.13);
--mf-track: rgba(255, 255, 255, 0.12);
--mf-accent: oklch(60% 0.1 145);
--mf-accent-soft: color-mix(in oklch, var(--mf-accent) 80%, transparent);
--mf-accent-glow: color-mix(in oklch, var(--mf-accent) 45%, transparent);
--mf-accent-dim: color-mix(in oklch, var(--mf-accent) 22%, transparent);
--mf-scrim: #000000;
--mf-on-art: #ffffff;
--mf-on-art: #fefdfb;
--mf-noise: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='180' height='180'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
--mf-sidebar-w: 288px;
--mf-nav-h: 60px;
+4
View File
@@ -26,6 +26,10 @@ export function targetExplicit(target: TrackTarget): boolean {
return target.kind === 'youtube' ? target.song.isExplicit : (target.track.isExplicit ?? false);
}
export function targetDurationSeconds(target: TrackTarget): number | undefined {
return target.kind === 'youtube' ? target.song.durationSeconds : undefined;
}
export function targetCoverSrc(target: TrackTarget): string | undefined {
return target.kind === 'youtube' ? target.song.thumbnailUrl : undefined;
}
+8
View File
@@ -14,3 +14,11 @@ export interface YouTubeSong {
thumbnailUrl: string;
isExplicit: boolean;
}
export interface YouTubeAlbumResult {
albumId: string;
title: string;
artist: string;
thumbnailUrl: string;
releaseYear: number | string | null;
}