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:
Vendored
+42
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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,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}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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) };
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user