refactor: rename frontend/ to web-player/ and format with prettier

Updates the compose build context, the Dockerfile header, the package
name and the docs. The compose service and container keep the name
frontend/musify-frontend, since nginx routes to http://frontend:3000.

The source files also carry the first prettier pass, so the rename and
the reformat land together for them.
This commit is contained in:
2026-07-20 07:35:01 +02:00
parent 05514a1c6e
commit 97daa13ebd
104 changed files with 134 additions and 2457 deletions
+1526
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="107" height="128" viewBox="0 0 107 128"><title>svelte-logo</title><path d="M94.157 22.819c-10.4-14.885-30.94-19.297-45.792-9.835L22.282 29.608A29.92 29.92 0 0 0 8.764 49.65a31.5 31.5 0 0 0 3.108 20.231 30 30 0 0 0-4.477 11.183 31.9 31.9 0 0 0 5.448 24.116c10.402 14.887 30.942 19.297 45.791 9.835l26.083-16.624A29.92 29.92 0 0 0 98.235 78.35a31.53 31.53 0 0 0-3.105-20.232 30 30 0 0 0 4.474-11.182 31.88 31.88 0 0 0-5.447-24.116" style="fill:#ff3e00"/><path d="M45.817 106.582a20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.503 18 18 0 0 1 .624-2.435l.49-1.498 1.337.981a33.6 33.6 0 0 0 10.203 5.098l.97.294-.09.968a5.85 5.85 0 0 0 1.052 3.878 6.24 6.24 0 0 0 6.695 2.485 5.8 5.8 0 0 0 1.603-.704L69.27 76.28a5.43 5.43 0 0 0 2.45-3.631 5.8 5.8 0 0 0-.987-4.371 6.24 6.24 0 0 0-6.698-2.487 5.7 5.7 0 0 0-1.6.704l-9.953 6.345a19 19 0 0 1-5.296 2.326 20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.502 17.99 17.99 0 0 1 8.13-12.052l26.081-16.623a19 19 0 0 1 5.3-2.329 20.72 20.72 0 0 1 22.237 8.243 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-.624 2.435l-.49 1.498-1.337-.98a33.6 33.6 0 0 0-10.203-5.1l-.97-.294.09-.968a5.86 5.86 0 0 0-1.052-3.878 6.24 6.24 0 0 0-6.696-2.485 5.8 5.8 0 0 0-1.602.704L37.73 51.72a5.42 5.42 0 0 0-2.449 3.63 5.79 5.79 0 0 0 .986 4.372 6.24 6.24 0 0 0 6.698 2.486 5.8 5.8 0 0 0 1.602-.704l9.952-6.342a19 19 0 0 1 5.295-2.328 20.72 20.72 0 0 1 22.237 8.242 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-8.13 12.053l-26.081 16.622a19 19 0 0 1-5.3 2.328" style="fill:#fff"/></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

+15
View File
@@ -0,0 +1,15 @@
export function appendUnique<T>(
current: T[],
incoming: T[],
keyOf: (item: T) => string | number
): T[] {
const seen = new Set(current.map(keyOf));
const added: T[] = [];
for (const item of incoming) {
const key = keyOf(item);
if (seen.has(key)) continue;
seen.add(key);
added.push(item);
}
return added.length > 0 ? [...current, ...added] : current;
}
@@ -0,0 +1,146 @@
<script lang="ts">
import { page } from '$app/state';
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 Music from '@lucide/svelte/icons/music';
import Upload from '@lucide/svelte/icons/upload';
import LogOut from '@lucide/svelte/icons/log-out';
interface Props {
user: SessionUser;
}
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 = [
{ href: '/', label: 'Inicio', icon: Home },
{ href: '/explore', label: 'Buscar', icon: Search },
{ href: '/playlists', label: 'Listas', icon: Music }
];
const secondaryLinks = [
{ href: '/library', label: 'Canciones subidas', icon: Folder },
{ href: '/upload', label: 'Subir música', icon: Upload }
];
function isActive(href: string) {
return href === '/' ? page.url.pathname === '/' : page.url.pathname.startsWith(href);
}
$effect(() => {
void page.url.pathname;
const box = navBox;
if (!box) return;
const active = box.querySelector<HTMLElement>('[data-active="true"]');
if (!active) {
pillVisible = false;
return;
}
const boxRect = box.getBoundingClientRect();
const rect = active.getBoundingClientRect();
pillTop = rect.top - boxRect.top;
pillHeight = rect.height;
pillVisible = true;
if (!animate) requestAnimationFrame(() => (animate = true));
});
</script>
{#snippet navLink(link: { href: string; label: string; icon: typeof Home })}
{@const active = isActive(link.href)}
<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
? 'text-fg'
: 'text-fg-3 hover:bg-hover hover:text-fg'}"
>
<link.icon class="h-6 w-6 shrink-0" style={active ? 'color:var(--mf-accent-60)' : ''} />
{link.label}
</a>
{/snippet}
<aside
class="sticky top-0 flex h-screen flex-shrink-0 flex-col gap-7 border-r border-line px-[18px] py-[26px]"
style="width:var(--mf-sidebar-w)"
>
<a href="/" class="px-3 text-xl font-extrabold tracking-wide">Musify</a>
<div class="relative flex flex-col gap-7" bind:this={navBox}>
<div
class="pointer-events-none absolute inset-x-0 z-0 overflow-hidden rounded-control bg-surface-hover {animate
? 'transition-[top,height] duration-200 ease-[cubic-bezier(0.4,0,0.2,1)]'
: ''}"
style="top:{pillTop}px; height:{pillHeight}px; opacity:{pillVisible ? 1 : 0}"
>
<span
class="absolute inset-y-0 left-0 w-[3px] rounded-r-full"
style="background:var(--mf-accent-60)"
></span>
</div>
<nav class="relative z-10 flex flex-col gap-1">
{#each mainLinks as link (link.href)}
{@render navLink(link)}
{/each}
</nav>
<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="absolute bottom-full left-0 mb-2 w-full overflow-hidden rounded-panel border border-line bg-elevated py-1.5 shadow-menu"
>
<form method="POST" action="/logout" data-sveltekit-reload>
<button
type="submit"
class="flex w-full items-center gap-3 px-3 py-2.5 text-base font-semibold text-fg-2 transition hover:bg-hover"
>
<LogOut class="h-[18px] w-[18px]" strokeWidth={2} />
Salir
</button>
</form>
</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 flex-shrink-0 place-items-center rounded-full bg-surface-2 text-sm font-semibold uppercase"
>
{user.name.charAt(0)}
</span>
{/if}
<span class="truncate text-base leading-[1.35] font-semibold">{user.name}</span>
</button>
</div>
</aside>
<svelte:window onclick={onDocumentClick} />
@@ -0,0 +1,121 @@
<script lang="ts" module>
import type { TrackTarget } from '$lib/tracks';
export type ContextMenuState = TrackTarget & { x: number; y: number; openLeft: boolean };
const MENU_WIDTH = 208;
const SUBMENU_WIDTH = 224;
export function contextMenuStateFor(event: MouseEvent, target: TrackTarget): ContextMenuState {
event.preventDefault();
return {
...target,
x: Math.min(event.clientX, window.innerWidth - MENU_WIDTH - 8),
y: Math.min(event.clientY, window.innerHeight - 60),
openLeft: event.clientX + MENU_WIDTH + SUBMENU_WIDTH + 16 > window.innerWidth
};
}
</script>
<script lang="ts">
import { enhance } from '$app/forms';
import ListPlus from '@lucide/svelte/icons/list-plus';
import ChevronRight from '@lucide/svelte/icons/chevron-right';
import PlaylistArt from '$lib/components/ui/PlaylistArt.svelte';
import { hueFor } from '$lib/theme/color';
interface Props {
menu: ContextMenuState;
playlists: { id: string; name: string }[];
onClose: () => void;
onAddToQueue: () => void;
}
let { menu, playlists, onClose, onAddToQueue }: Props = $props();
function onWindowKeydown(event: KeyboardEvent) {
if (event.key === 'Escape') onClose();
}
</script>
<svelte:window onkeydown={onWindowKeydown} />
<div
class="fixed inset-0 z-30"
role="presentation"
onclick={onClose}
oncontextmenu={(e) => {
e.preventDefault();
onClose();
}}
></div>
<div
class="fixed z-40 w-60 rounded-panel border border-line bg-elevated p-1.5 shadow-menu"
style="left:{menu.x}px; top:{menu.y}px;"
>
<button
type="button"
onclick={onAddToQueue}
class="flex w-full items-center gap-3 rounded-control px-2 py-2.5 text-left text-sm text-fg-2 transition hover:bg-hover"
>
<ListPlus class="h-6 w-6 shrink-0" strokeWidth={2} />
<span class="text-base">Reproducir a continuación</span>
</button>
{#if playlists.length > 0}
<div class="group/addmenu relative">
<div
class="flex items-center gap-3 rounded-control px-2 py-2.5 text-sm text-fg-2 transition group-hover/addmenu:bg-hover"
>
<span class="flex-1 cursor-default text-base">Añadir a una playlist</span>
<ChevronRight class="h-4 w-4 shrink-0 text-fg-3" />
</div>
<div
class="invisible absolute top-0 z-50 opacity-0 transition group-hover/addmenu:visible group-hover/addmenu:opacity-100 {menu.openLeft
? 'right-full pr-1.5'
: 'left-full pl-1.5'}"
>
<div
class="w-64 overflow-hidden rounded-panel border border-line bg-elevated p-2 shadow-menu"
>
<div class="max-h-80 overflow-y-auto">
{#each playlists as playlist (playlist.id)}
<form
method="POST"
action={menu.kind === 'youtube' ? '?/addYouTubeToPlaylist' : '?/addTrack'}
use:enhance={() =>
({ update }) => {
onClose();
return update({ reset: false, invalidateAll: false });
}}
>
<input type="hidden" name="playlistId" value={playlist.id} />
{#if menu.kind === 'youtube'}
<input type="hidden" name="videoId" value={menu.song.videoId} />
<input type="hidden" name="title" value={menu.song.title} />
<input type="hidden" name="artist" value={menu.song.artist} />
<input type="hidden" name="durationSeconds" value={menu.song.durationSeconds} />
<input type="hidden" name="thumbnailUrl" value={menu.song.thumbnailUrl} />
{:else}
<input type="hidden" name="trackId" value={menu.track.id} />
{/if}
<button
type="submit"
class="flex w-full items-center gap-3 rounded-control px-2 py-2.5 text-left text-base text-fg transition hover:bg-hover"
>
<PlaylistArt
playlistId={playlist.id}
trackIds={[]}
hue={hueFor(playlist.id)}
size="small"
class="h-8 w-8 flex-shrink-0 rounded-control"
/>
<span class="truncate text-base text-fg-2">{playlist.name}</span>
</button>
</form>
{/each}
</div>
</div>
</div>
</div>
{/if}
</div>
@@ -0,0 +1,76 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import Cover from '$lib/components/ui/Cover.svelte';
import PlayPauseOverlay from '$lib/components/ui/PlayPauseOverlay.svelte';
import ExplicitBadge from '$lib/components/ui/ExplicitBadge.svelte';
interface Props {
id: string | number;
title: string;
artist?: string;
coverSrc?: string;
coverSize?: 'small' | 'medium' | 'large';
hue: number;
explicit?: boolean;
active: boolean;
playing: boolean;
index: number;
onClick: () => void;
onContextMenu?: (event: MouseEvent) => void;
badge?: Snippet;
}
let {
id,
title,
artist,
coverSrc,
coverSize = 'large',
hue,
explicit = false,
active,
playing,
index,
onClick,
onContextMenu,
badge
}: Props = $props();
</script>
<li
class="group animate-enter relative min-w-0"
style="animation-delay:{index * 40}ms"
oncontextmenu={onContextMenu}
>
<button
type="button"
onclick={onClick}
aria-label="{active && playing ? 'Pausar' : 'Reproducir'} {title}"
class="block w-full text-left"
>
<Cover
trackId={id}
src={coverSrc}
{hue}
size={coverSize}
alt={title}
class="aspect-square w-full rounded-art shadow-art"
>
<PlayPauseOverlay {active} {playing} />
</Cover>
<div class="mt-2.5 flex min-w-0 items-center gap-1.5">
{#if explicit}
<ExplicitBadge />
{/if}
<span class="truncate text-fg" {title}>{title}</span>
</div>
{#if badge || artist}
<div class="mt-0.5 flex min-w-0 items-center gap-1.5">
{@render badge?.()}
{#if artist}
<span class="truncate text-sm text-fg-3" title={artist}>{artist}</span>
{/if}
</div>
{/if}
</button>
</li>
@@ -0,0 +1,23 @@
<script lang="ts">
import TrackInfo from './TrackInfo.svelte';
import TransportControls from './TransportControls.svelte';
import SeekBar from './SeekBar.svelte';
import VolumeControl from './VolumeControl.svelte';
</script>
<div
class="animate-enter fixed right-0 bottom-0 z-50 h-[92px] border-t border-line bg-bg"
style="left:var(--mf-sidebar-w)"
>
<div
class="grid h-full items-center gap-5 px-8"
style="grid-template-columns:minmax(200px,1fr) minmax(0,3fr) minmax(200px,1fr)"
>
<TrackInfo />
<div class="flex flex-col items-center gap-2">
<TransportControls />
<SeekBar />
</div>
<VolumeControl />
</div>
</div>
@@ -0,0 +1,33 @@
<script lang="ts">
import { player } from '$lib/player/player.svelte';
import { fmtTime } from '$lib/theme/color';
function seek(event: MouseEvent) {
const rect = (event.currentTarget as HTMLElement).getBoundingClientRect();
player.seekFraction((event.clientX - rect.left) / rect.width);
}
</script>
<div class="flex w-full max-w-172 items-center gap-3">
<span class="text-right text-xs text-muted tabular-nums">
{fmtTime(player.progress)}
</span>
<button
type="button"
onclick={seek}
aria-label="Barra de progreso"
class="group relative h-1.5 flex-1 cursor-pointer rounded-full bg-track transition-[height] hover:h-2"
>
<div
class="absolute inset-y-0 left-0 rounded-full bg-accent/60 group-hover:brightness-110"
style="width:{player.progressPercent}%"
></div>
<span
class="pointer-events-none absolute top-1/2 h-3 w-3 -translate-x-1/2 -translate-y-1/2 rounded-full bg-white opacity-0 shadow-[0_1px_4px_rgba(0,0,0,0.5)] transition-opacity duration-75 group-hover:opacity-100"
style="left:{player.progressPercent}%"
></span>
</button>
<span class="text-xs text-(--mf-text-4) tabular-nums">
{fmtTime(player.current.duration)}
</span>
</div>
@@ -0,0 +1,32 @@
<script lang="ts">
import { player } from '$lib/player/player.svelte';
import Cover from '$lib/components/ui/Cover.svelte';
import ExplicitBadge from '$lib/components/ui/ExplicitBadge.svelte';
</script>
<div class="flex min-w-0 items-center gap-3">
{#if player.current.id}
<Cover
trackId={player.current.id}
src={player.current.coverUrl}
hue={player.current.hue}
size="small"
alt={player.current.title}
class="h-13 w-13 shrink-0 rounded-lg shadow-[0_6px_16px_-6px_rgba(0,0,0,0.6)]"
/>
{:else}
<div
class="h-13 w-13 shrink-0 rounded-lg shadow-[0_6px_16px_-6px_rgba(0,0,0,0.6)]"
style="background:{player.artGradient}"
></div>
{/if}
<div class="min-w-0">
<div class="flex min-w-0 items-center gap-1.5">
{#if player.current.explicit}
<ExplicitBadge />
{/if}
<span class="truncate text-fg">{player.current.title}</span>
</div>
<div class="truncate text-sm text-muted">{player.current.artist || '—'}</div>
</div>
</div>
@@ -0,0 +1,47 @@
<script lang="ts">
import { player } from '$lib/player/player.svelte';
</script>
<div class="flex items-center gap-5">
<button
type="button"
onclick={() => player.previous()}
aria-label="Anterior"
class="flex text-fg opacity-70 transition hover:opacity-100 active:scale-90"
>
<svg width="16" height="14" viewBox="0 0 16 14" fill="currentColor">
<rect x="0" y="0" width="2.5" height="14" rx="1" />
<path d="M15 0 5 7l10 7z" />
</svg>
</button>
<button
type="button"
onclick={() => player.toggle()}
aria-label={player.playing ? 'Pausar' : 'Reproducir'}
class="grid h-[36px] w-[36px] place-items-center rounded-full bg-fg text-bg shadow-lg transition duration-100 active:scale-95"
>
{#if player.playing}
<svg width="12" height="12" viewBox="0 0 12 12" fill="currentColor">
<rect x="1.5" y="0" width="3.5" height="12" rx="1" />
<rect x="7" y="0" width="3.5" height="12" rx="1" />
</svg>
{:else}
<svg width="12" height="12" viewBox="0 0 12 12" fill="currentColor">
<path d="M2.5 0 12 6l-9.5 6z" />
</svg>
{/if}
</button>
<button
type="button"
onclick={() => player.next()}
aria-label="Siguiente"
class="flex text-fg opacity-70 transition hover:opacity-100 active:scale-90"
>
<svg width="16" height="14" viewBox="0 0 16 14" fill="currentColor">
<path d="M1 0 11 7 1 14z" />
<rect x="13.5" y="0" width="2.5" height="14" rx="1" />
</svg>
</button>
</div>
@@ -0,0 +1,51 @@
<script lang="ts">
import { player } from '$lib/player/player.svelte';
function handleVolumeChange(event: Event) {
const target = event.currentTarget as HTMLInputElement;
const newVolume = Number(target.value);
player.setVolume(newVolume);
}
</script>
<div class="flex items-center justify-end gap-3.5">
<input
type="range"
min="0"
max="100"
value={player.volume}
oninput={handleVolumeChange}
aria-label="Volumen"
class="mf-volume"
style="background:linear-gradient(to right, var(--mf-accent-60) {player.volume}%, var(--mf-track) {player.volume}%)"
/>
</div>
<style>
.mf-volume {
-webkit-appearance: none;
appearance: none;
width: 80px;
height: 4px;
border-radius: 100px;
outline: none;
cursor: pointer;
}
.mf-volume::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 12px;
height: 12px;
border-radius: 50%;
background: var(--mf-text);
cursor: pointer;
}
.mf-volume::-moz-range-thumb {
width: 12px;
height: 12px;
border-radius: 50%;
background: var(--mf-text);
border: none;
cursor: pointer;
}
</style>
@@ -0,0 +1,20 @@
<script lang="ts">
import type { Snippet } from 'svelte';
interface Props {
tone?: 'neutral' | 'danger';
class?: string;
children: Snippet;
}
let { tone = 'neutral', class: klass = '', children }: Props = $props();
const tones = {
neutral: 'border-line bg-surface text-fg-2',
danger: 'border-danger/40 bg-danger/10 text-danger-fg'
};
</script>
<p class="rounded-control border px-4 py-3 text-base {tones[tone]} {klass}">
{@render children()}
</p>
@@ -0,0 +1,23 @@
<script lang="ts">
import type { Snippet } from 'svelte';
interface Props {
tone?: 'neutral' | 'accent' | 'danger';
class?: string;
children: Snippet;
}
let { tone = 'neutral', class: klass = '', children }: Props = $props();
const tones = {
neutral: 'bg-surface-2 text-fg',
accent: 'bg-accent/20 text-accent',
danger: 'bg-danger/10 text-danger-fg'
};
</script>
<span
class="shrink-0 rounded-full px-2 py-0.5 text-sm leading-none font-semibold {tones[tone]} {klass}"
>
{@render children()}
</span>
@@ -0,0 +1,60 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import LoaderCircle from '@lucide/svelte/icons/loader-circle';
interface Props {
variant?: 'primary' | 'secondary' | 'subtle' | 'danger';
size?: 'sm' | 'md' | 'lg';
type?: 'button' | 'submit';
href?: string;
reload?: boolean;
disabled?: boolean;
loading?: boolean;
onclick?: (event: MouseEvent) => void;
class?: string;
children: Snippet;
}
let {
variant = 'primary',
size = 'md',
type = 'button',
href,
reload = false,
disabled = false,
loading = false,
onclick,
class: klass = '',
children
}: Props = $props();
const sizes = {
sm: 'px-4 py-2 text-base',
md: 'px-5 py-2.5 text-base',
lg: 'px-8 py-3.5 text-base'
};
const variants = {
primary: 'bg-accent-soft font-semibold text-on-accent active:scale-[0.97]',
secondary: 'border border-line text-fg hover:bg-hover',
subtle: 'bg-surface-2 font-semibold text-fg hover:bg-surface-hover',
danger: 'bg-danger font-semibold text-white hover:brightness-110'
};
const classes = $derived(
`inline-flex items-center justify-center gap-2 rounded-control transition disabled:opacity-40 ${sizes[size]} ${variants[variant]} ${klass}`
);
</script>
{#if href}
<a {href} data-sveltekit-reload={reload ? '' : undefined} class={classes}>
{@render children()}
</a>
{:else}
<button {type} disabled={disabled || loading} {onclick} class={classes}>
{#if loading}
<LoaderCircle class="h-4 w-4 animate-spin" />
{/if}
{@render children()}
</button>
{/if}
@@ -0,0 +1,29 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import Check from '@lucide/svelte/icons/check';
interface Props {
checked?: boolean;
name?: string;
class?: string;
children: Snippet;
}
let { checked = $bindable(false), name, class: klass = '', children }: Props = $props();
</script>
<label class="flex items-center gap-3 text-base text-fg-2 {klass}">
<span class="relative inline-grid h-5 w-5 shrink-0 place-items-center">
<input
type="checkbox"
{name}
bind:checked
class="peer h-5 w-5 cursor-pointer appearance-none rounded border border-line bg-surface transition checked:border-accent-soft checked:bg-accent-soft focus-visible:border-accent/60 focus-visible:outline-none"
/>
<Check
class="pointer-events-none absolute h-3.5 w-3.5 text-on-accent opacity-0 transition peer-checked:opacity-100"
strokeWidth={3}
/>
</span>
<span class="cursor-pointer">{@render children()}</span>
</label>
@@ -0,0 +1,52 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import { gradientForHue } from '$lib/theme/color';
interface Props {
trackId: string | number;
hue: number;
size?: 'small' | 'medium' | 'large';
src?: string;
alt?: string;
class?: string;
children?: Snippet;
}
let {
trackId,
hue,
size = 'medium',
src,
alt = '',
class: klass = '',
children
}: Props = $props();
let failed = $state(false);
let loaded = $state(false);
const imageSrc = $derived(src ?? `/api/tracks/${trackId}/cover?size=${size}`);
$effect(() => {
imageSrc;
failed = false;
loaded = false;
});
</script>
<div
class="relative overflow-hidden {klass}"
style={loaded && !failed ? undefined : `background:${gradientForHue(hue)}`}
>
{#if !failed}
<img
src={imageSrc}
{alt}
loading="lazy"
onload={() => (loaded = true)}
onerror={() => (failed = true)}
class="h-full w-full object-cover"
/>
{/if}
{@render children?.()}
</div>
@@ -0,0 +1,31 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import type { LucideIcon } from '@lucide/svelte';
interface Props {
icon?: LucideIcon;
title?: string;
description?: string;
class?: string;
actions?: Snippet;
}
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}">
{#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>
{/if}
{#if title}
<h3 class="mt-5 text-2xl font-bold tracking-tight text-fg">{title}</h3>
{/if}
{#if description}
<p class="mt-2 text-fg-2">{description}</p>
{/if}
{#if actions}
<div class="mt-6 flex items-center gap-3">{@render actions()}</div>
{/if}
</div>
@@ -0,0 +1,13 @@
<script lang="ts">
interface Props {
class?: string;
}
let { class: klass = '' }: Props = $props();
</script>
<span
class="grid h-4 w-4 shrink-0 place-items-center rounded-sm bg-fg-3 text-[11px] leading-4 font-bold text-bg {klass}"
title="Contenido explícito"
aria-label="Contenido explícito">E</span
>
@@ -0,0 +1,27 @@
<script lang="ts">
import type { Snippet } from 'svelte';
interface Props {
label: string;
for?: string;
optional?: boolean;
class?: string;
hint?: Snippet;
children: Snippet;
}
let { label, for: forId, optional = false, class: klass = '', hint, children }: Props = $props();
</script>
<div class="flex min-w-0 flex-col {klass}">
<label for={forId} class="mb-2 block text-base font-medium text-fg-2">
{label}
{#if optional}<span class="text-muted">(Opcional)</span>{/if}
</label>
{@render children()}
{#if hint}
<div class="mt-2 flex items-center justify-between gap-3 text-sm text-muted">
{@render hint()}
</div>
{/if}
</div>
@@ -0,0 +1,37 @@
<script lang="ts">
import type { Snippet } from 'svelte';
interface Props {
label: string;
type?: 'button' | 'submit';
size?: 'sm' | 'md';
revealOnHover?: boolean;
onclick?: (event: MouseEvent) => void;
class?: string;
children: Snippet;
}
let {
label,
type = 'button',
size = 'md',
revealOnHover = false,
onclick,
class: klass = '',
children
}: Props = $props();
const sizes = { sm: 'h-7 w-7', md: 'h-8 w-8' };
</script>
<button
{type}
{onclick}
aria-label={label}
title={label}
class="grid place-items-center rounded-control text-muted transition hover:bg-hover hover:text-fg {sizes[
size
]} {revealOnHover ? 'opacity-0 group-hover:opacity-100 focus-visible:opacity-100' : ''} {klass}"
>
{@render children()}
</button>
@@ -0,0 +1,49 @@
<script lang="ts">
import ImageIcon from '@lucide/svelte/icons/image';
interface Props {
name: string;
fallbackUrl?: string;
class?: string;
}
let {
name,
fallbackUrl,
class: klass = 'h-52 w-52 rounded-control border border-line bg-surface hover:border-accent/50'
}: Props = $props();
let preview = $state('');
let fallbackFailed = $state(false);
function onInput(event: Event) {
const file = (event.currentTarget as HTMLInputElement).files?.[0];
if (file) preview = URL.createObjectURL(file);
}
</script>
<label
class="relative grid cursor-pointer place-items-center overflow-hidden transition-colors {klass}"
>
<input
type="file"
{name}
accept="image/*"
onchange={onInput}
class="absolute inset-0 cursor-pointer opacity-0"
aria-label="Seleccionar portada"
/>
{#if preview !== ''}
<img src={preview} alt="Portada" class="h-full w-full object-cover" />
{:else if fallbackUrl && !fallbackFailed}
<img
src={fallbackUrl}
alt="Portada actual"
loading="lazy"
onerror={() => (fallbackFailed = true)}
class="h-full w-full object-cover"
/>
{:else}
<ImageIcon class="h-12 w-12 text-muted" />
{/if}
</label>
@@ -0,0 +1,38 @@
<script lang="ts">
import LoaderCircle from '@lucide/svelte/icons/loader-circle';
interface Props {
onLoadMore: () => void;
hasMore: boolean;
loading: boolean;
rootMargin?: string;
}
let { onLoadMore, hasMore, loading, rootMargin = '600px' }: Props = $props();
let sentinel = $state<HTMLDivElement>();
let visible = $state(false);
$effect(() => {
const node = sentinel;
if (!node) return;
const observer = new IntersectionObserver(
(entries) => {
visible = entries[0].isIntersecting;
},
{ rootMargin }
);
observer.observe(node);
return () => observer.disconnect();
});
$effect(() => {
if (visible && hasMore && !loading) onLoadMore();
});
</script>
<div bind:this={sentinel} class="flex min-h-12 items-center justify-center py-8">
{#if loading}
<LoaderCircle class="h-5 w-5 animate-spin text-muted" />
{/if}
</div>
@@ -0,0 +1,53 @@
<script lang="ts">
import type { LucideIcon } from '@lucide/svelte';
interface Props {
value?: string;
id?: string;
name?: string;
type?: 'text' | 'search';
placeholder?: string;
maxlength?: number;
required?: boolean;
autocomplete?: 'on' | 'off';
icon?: LucideIcon;
oninput?: (event: Event) => void;
class?: string;
}
let {
value = $bindable(''),
id,
name,
type = 'text',
placeholder,
maxlength,
required = false,
autocomplete = 'off',
icon: Icon,
oninput,
class: klass = ''
}: Props = $props();
</script>
<div class="relative w-full">
{#if Icon}
<span class="pointer-events-none absolute inset-y-0 left-4 grid place-items-center text-muted">
<Icon class="h-4 w-4" />
</span>
{/if}
<input
{id}
{name}
{type}
{placeholder}
{maxlength}
{required}
{autocomplete}
{oninput}
bind:value
class="w-full rounded-control border border-line bg-surface py-3 text-base text-fg transition placeholder:text-muted focus:border-accent/50 focus:outline-none {Icon
? 'pr-4 pl-11'
: 'px-4'} {klass}"
/>
</div>
@@ -0,0 +1,20 @@
<script lang="ts">
import type { Snippet } from 'svelte';
interface Props {
as?: 'ul' | 'div';
min?: string;
class?: string;
children: Snippet;
}
let { as = 'div', min = '180px', class: klass = '', children }: Props = $props();
</script>
<svelte:element
this={as}
class="grid gap-4 {klass}"
style="grid-template-columns:repeat(auto-fill,minmax({min},1fr))"
>
{@render children()}
</svelte:element>
@@ -0,0 +1,94 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import X from '@lucide/svelte/icons/x';
import IconButton from './IconButton.svelte';
interface Props {
open: boolean;
onClose: () => void;
title?: string;
maxWidth?: string;
panelClass?: string;
children: Snippet;
}
let {
open,
onClose,
title,
maxWidth = 'max-w-2xl',
panelClass = 'border border-line bg-elevated',
children
}: Props = $props();
let panel: HTMLDivElement | undefined = $state();
$effect(() => {
if (!open) return;
const trigger = document.activeElement as HTMLElement | null;
panel?.focus();
return () => trigger?.focus();
});
function onBackdropClick(event: MouseEvent) {
if (event.target === event.currentTarget) onClose();
}
function focusableElements(): HTMLElement[] {
if (!panel) return [];
return Array.from(
panel.querySelectorAll<HTMLElement>(
'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])'
)
);
}
function onWindowKeydown(event: KeyboardEvent) {
if (!open) return;
if (event.key === 'Escape') {
onClose();
return;
}
if (event.key !== 'Tab') return;
const elements = focusableElements();
if (elements.length === 0) return;
const first = elements[0];
const last = elements[elements.length - 1];
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.focus();
}
}
</script>
<svelte:window onkeydown={onWindowKeydown} />
{#if open}
<div
class="animate-fade fixed inset-0 z-[60] grid place-items-center bg-black/70 p-4 backdrop-blur-sm"
role="presentation"
onclick={onBackdropClick}
>
<div
bind:this={panel}
class="animate-pop w-full {maxWidth} rounded-panel p-6 shadow-2xl outline-none {panelClass}"
role="dialog"
aria-modal="true"
aria-label={title}
tabindex="-1"
>
{#if title}
<div class="flex items-center justify-between">
<h2 class="text-lg font-bold tracking-tight text-fg">{title}</h2>
<IconButton label="Cerrar" onclick={onClose}>
<X class="h-4 w-4" />
</IconButton>
</div>
{/if}
{@render children()}
</div>
</div>
{/if}
@@ -0,0 +1,25 @@
<script lang="ts">
interface Props {
paused?: boolean;
}
let { paused = false }: Props = $props();
const bars = [
{ delay: 0, dur: 0.9 },
{ delay: 0.25, dur: 0.7 },
{ delay: 0.1, dur: 1.05 },
{ delay: 0.4, dur: 0.8 }
];
</script>
<div class="absolute inset-0 flex items-end justify-center gap-[2.5px] bg-black/45 pb-2">
{#each bars as bar, i (i)}
<span
class="w-[3px] origin-bottom rounded-full bg-white"
style="height:14px; animation:equalize {bar.dur}s ease-in-out {bar.delay}s infinite; animation-play-state:{paused
? 'paused'
: 'running'}"
></span>
{/each}
</div>
@@ -0,0 +1,14 @@
<script lang="ts">
import type { Snippet } from 'svelte';
interface Props {
class?: string;
children: Snippet;
}
let { class: klass = '', children }: Props = $props();
</script>
<section class="page-x pt-11 pb-10 {klass}">
{@render children()}
</section>
@@ -0,0 +1,24 @@
<script lang="ts">
import type { Snippet } from 'svelte';
interface Props {
title: string;
subtitle?: string;
class?: string;
actions?: Snippet;
}
let { title, subtitle, class: klass = '', actions }: Props = $props();
</script>
<div class="flex items-end justify-between gap-4 {klass}">
<div class="min-w-0">
<h1 class="text-3xl font-bold tracking-tight text-fg sm:text-4xl">{title}</h1>
{#if subtitle}
<p class="mt-2 text-fg-2">{subtitle}</p>
{/if}
</div>
{#if actions}
<div class="flex shrink-0 items-center gap-3">{@render actions()}</div>
{/if}
</div>
@@ -0,0 +1,23 @@
<script lang="ts">
import Play from '@lucide/svelte/icons/play';
import Pause from '@lucide/svelte/icons/pause';
interface Props {
active: boolean;
playing: boolean;
}
let { active, playing }: Props = $props();
</script>
<span
class="absolute right-2.5 bottom-2.5 grid h-11 w-11 translate-y-2 place-items-center rounded-full bg-accent-soft text-on-accent opacity-0 shadow-lg transition-all group-hover:translate-y-0 group-hover:opacity-100"
class:!opacity-100={active}
class:!translate-y-0={active}
>
{#if active && playing}
<Pause class="h-5 w-5" fill="currentColor" />
{:else}
<Play class="h-5 w-5" fill="currentColor" />
{/if}
</span>
@@ -0,0 +1,44 @@
<script lang="ts">
import Cover from './Cover.svelte';
import { gradientForHue, hueFor } from '$lib/theme/color';
interface Props {
playlistId?: string;
trackIds: (string | number)[];
hue: number;
size?: 'small' | 'medium' | 'large';
version?: string;
class?: string;
}
let { playlistId, trackIds, hue, size = 'medium', version, class: klass = '' }: Props = $props();
let coverFailed = $state(false);
const mosaic = $derived(trackIds.length >= 4);
const single = $derived(trackIds.length >= 1 && trackIds.length < 4 ? trackIds[0] : null);
</script>
{#if playlistId && !coverFailed}
<div class="relative overflow-hidden {klass}" style="background:{gradientForHue(hue)}">
<img
src="/api/playlists/{playlistId}/cover?size={size}{version
? `&v=${encodeURIComponent(version)}`
: ''}"
alt=""
loading="lazy"
onerror={() => (coverFailed = true)}
class="h-full w-full object-cover"
/>
</div>
{:else if mosaic}
<div class="grid grid-cols-2 grid-rows-2 overflow-hidden {klass}">
{#each trackIds.slice(0, 4) as id (id)}
<Cover trackId={id} hue={hueFor(id)} size="small" class="h-full w-full" />
{/each}
</div>
{:else if single !== null}
<Cover trackId={single} hue={hueFor(single)} size="medium" class={klass} />
{:else}
<div class={klass} style="background:{gradientForHue(hue)}"></div>
{/if}
@@ -0,0 +1,33 @@
<script lang="ts">
import PlaylistArt from './PlaylistArt.svelte';
import { hueFor } from '$lib/theme/color';
interface Props {
id: string;
name: string;
description?: string;
trackIds: (string | number)[];
updatedAt: string;
index?: number;
}
let { id, name, description, trackIds, updatedAt, index = 0 }: Props = $props();
</script>
<a
href="/playlists/{id}"
class="group animate-enter min-w-0 text-left"
style="animation-delay:{index * 45}ms"
>
<PlaylistArt
playlistId={id}
{trackIds}
hue={hueFor(id)}
version={updatedAt}
class="aspect-square w-full rounded-art shadow-art transition group-hover:shadow-art-lg"
/>
<div class="mt-2.5 truncate text-fg">{name}</div>
{#if description}
<div class="mt-0.5 truncate text-sm text-fg-3">{description}</div>
{/if}
</a>
@@ -0,0 +1,96 @@
<script lang="ts">
import { untrack } from 'svelte';
import { enhance } from '$app/forms';
import ImageDropzone from './ImageDropzone.svelte';
import Button from './Button.svelte';
import Field from './Field.svelte';
import Input from './Input.svelte';
import Textarea from './Textarea.svelte';
import Alert from './Alert.svelte';
interface Props {
action: string;
initialName?: string;
initialDescription?: string;
namePlaceholder?: string;
coverFallbackUrl?: string;
formMessage?: string;
submitLabel: string;
submittingLabel: string;
onCancel: () => void;
onSuccess?: () => void;
}
let {
action,
initialName = '',
initialDescription = '',
namePlaceholder = '',
coverFallbackUrl,
formMessage,
submitLabel,
submittingLabel,
onCancel,
onSuccess
}: Props = $props();
let name = $state(untrack(() => initialName));
let description = $state(untrack(() => initialDescription));
let submitting = $state(false);
</script>
<form
method="POST"
{action}
enctype="multipart/form-data"
use:enhance={() => {
submitting = true;
return async ({ update, result }) => {
await update({ reset: false });
submitting = false;
if (result.type === 'success') onSuccess?.();
};
}}
class="mt-4 w-full max-w-4xl space-y-4"
>
<div class="flex items-stretch gap-8">
<Field label="Portada" optional>
<ImageDropzone name="cover" fallbackUrl={coverFallbackUrl} />
</Field>
<div class="flex flex-1 flex-col gap-4">
<Field label="Nombre" for="playlist-name">
<Input
id="playlist-name"
name="name"
maxlength={100}
required
bind:value={name}
placeholder={namePlaceholder}
/>
</Field>
<Field label="Descripción" for="playlist-description" optional class="min-h-0 flex-1">
<Textarea
id="playlist-description"
name="description"
maxlength={300}
bind:value={description}
placeholder="¿De qué va esta lista?"
class="flex-1"
/>
</Field>
</div>
</div>
{#if formMessage}
<Alert tone="danger">{formMessage}</Alert>
{/if}
<div class="flex items-center justify-end gap-3">
<Button type="button" variant="secondary" onclick={onCancel}>Cancelar</Button>
<Button type="submit" disabled={submitting || name.trim() === ''}>
{submitting ? submittingLabel : submitLabel}
</Button>
</div>
</form>
@@ -0,0 +1,10 @@
<script lang="ts">
interface Props {
title: string;
class?: string;
}
let { title, class: klass = '' }: Props = $props();
</script>
<h2 class="mb-6 text-2xl font-bold text-fg {klass}">{title}</h2>
@@ -0,0 +1,17 @@
<script lang="ts">
import type { Snippet } from 'svelte';
interface Props {
padding?: 'sm' | 'md' | 'lg';
class?: string;
children: Snippet;
}
let { padding = 'md', class: klass = '', children }: Props = $props();
const paddings = { sm: 'p-4', md: 'p-6', lg: 'p-12' };
</script>
<div class="rounded-panel border border-line bg-surface transition {paddings[padding]} {klass}">
{@render children()}
</div>
@@ -0,0 +1,28 @@
<script lang="ts">
interface Props {
value?: string;
id?: string;
name?: string;
placeholder?: string;
maxlength?: number;
class?: string;
}
let {
value = $bindable(''),
id,
name,
placeholder,
maxlength,
class: klass = ''
}: Props = $props();
</script>
<textarea
{id}
{name}
{placeholder}
{maxlength}
bind:value
class="min-h-0 w-full resize-none rounded-control border border-line bg-surface px-4 py-3 text-base text-fg transition placeholder:text-muted focus:border-accent/50 focus:outline-none {klass}"
></textarea>
@@ -0,0 +1,27 @@
<script lang="ts">
import Play from '@lucide/svelte/icons/play';
import NowPlaying from './NowPlaying.svelte';
interface Props {
index: number;
active: boolean;
playing: boolean;
onToggle: () => void;
}
let { index, active, playing, onToggle }: Props = $props();
</script>
<button
type="button"
onclick={onToggle}
aria-label={active && playing ? 'Pausar' : 'Reproducir'}
class="relative grid h-8 w-8 place-items-center overflow-hidden rounded text-sm text-muted"
>
{#if active}
<NowPlaying paused={!playing} />
{:else}
<span class="group-hover:hidden">{index + 1}</span>
<Play class="hidden h-3.5 w-3.5 text-fg group-hover:block" fill="currentColor" />
{/if}
</button>
@@ -0,0 +1,14 @@
<script lang="ts">
import type { Snippet } from 'svelte';
interface Props {
class?: string;
children: Snippet;
}
let { class: klass = '', children }: Props = $props();
</script>
<span class="truncate text-center text-sm text-muted tabular-nums {klass}">
{@render children()}
</span>
@@ -0,0 +1,24 @@
<script lang="ts">
import { getContext } from 'svelte';
import type { Snippet } from 'svelte';
import { trackTableKey, type TrackTableContext } from './TrackTable.svelte';
interface Props {
class?: string;
oncontextmenu?: (event: MouseEvent) => void;
children: Snippet;
}
let { class: klass = '', oncontextmenu, children }: Props = $props();
const table = getContext<TrackTableContext>(trackTableKey);
</script>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="group grid items-center gap-8 rounded-control px-3 py-2.5 transition hover:bg-surface {klass}"
style="grid-template-columns:{table.columns}"
{oncontextmenu}
>
{@render children()}
</div>
@@ -0,0 +1,39 @@
<script lang="ts" module>
export const trackTableKey = Symbol('track-table');
export interface TrackTableContext {
readonly columns: string;
}
</script>
<script lang="ts">
import { setContext } from 'svelte';
import type { Snippet } from 'svelte';
interface Props {
columns: string;
class?: string;
headers: Snippet;
children: Snippet;
}
let { columns, class: klass = '', headers, children }: Props = $props();
setContext<TrackTableContext>(trackTableKey, {
get columns() {
return columns;
}
});
</script>
<div class={klass}>
<div
class="grid items-center gap-8 border-b border-line px-3 pb-2 text-sm font-medium tracking-wide text-muted uppercase"
style="grid-template-columns:{columns}"
>
{@render headers()}
</div>
<div class="mt-1 flex flex-col">
{@render children()}
</div>
</div>
@@ -0,0 +1,58 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import Cover from './Cover.svelte';
import ExplicitBadge from './ExplicitBadge.svelte';
interface Props {
trackId: string | number;
title: string;
artist?: string;
coverSrc?: string;
coverSize?: string;
hue: number;
explicit?: boolean;
titleClass?: string;
onClick: () => void;
overlay?: Snippet;
badge?: Snippet;
}
let {
trackId,
title,
artist,
coverSrc,
coverSize = 'h-[42px] w-[42px]',
hue,
explicit = false,
titleClass = '',
onClick,
overlay,
badge
}: Props = $props();
</script>
<button type="button" onclick={onClick} class="flex min-w-0 items-center gap-3.5 text-left">
<Cover
{trackId}
src={coverSrc}
{hue}
size="small"
alt={title}
class="{coverSize} flex-shrink-0 rounded-control"
>
{@render overlay?.()}
</Cover>
<div class="min-w-0 flex-1">
<div class="flex min-w-0 items-center gap-2">
{#if explicit}
<ExplicitBadge />
{/if}
<span class="min-w-0 truncate text-fg {titleClass}">{title}</span>
{@render badge?.()}
</div>
{#if artist}
<div class="truncate text-left text-sm text-fg-3">{artist}</div>
{/if}
</div>
</button>
+3
View File
@@ -0,0 +1,3 @@
export const EXPLORE_PAGE_SIZE = 27;
export const HOME_LATEST_PAGE_SIZE = 12;
export const YOUTUBE_FILLER_LIMIT = 27;
+420
View File
@@ -0,0 +1,420 @@
import { browser } from '$app/environment';
import { HUES, accentForHue, gradientForHue, hueFor } from '$lib/theme/color';
import { extractAccent, type Accent } from '$lib/theme/palette';
export type TrackSourceKind = 'local' | 'youtube';
export interface PlayerTrack {
id: string | number;
title: string;
artist: string;
duration: number;
hue: number;
source: TrackSourceKind;
coverUrl?: string;
explicit?: boolean;
}
export interface Playlist {
id: string | number;
name: string;
trackIds: (string | number)[];
}
export interface QueueItem {
id: string | number;
title: string;
artist?: string;
source?: TrackSourceKind;
coverUrl?: string;
explicit?: boolean;
}
export interface ApiTrackLike {
id: string | number;
title: string;
artist?: string;
source?: 'Local' | 'YouTube';
externalId?: string;
audioStatus?: 'Pending' | 'Processing' | 'Completed' | 'Failed';
isExplicit?: boolean;
}
export function isYouTubeTrack(track: ApiTrackLike): boolean {
return track.source === 'YouTube';
}
export function isPendingYouTubeTrack(track: ApiTrackLike): boolean {
return isYouTubeTrack(track) && track.audioStatus !== 'Completed';
}
export function youTubeThumbnailUrl(videoId: string): string {
return `https://i.ytimg.com/vi/${videoId}/mqdefault.jpg`;
}
export function queueIdForTrack(track: ApiTrackLike): string | number {
return isYouTubeTrack(track) && track.externalId ? track.externalId : track.id;
}
export function toQueueItems(tracks: ApiTrackLike[]): QueueItem[] {
return tracks.map((track) => {
const youTube = isYouTubeTrack(track);
return {
id: queueIdForTrack(track),
title: track.title,
artist: track.artist,
source: youTube ? 'youtube' : 'local',
coverUrl: youTube
? isPendingYouTubeTrack(track) && track.externalId
? youTubeThumbnailUrl(track.externalId)
: `/api/tracks/${track.id}/cover?size=small`
: undefined,
explicit: track.isExplicit
};
});
}
const RECENT_LIMIT = 15;
const EMPTY: PlayerTrack = {
id: '',
title: '',
artist: '',
duration: 0,
hue: HUES[0],
source: 'local'
};
function toTrack(item: QueueItem): PlayerTrack {
return {
id: item.id,
title: item.title,
artist: item.artist ?? '',
duration: 0,
hue: hueFor(item.id),
explicit: item.explicit,
source: item.source ?? 'local',
coverUrl: item.coverUrl
};
}
class PlayerState {
tracks = $state<PlayerTrack[]>([]);
currentId = $state<string | number | null>(null);
playing = $state(false);
progress = $state(0);
volume = $state(browser ? Number(localStorage.getItem('player.volume') ?? 100) : 100);
loading = $state(false);
error = $state('');
recentlyPlayed = $state<PlayerTrack[]>([]);
playlists = $state<Playlist[]>([]);
accentColor = $state<string | null>(null);
gradientColor = $state<string | null>(null);
#audio: HTMLAudioElement | null = null;
#loadToken = 0;
#retriedId: string | number | null = null;
#accentCache = new Map<string, Accent>();
#rafId: number | null = null;
current = $derived(this.tracks.find((t) => t.id === this.currentId) ?? EMPTY);
accent = $derived(this.accentColor ?? accentForHue(this.current.hue));
artGradient = $derived(this.gradientColor ?? gradientForHue(this.current.hue));
progressPercent = $derived(
this.current.duration > 0 ? Math.min(100, (this.progress / this.current.duration) * 100) : 0
);
get isPlaying() {
return this.playing;
}
#audioEl(): HTMLAudioElement | null {
if (!browser) return null;
if (this.#audio) return this.#audio;
const audio = new Audio();
audio.preload = 'auto';
audio.volume = this.volume / 100;
audio.addEventListener('durationchange', () => this.#syncDuration(audio.duration));
audio.addEventListener('loadedmetadata', () => this.#syncDuration(audio.duration));
audio.addEventListener('play', () => {
this.playing = true;
this.#startProgressLoop();
this.#syncMediaSessionPlaybackState();
});
audio.addEventListener('pause', () => {
this.playing = false;
this.#stopProgressLoop();
this.#syncMediaSessionPlaybackState();
});
audio.addEventListener('ended', () => {
this.#stopProgressLoop();
this.next();
});
audio.addEventListener('error', () => {
this.#stopProgressLoop();
if (
this.currentId !== null &&
this.current.source === 'youtube' &&
this.#retriedId !== this.currentId
) {
this.#retriedId = this.currentId;
this.#loadCurrent();
return;
}
this.loading = false;
this.playing = false;
this.error = 'No se pudo reproducir la pista.';
});
this.#audio = audio;
this.#setupMediaSession(audio);
return audio;
}
#setupMediaSession(audio: HTMLAudioElement) {
if (!('mediaSession' in navigator)) return;
navigator.mediaSession.setActionHandler('play', () => audio.play().catch(() => {}));
navigator.mediaSession.setActionHandler('pause', () => audio.pause());
navigator.mediaSession.setActionHandler('previoustrack', () => this.previous());
navigator.mediaSession.setActionHandler('nexttrack', () => this.next());
}
#syncMediaSessionPlaybackState() {
if (!browser || !('mediaSession' in navigator)) return;
navigator.mediaSession.playbackState = this.playing ? 'playing' : 'paused';
}
#syncMediaSessionMetadata(track: PlayerTrack) {
if (!browser || !('mediaSession' in navigator)) return;
if (!track.id) {
navigator.mediaSession.metadata = null;
return;
}
const artwork = track.coverUrl ?? `/api/tracks/${track.id}/cover?size=small`;
navigator.mediaSession.metadata = new MediaMetadata({
title: track.title,
artist: track.artist,
artwork: [{ src: artwork, sizes: '256x256' }]
});
}
#tickProgress = () => {
const audio = this.#audio;
if (!audio) return;
this.progress = audio.currentTime;
this.#rafId = requestAnimationFrame(this.#tickProgress);
};
#startProgressLoop() {
if (this.#rafId !== null) return;
this.#rafId = requestAnimationFrame(this.#tickProgress);
}
#stopProgressLoop() {
if (this.#rafId === null) return;
cancelAnimationFrame(this.#rafId);
this.#rafId = null;
}
#syncDuration(value: number) {
if (!Number.isFinite(value) || value <= 0) return;
const id = this.currentId;
this.tracks = this.tracks.map((t) => (t.id === id ? { ...t, duration: Math.round(value) } : t));
}
#index() {
return this.tracks.findIndex((t) => t.id === this.currentId);
}
#pushRecent(track: PlayerTrack) {
if (!track.id) return;
this.recentlyPlayed = [track, ...this.recentlyPlayed.filter((t) => t.id !== track.id)].slice(
0,
RECENT_LIMIT
);
}
async #applyAccent(id: string | number, coverUrl?: string) {
const key = String(id);
const cached = this.#accentCache.get(key);
if (cached) {
this.accentColor = cached.accent;
this.gradientColor = cached.gradient;
return;
}
const result = await extractAccent(coverUrl ?? `/api/tracks/${id}/cover?size=small`);
if (this.currentId !== id) return;
if (result) {
this.#accentCache.set(key, result);
this.accentColor = result.accent;
this.gradientColor = result.gradient;
} else {
this.accentColor = null;
this.gradientColor = null;
}
}
async #loadCurrent() {
const audio = this.#audioEl();
if (!audio || this.currentId === null) return;
const id = this.currentId;
const track = this.tracks.find((t) => t.id === id);
this.#applyAccent(id, track?.coverUrl);
if (track) this.#syncMediaSessionMetadata(track);
const token = ++this.#loadToken;
this.loading = true;
this.error = '';
this.progress = 0;
try {
const src =
track?.source === 'youtube'
? await this.#resolveYouTubeSrc(String(id))
: await this.#resolveLocalSrc(String(id));
if (token !== this.#loadToken) return;
audio.src = src;
audio.volume = this.volume / 100;
await audio.play();
this.loading = false;
this.#retriedId = null;
this.#pushRecent(this.current);
} catch (exception) {
console.error('playback failed', exception);
if (token !== this.#loadToken) return;
this.loading = false;
this.playing = false;
this.error = 'No se pudo reproducir la pista.';
}
}
async #resolveLocalSrc(id: string): Promise<string> {
const res = await fetch(`/api/tracks/${id}/stream`);
if (!res.ok) throw new Error(String(res.status));
const { manifestUrl, ticket } = (await res.json()) as {
manifestUrl: string;
ticket: string;
};
return this.#withTicket(manifestUrl, ticket);
}
async #resolveYouTubeSrc(videoId: string): Promise<string> {
const res = await fetch(`/api/youtube/tracks/${videoId}/stream`);
if (!res.ok) throw new Error(String(res.status));
const { mode, streamUrl, ticket } = (await res.json()) as {
mode: string;
streamUrl: string;
ticket: string;
};
return mode === 'Server' ? this.#withTicket(streamUrl, ticket) : streamUrl;
}
#withTicket(url: string, ticket: string): string {
if (!ticket) return url;
const sep = url.includes('?') ? '&' : '?';
return `${url}${sep}t=${encodeURIComponent(ticket)}`;
}
playlistTracks(ids: (string | number)[]): PlayerTrack[] {
return ids
.map((id) => this.tracks.find((t) => t.id === id))
.filter((t): t is PlayerTrack => Boolean(t));
}
mosaicFor(ids: (string | number)[]): string[] {
const gradients = this.playlistTracks(ids)
.slice(0, 4)
.map((t) => gradientForHue(t.hue));
while (gradients.length < 4) gradients.push('var(--mf-surface)');
return gradients;
}
playQueue(list: QueueItem[], startIndex = 0) {
if (list.length === 0) return;
this.tracks = list.map(toTrack);
const start = this.tracks[startIndex] ?? this.tracks[0];
this.currentId = start.id;
this.#loadCurrent();
}
playOrToggle(list: QueueItem[], index: number) {
if (index < 0 || index >= list.length) return;
if (this.currentId === list[index].id) this.toggle();
else this.playQueue(list, index);
}
addToQueue(item: QueueItem) {
if (this.currentId === null) {
this.playQueue([item], 0);
return;
}
const tracks = [...this.tracks];
tracks.splice(this.#index() + 1, 0, toTrack(item));
this.tracks = tracks;
}
playTrack(id: string | number) {
if (this.currentId === id) {
this.toggle();
return;
}
const known =
this.tracks.find((t) => t.id === id) ?? this.recentlyPlayed.find((t) => t.id === id);
if (known && !this.tracks.some((t) => t.id === id)) {
this.tracks = [known];
}
if (!this.tracks.some((t) => t.id === id)) return;
this.currentId = id;
this.#loadCurrent();
}
playPlaylist(id: string | number) {
const playlist = this.playlists.find((p) => p.id === id);
if (!playlist) return;
const items = this.playlistTracks(playlist.trackIds);
if (items.length > 0) this.playQueue(items, 0);
}
toggle() {
const audio = this.#audioEl();
if (!audio || this.currentId === null) return;
if (audio.paused) audio.play().catch(() => {});
else audio.pause();
}
next() {
if (this.tracks.length === 0) return;
const idx = this.#index();
this.currentId = this.tracks[(idx + 1) % this.tracks.length].id;
this.#loadCurrent();
}
previous() {
if (this.tracks.length === 0) return;
const audio = this.#audioEl();
if (audio && audio.currentTime > 3) {
audio.currentTime = 0;
return;
}
const idx = this.#index();
this.currentId = this.tracks[(idx - 1 + this.tracks.length) % this.tracks.length].id;
this.#loadCurrent();
}
seekFraction(fraction: number) {
const audio = this.#audioEl();
const clamped = Math.min(1, Math.max(0, fraction));
const target = clamped * this.current.duration;
this.progress = target;
if (audio && Number.isFinite(audio.duration)) audio.currentTime = target;
}
setVolume(value: number) {
this.volume = Math.min(100, Math.max(0, Math.round(value)));
const audio = this.#audioEl();
if (audio) {
audio.volume = this.volume / 100;
localStorage.setItem('player.volume', String(this.volume));
}
}
}
export const player = new PlayerState();
+69
View File
@@ -0,0 +1,69 @@
import createClient from 'openapi-fetch';
import { error, fail, redirect, type ActionFailure } from '@sveltejs/kit';
import type { paths } from '$lib/api/schema';
import type { SessionUser } from '$lib/types';
import { apiConfig } from '$lib/server/config';
interface ApiClientOptions {
fetch: typeof fetch;
accessToken?: string;
}
export function createApiClient({ fetch, accessToken }: ApiClientOptions) {
return createClient<paths>({
baseUrl: apiConfig.baseUrl,
fetch,
headers: accessToken ? { Authorization: `Bearer ${accessToken}` } : undefined
});
}
export function requireUser(locals: App.Locals, url: URL): SessionUser {
if (!locals.user)
redirect(302, `/login?returnTo=${encodeURIComponent(url.pathname + url.search)}`);
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 requireAccessTokenAction(
locals: App.Locals,
message = 'Inicia sesión.'
): string | ActionFailure<{ message: string }> {
return locals.accessToken ?? fail(401, { message });
}
export function unwrapOrError<T>(
result: { data?: T; error?: unknown; response?: Response },
message: string,
status?: number
): T {
if (result.error || result.data === undefined) {
error(status ?? result.response?.status ?? 502, message);
}
return result.data;
}
export function unwrapOrFail(
result: { error?: unknown },
message: string
): ActionFailure<{ message: string }> | undefined {
return result.error ? fail(502, { message }) : undefined;
}
export async function playlistCoverTrackIds(
api: ReturnType<typeof createApiClient>,
playlistIds: string[]
): Promise<Record<string, string[]>> {
const covers = await Promise.all(
playlistIds.map(async (playlistId) => {
const { data: tracks } = await api.GET('/playlists/{playlistId}/tracks', {
params: { path: { playlistId }, query: { pageNumber: 1, pageSize: 4 } }
});
return [playlistId, tracks?.items.map((t) => t.id) ?? []] as const;
})
);
return Object.fromEntries(covers);
}
+109
View File
@@ -0,0 +1,109 @@
import * as client from 'openid-client';
import { EncryptJWT, jwtDecrypt } from 'jose';
import { dev } from '$app/environment';
import { authConfig } from '$lib/server/config';
import type { SessionUser } from '$lib/types';
export const SESSION_COOKIE = 'mf_session';
export const STATE_COOKIE = 'mf_oidc_state';
export const VERIFIER_COOKIE = 'mf_oidc_verifier';
export const NONCE_COOKIE = 'mf_oidc_nonce';
export const RETURN_COOKIE = 'mf_oidc_return';
export interface Session extends SessionUser {
accessToken: string;
refreshToken: string;
idToken: string;
expiresAt: number;
}
let configPromise: Promise<client.Configuration>;
export function getOidcConfig(): Promise<client.Configuration> {
configPromise ??= (async () => {
const issuer = new URL(authConfig.issuer);
const insecureAllowed = (dev || authConfig.allowInsecureHttp) && issuer.protocol === 'http:';
const options = insecureAllowed ? { execute: [client.allowInsecureRequests] } : undefined;
const secret = authConfig.clientSecret;
return secret
? client.discovery(issuer, authConfig.clientId, secret, undefined, options)
: client.discovery(issuer, authConfig.clientId, undefined, client.None(), options);
})();
return configPromise;
}
let keyPromise: Promise<Uint8Array>;
function sessionKey(): Promise<Uint8Array> {
keyPromise ??= crypto.subtle
.digest('SHA-256', new TextEncoder().encode(authConfig.sessionSecret))
.then((digest) => new Uint8Array(digest));
return keyPromise;
}
export function sessionCookieOptions(url: URL) {
return {
httpOnly: true,
sameSite: 'lax' as const,
secure: url.protocol === 'https:',
path: '/',
maxAge: authConfig.sessionTtlSeconds
};
}
export async function encodeSession(session: Session): Promise<string> {
const now = Math.floor(Date.now() / 1000);
return new EncryptJWT({ ...session })
.setProtectedHeader({ alg: 'dir', enc: 'A256GCM' })
.setIssuedAt(now)
.setExpirationTime(now + authConfig.sessionTtlSeconds)
.encrypt(await sessionKey());
}
export async function decodeSession(token: string | undefined): Promise<Session | null> {
if (!token) return null;
try {
const { payload } = await jwtDecrypt(token, await sessionKey());
return payload as unknown as Session;
} catch {
return null;
}
}
function claimString(value: unknown): string | undefined {
return typeof value === 'string' && value.length > 0 ? value : undefined;
}
export function toSessionUser(
claims: Record<string, unknown>,
fallback?: SessionUser
): SessionUser {
const sub = claimString(claims.sub) ?? fallback?.sub ?? '';
return {
sub,
name:
claimString(claims.name) ??
claimString(claims.preferred_username) ??
fallback?.name ??
claimString(claims.email) ??
sub,
email: claimString(claims.email) ?? fallback?.email ?? '',
picture: claimString(claims.picture) ?? fallback?.picture ?? ''
};
}
export async function refreshSession(session: Session): Promise<Session | null> {
try {
const config = await getOidcConfig();
const tokens = await client.refreshTokenGrant(config, session.refreshToken);
return {
...toSessionUser(tokens.claims() ?? {}, session),
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token ?? session.refreshToken,
idToken: tokens.id_token ?? session.idToken,
expiresAt: Math.floor(Date.now() / 1000) + (tokens.expires_in ?? 3600)
};
} catch (exception) {
console.error('Token refresh failed', exception);
return null;
}
}
+53
View File
@@ -0,0 +1,53 @@
import { env } from '$env/dynamic/private';
const DAY_SECONDS = 60 * 60 * 24;
const DEFAULT_OIDC_SCOPE = 'openid profile email offline_access';
function positiveInt(value: string | undefined, fallback: number): number {
if (value === undefined || value === '') return fallback;
const parsed = Number(value);
return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback;
}
export const authConfig = {
get issuer() {
return env.ZITADEL_ISSUER;
},
get clientId() {
return env.ZITADEL_CLIENT_ID;
},
get clientSecret(): string | undefined {
const secret = env.ZITADEL_CLIENT_SECRET;
return secret && !secret.startsWith('REPLACE_') ? secret : undefined;
},
get scope() {
return env.OIDC_SCOPE || DEFAULT_OIDC_SCOPE;
},
get redirectUri() {
return env.AUTH_REDIRECT_URI;
},
get postLogoutUri() {
return env.AUTH_POST_LOGOUT_URI;
},
get sessionSecret() {
return env.SESSION_SECRET;
},
get sessionTtlSeconds() {
return positiveInt(env.SESSION_TTL_SECONDS, 7 * DAY_SECONDS);
},
get refreshThresholdSeconds() {
return positiveInt(env.SESSION_REFRESH_THRESHOLD_SECONDS, 30);
},
get flowTtlSeconds() {
return positiveInt(env.AUTH_FLOW_TTL_SECONDS, 600);
},
get allowInsecureHttp() {
return env.AUTH_ALLOW_INSECURE_HTTP === 'true';
}
};
export const apiConfig = {
get baseUrl() {
return (env.API_BASE_URL ?? '').replace(/\/+$/, '');
}
};
+24
View File
@@ -0,0 +1,24 @@
const GREETINGS_BY_HOUR: { until: number; pool: string[] }[] = [
{
until: 6,
pool: ['¿Aún despierto?', 'La noche también tiene banda sonora', 'Un ratito más, con música']
},
{
until: 12,
pool: ['Buenos días', 'Que empiece bien la mañana', 'Un nuevo día, una nueva lista']
},
{ until: 14, pool: ['Buenos días', 'El mediodía sabe mejor con música'] },
{
until: 19,
pool: ['Buenas tardes', 'La tarde pide su banda sonora', 'Un respiro, con algo de música']
},
{ until: 23, pool: ['Buenas noches', 'Hora de bajar el ritmo', 'Que la noche te acompañe'] },
{ until: 24, pool: ['¿Aún despierto?', 'La noche también tiene banda sonora'] }
];
export function pickGreeting(hour: number): string {
const pool =
GREETINGS_BY_HOUR.find((slot) => hour < slot.until)?.pool ?? GREETINGS_BY_HOUR[0].pool;
const greeting = pool[Math.floor(Math.random() * pool.length)];
return /[.!?]$/.test(greeting) ? greeting : `${greeting}.`;
}
+26
View File
@@ -0,0 +1,26 @@
import { apiConfig } from '$lib/server/config';
const ALLOWED_SIZES = new Set(['small', 'medium', 'large']);
export async function proxyCoverImage(
fetch: typeof globalThis.fetch,
kind: 'playlists' | 'tracks',
id: string,
size: string | null
): Promise<Response> {
const safeSize = size && ALLOWED_SIZES.has(size) ? size : 'medium';
const upstream = await fetch(`${apiConfig.baseUrl}/${kind}/${id}/cover?size=${safeSize}`);
if (!upstream.ok) {
return new Response(null, { status: upstream.status });
}
return new Response(upstream.body, {
status: 200,
headers: {
'Content-Type': upstream.headers.get('content-type') ?? 'image/webp',
'Cache-Control': 'public, max-age=86400'
}
});
}
@@ -0,0 +1,61 @@
import { fail, type RequestEvent } from '@sveltejs/kit';
import { createApiClient, requireAccessTokenAction } from '$lib/server/api';
export async function addTrackAction({ request, params, locals, fetch }: RequestEvent) {
const accessToken = requireAccessTokenAction(locals);
if (typeof accessToken !== 'string') return accessToken;
const form = await request.formData();
const playlistId = String(form.get('playlistId') ?? '') || String(params.id ?? '');
const trackId = String(form.get('trackId') ?? '');
if (!playlistId || !trackId) {
return fail(400, { message: 'Faltan datos de la canción.' });
}
const api = createApiClient({ fetch, accessToken });
const { error: err, response } = await api.POST('/playlists/{playlistId}/tracks/{trackId}', {
params: { path: { playlistId, trackId } }
});
if (err) {
if (response?.status === 409) {
return fail(409, { message: 'La canción ya está en esa playlist.', trackId });
}
return fail(502, { message: 'No se pudo añadir la canción.', trackId });
}
return { added: true, trackId, playlistId };
}
export async function addYouTubeToPlaylistAction({ request, params, locals, fetch }: RequestEvent) {
const accessToken = requireAccessTokenAction(locals);
if (typeof accessToken !== 'string') return accessToken;
const form = await request.formData();
const playlistId = String(form.get('playlistId') ?? '') || String(params.id ?? '');
const videoId = String(form.get('videoId') ?? '');
const title = String(form.get('title') ?? '');
const artist = String(form.get('artist') ?? '');
const durationSeconds = Number(form.get('durationSeconds') ?? 0);
const thumbnailUrl = String(form.get('thumbnailUrl') ?? '');
if (!playlistId || !videoId || !title) {
return fail(400, { message: 'Faltan datos de la canción.' });
}
const api = createApiClient({ fetch, accessToken });
const { error: err, response } = await api.POST('/playlists/{playlistId}/youtube-tracks', {
params: { path: { playlistId } },
body: { videoId, title, artist, durationSeconds, thumbnailUrl }
});
if (err) {
if (response?.status === 409) {
return fail(409, { message: 'La canción ya está en esa playlist.', videoId });
}
return fail(502, { message: 'No se pudo añadir la canción.', videoId });
}
return { added: true, videoId, playlistId };
}
+110
View File
@@ -0,0 +1,110 @@
import http from 'node:http';
import https from 'node:https';
import { env } from '$env/dynamic/private';
const CONNECT_HOST = env.S3_UPLOAD_CONNECT_HOST || '127.0.0.1';
export const IMAGE_TYPES: Record<string, string> = {
jpg: 'image/jpeg',
jpeg: 'image/jpeg',
png: 'image/png',
webp: 'image/webp'
};
export const AUDIO_TYPES: Record<string, string> = {
mp3: 'audio/mpeg',
m4a: 'audio/mp4',
aac: 'audio/aac',
flac: 'audio/flac',
wav: 'audio/wav',
ogg: 'audio/ogg',
opus: 'audio/opus'
};
export function extOf(name: string): string {
const dot = name.lastIndexOf('.');
return dot >= 0 ? name.slice(dot + 1).toLowerCase() : '';
}
export function contentTypeOf(file: File, table: Record<string, string> = IMAGE_TYPES): string {
if (file.type) return file.type;
return table[extOf(file.name)] ?? 'application/octet-stream';
}
export async function putPresigned(
presignedUrl: string,
body: Buffer,
contentType: string
): Promise<void> {
const url = new URL(presignedUrl);
const signed = new Set(
(url.searchParams.get('X-Amz-SignedHeaders') ?? 'host').toLowerCase().split(';')
);
const headers: Record<string, string | number> = {
Host: url.host,
'Content-Length': body.byteLength
};
if (signed.has('content-type')) headers['Content-Type'] = contentType;
if (signed.has('if-none-match')) headers['If-None-Match'] = '*';
const secure = url.protocol === 'https:';
const transport = secure ? https : http;
const port = url.port || (secure ? 443 : 80);
await new Promise<void>((resolve, reject) => {
const request = transport.request(
{
host: CONNECT_HOST,
port,
method: 'PUT',
path: url.pathname + url.search,
headers
},
(response) => {
let raw = '';
response.on('data', (chunk) => (raw += chunk));
response.on('end', () => {
const status = response.statusCode ?? 0;
if (status >= 200 && status < 300) resolve();
else reject(new Error(`S3 PUT ${status}: ${raw.slice(0, 200)}`));
});
}
);
request.on('error', reject);
request.write(body);
request.end();
});
}
export type UploadImageResult = { intentId: string } | { failMessage: string; detail?: string };
export async function uploadPresignedImage(
reserve: (args: { fileType: string; contentType: string; expectedSizeBytes: number }) => Promise<{
data?: { uploadUrl: string; contentType: string; intentId: string } | null;
error?: unknown;
}>,
file: File
): Promise<UploadImageResult> {
const { data: reserved, error: reserveError } = await reserve({
fileType: extOf(file.name) || 'jpg',
contentType: contentTypeOf(file),
expectedSizeBytes: file.size
});
if (reserveError || !reserved) {
return { failMessage: 'No se pudo reservar la subida de la portada.' };
}
try {
const buffer = await file.arrayBuffer();
await putPresigned(reserved.uploadUrl, Buffer.from(buffer), reserved.contentType);
} catch (err) {
return {
failMessage: 'Falló la subida de la portada al almacenamiento.',
detail: err instanceof Error ? err.message : undefined
};
}
return { intentId: reserved.intentId };
}
+54
View File
@@ -0,0 +1,54 @@
import type { createApiClient } from '$lib/server/api';
import type { YouTubeSong } from '$lib/types';
export const YOUTUBE_FILLER_QUERIES = [
'pop',
'rock',
'reggaeton',
'lo-fi',
'indie',
'salsa',
'k-pop',
'electrónica',
'jazz',
'baladas',
'trap',
'cumbia',
'hip hop',
'música clásica'
];
export interface YoutubeFiller {
query: string;
items: YouTubeSong[];
continuationToken: string;
}
export function pickRandomFillerQuery(): string {
return YOUTUBE_FILLER_QUERIES[Math.floor(Math.random() * YOUTUBE_FILLER_QUERIES.length)];
}
export function shuffle<T>(items: T[]): T[] {
const copy = [...items];
for (let i = copy.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[copy[i], copy[j]] = [copy[j], copy[i]];
}
return copy;
}
export async function fetchYoutubeFiller(
api: ReturnType<typeof createApiClient>,
accessToken: string | null | undefined,
limit: number,
seed?: string
): Promise<YoutubeFiller> {
if (!accessToken) return { query: '', items: [], continuationToken: '' };
const query = seed?.trim() || pickRandomFillerQuery();
const { data } = await api.GET('/youtube/search', { params: { query: { query } } });
return {
query,
items: ((data?.items ?? []) as YouTubeSong[]).slice(0, limit),
continuationToken: data?.continuationToken ?? ''
};
}
+37
View File
@@ -0,0 +1,37 @@
export const HUES = [145, 330, 40, 152, 300, 200, 10, 120, 280];
export const ACCENT_LIGHTNESS = 60;
export const ACCENT_CHROMA = 0.17;
export const ACCENT_HUE = 145;
export function hueFor(id: string | number): number {
const text = String(id);
let hash = 0;
for (let i = 0; i < text.length; i++) hash = (hash * 31 + text.charCodeAt(i)) >>> 0;
return HUES[hash % HUES.length];
}
export function accentForHue(hue: number): string {
return `oklch(${ACCENT_LIGHTNESS}% ${ACCENT_CHROMA} ${hue})`;
}
export function accentSoft(hue: number, pct: number): string {
return `color-mix(in srgb, ${accentForHue(hue)} ${pct}%, transparent)`;
}
export function gradientForHue(hue: number): string {
return `linear-gradient(135deg, oklch(70% 0.15 ${hue}), oklch(36% 0.13 ${hue + 28}))`;
}
export function fmtTime(seconds: number): string {
const total = Math.max(0, Math.floor(seconds || 0));
const minutes = Math.floor(total / 60);
const rest = total % 60;
return `${minutes}:${rest < 10 ? '0' : ''}${rest}`;
}
const dateFormatter = new Intl.DateTimeFormat('es', { dateStyle: 'medium' });
export function fmtDate(value: string | number | Date): string {
return dateFormatter.format(new Date(value));
}
+114
View File
@@ -0,0 +1,114 @@
import { browser } from '$app/environment';
import { ACCENT_LIGHTNESS } from './color';
export interface Accent {
accent: string;
gradient: string;
}
function srgbToLinear(channel: number): number {
const c = channel / 255;
return c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
}
function rgbToOklch(r: number, g: number, b: number): [number, number, number] {
const lr = srgbToLinear(r);
const lg = srgbToLinear(g);
const lb = srgbToLinear(b);
const l = 0.4122214708 * lr + 0.5363325363 * lg + 0.0514459929 * lb;
const m = 0.2119034982 * lr + 0.6806995451 * lg + 0.1073969566 * lb;
const s = 0.0883024619 * lr + 0.2817188376 * lg + 0.6299787005 * lb;
const l_ = Math.cbrt(l);
const m_ = Math.cbrt(m);
const s_ = Math.cbrt(s);
const L = 0.2104542553 * l_ + 0.793617785 * m_ - 0.0040720468 * s_;
const a = 1.9779984951 * l_ - 2.428592205 * m_ + 0.4505937099 * s_;
const bb = 0.0259040371 * l_ + 0.7827717662 * m_ - 0.808675766 * s_;
const C = Math.sqrt(a * a + bb * bb);
const H = (Math.atan2(bb, a) * 180) / Math.PI;
return [L, C, (H + 360) % 360];
}
function loadImage(url: string): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const img = new Image();
img.crossOrigin = 'anonymous';
img.onload = () => resolve(img);
img.onerror = reject;
img.src = url;
});
}
const BUCKETS = 36;
export async function extractAccent(url: string): Promise<Accent | null> {
if (!browser) return null;
let img: HTMLImageElement;
try {
img = await loadImage(url);
} catch {
return null;
}
const size = 40;
const canvas = document.createElement('canvas');
canvas.width = size;
canvas.height = size;
const ctx = canvas.getContext('2d', { willReadFrequently: true });
if (!ctx) return null;
ctx.drawImage(img, 0, 0, size, size);
let pixels: Uint8ClampedArray;
try {
pixels = ctx.getImageData(0, 0, size, size).data;
} catch {
return null;
}
const weight = new Array(BUCKETS).fill(0);
const hueSin = new Array(BUCKETS).fill(0);
const hueCos = new Array(BUCKETS).fill(0);
const chromaSum = new Array(BUCKETS).fill(0);
for (let i = 0; i < pixels.length; i += 4) {
if (pixels[i + 3] < 128) continue;
const [L, C, H] = rgbToOklch(pixels[i], pixels[i + 1], pixels[i + 2]);
if (L < 0.2 || L > 0.92 || C < 0.03) continue;
const bucket = Math.floor((H / 360) * BUCKETS) % BUCKETS;
const w = C * C;
const rad = (H * Math.PI) / 180;
weight[bucket] += w;
hueSin[bucket] += Math.sin(rad) * w;
hueCos[bucket] += Math.cos(rad) * w;
chromaSum[bucket] += C * w;
}
let best = -1;
let bestWeight = 0;
for (let b = 0; b < BUCKETS; b++) {
if (weight[b] > bestWeight) {
bestWeight = weight[b];
best = b;
}
}
if (best < 0) return null;
const hue = ((Math.atan2(hueSin[best], hueCos[best]) * 180) / Math.PI + 360) % 360;
const chroma = Math.min(0.19, Math.max(0.1, chromaSum[best] / weight[best]));
const h = hue.toFixed(1);
const c = chroma.toFixed(3);
const c2 = (chroma * 0.85).toFixed(3);
const h2 = ((hue + 25) % 360).toFixed(1);
return {
accent: `oklch(${ACCENT_LIGHTNESS}% ${c} ${h})`,
gradient: `linear-gradient(135deg, oklch(74% ${c} ${h}), oklch(56% ${c2} ${h2}))`
};
}
+22
View File
@@ -0,0 +1,22 @@
:root {
--mf-bg: rgb(9, 9, 9);
--mf-elevated: #0b0b0d;
--mf-text: #c7c6c5;
--mf-text-2: #a5a4a3;
--mf-text-3: #8c8b8a;
--mf-text-4: #7f7e7d;
--mf-surface: rgb(18, 18, 18);
--mf-surface-2: rgba(255, 255, 255, 0.06);
--mf-surface-hover: rgba(255, 255, 255, 0.09);
--mf-border: rgba(255, 255, 255, 0.08);
--mf-border-strong: rgba(255, 255, 255, 0.12);
--mf-track: rgba(255, 255, 255, 0.12);
--mf-accent: oklch(60% 0.17 145);
--mf-accent-60: color-mix(in oklch, var(--mf-accent) 80%, transparent);
--mf-radius: 12px;
--mf-sidebar-w: 264px;
}
+78
View File
@@ -0,0 +1,78 @@
import {
player,
queueIdForTrack,
toQueueItems,
type ApiTrackLike,
type QueueItem
} from '$lib/player/player.svelte';
import type { YouTubeSong } from '$lib/types';
export type TrackTarget =
{ kind: 'youtube'; song: YouTubeSong } | { kind: 'local'; track: ApiTrackLike };
export function targetId(target: TrackTarget): string | number {
return target.kind === 'youtube' ? target.song.videoId : target.track.id;
}
export function targetTitle(target: TrackTarget): string {
return target.kind === 'youtube' ? target.song.title : target.track.title;
}
export function targetArtist(target: TrackTarget): string | undefined {
return target.kind === 'youtube' ? target.song.artist : target.track.artist;
}
export function targetExplicit(target: TrackTarget): boolean {
return target.kind === 'youtube' ? target.song.isExplicit : (target.track.isExplicit ?? false);
}
export function targetCoverSrc(target: TrackTarget): string | undefined {
return target.kind === 'youtube' ? target.song.thumbnailUrl : undefined;
}
export function queueIdForTarget(target: TrackTarget): string | number {
return target.kind === 'youtube' ? target.song.videoId : queueIdForTrack(target.track);
}
export function isTargetCurrent(target: TrackTarget): boolean {
return player.current.id === queueIdForTarget(target);
}
export function queueItemForTarget(target: TrackTarget): QueueItem {
return target.kind === 'youtube'
? {
id: target.song.videoId,
title: target.song.title,
artist: target.song.artist,
source: 'youtube',
coverUrl: target.song.thumbnailUrl,
explicit: target.song.isExplicit
}
: toQueueItems([target.track])[0];
}
export function targetForQueueItem(item: QueueItem): TrackTarget {
return item.source === 'youtube'
? {
kind: 'youtube',
song: {
videoId: String(item.id),
title: item.title,
artist: item.artist ?? '',
album: '',
durationSeconds: 0,
thumbnailUrl: item.coverUrl ?? '',
isExplicit: item.explicit ?? false
}
}
: {
kind: 'local',
track: {
id: item.id,
title: item.title,
artist: item.artist,
source: 'Local',
isExplicit: item.explicit
}
};
}
+16
View File
@@ -0,0 +1,16 @@
export interface SessionUser {
sub: string;
name: string;
email: string;
picture: string;
}
export interface YouTubeSong {
videoId: string;
title: string;
artist: string;
album: string;
durationSeconds: number;
thumbnailUrl: string;
isExplicit: boolean;
}