Add likes, follows and playlist visibility; redesign web-player UI

Backend:
- New Likes feature (toggle/get liked tracks) and endpoints
- User follow/unfollow, listening stats, public user profile
- Playlist visibility (public/private) support
- Various auth and DTO/response adjustments to support the above

Frontend (web-player):
- New design system pass: theme tokens/mode (light/dark), glass menu,
  tabs bar, player dock/extras/queue components, mix tiles, artist link
- New routes: Liked, public user profile (/u/[id]), likes API proxy
- Search refactor: shared SearchBox component used by TopBar and
  MobileHeader (search now reachable on mobile), live results as you
  type instead of requiring Enter, dropped the "Buscando…" quick-result
  preview
- Removed now-unused components (MobileNav, CoverBadge, MixBentoTile,
  PlayPauseOverlay, SuggestionsTable, userDirectory)
- Design handoff reference docs added under web-player/design_handoff_musify_player
  and web-player/docs/design-system-refactor-plan.md
This commit is contained in:
2026-09-14 23:42:45 +02:00
parent 389b3b9244
commit dc1ba89a6d
166 changed files with 14271 additions and 1982 deletions
@@ -2,44 +2,27 @@
import TrackInfo from './TrackInfo.svelte';
import TransportControls from './TransportControls.svelte';
import SeekBar from './SeekBar.svelte';
import VolumeControl from './VolumeControl.svelte';
import Alert from '$lib/components/ui/Alert.svelte';
import { player } from '$lib/player/player.svelte';
import PlayerExtras from './PlayerExtras.svelte';
</script>
{#if player.error}
<div
class="fixed inset-x-0 z-40 page-x py-2 md:left-[var(--mf-sidebar-w)] md:z-50"
style="bottom:calc(var(--mf-nav-h) + var(--mf-safe-b) + var(--mf-player-h))"
>
<Alert tone="danger">{player.error}</Alert>
<div
class="animate-enter pointer-events-auto flex items-center gap-4.5 rounded-[18px] border border-hairline bg-[image:var(--mf-bar-bg)] px-4 py-2.75 shadow-player backdrop-blur-[22px] transition-[background] duration-[600ms] sm:hidden"
>
<div class="min-w-0 flex-1">
<TrackInfo compact />
</div>
{/if}
<TransportControls compact />
</div>
<div
class="animate-enter fixed right-0 left-0 z-40 h-[var(--mf-player-h)] border-t border-line bg-bg md:left-[var(--mf-sidebar-w)] md:z-50"
style="bottom:calc(var(--mf-nav-h) + var(--mf-safe-b))"
class="animate-enter pointer-events-auto hidden items-center gap-5 rounded-[18px] border border-hairline bg-[image:var(--mf-bar-bg)] px-4 py-2.75 shadow-player backdrop-blur-[22px] transition-[background] duration-[600ms] sm:flex"
>
<div class="absolute inset-x-0 top-0 md:hidden">
<SeekBar compact />
</div>
<div class="flex h-full items-center gap-3 page-x md:hidden">
<div class="min-w-0 flex-1">
<TrackInfo />
</div>
<TransportControls compact />
</div>
<div
class="hidden h-full items-center gap-5 px-8 md:grid"
style="grid-template-columns:minmax(200px,1fr) minmax(0,3fr) minmax(200px,1fr)"
>
<div class="w-64 min-w-0 shrink-0">
<TrackInfo />
<div class="flex flex-col items-center gap-2">
<TransportControls />
<SeekBar />
</div>
<VolumeControl />
</div>
<div class="flex min-w-0 flex-1 flex-col items-center gap-2">
<TransportControls />
<SeekBar />
</div>
<PlayerExtras />
</div>
@@ -0,0 +1,29 @@
<script lang="ts">
import PlayerBar from './PlayerBar.svelte';
import TabsBar from '../TabsBar.svelte';
import Alert from '$lib/components/ui/Alert.svelte';
import { player } from '$lib/player/player.svelte';
import type { SessionUser } from '$lib/types';
interface Props {
user: SessionUser | null;
}
let { user }: Props = $props();
const hasTrack = $derived(player.currentId !== null);
</script>
<div
class="pointer-events-none fixed inset-x-0 bottom-0 z-40 flex flex-col gap-2.5 px-4 pb-4 lg:left-[var(--mf-sidebar-w)]"
>
{#if player.error}
<div class="pointer-events-auto">
<Alert tone="danger">{player.error}</Alert>
</div>
{/if}
{#if hasTrack}
<PlayerBar />
{/if}
<TabsBar {user} />
</div>
@@ -0,0 +1,22 @@
<script lang="ts">
import VolumeControl from './VolumeControl.svelte';
import { queuePanel } from '$lib/player/queuePanel.svelte';
import AlignJustify from '@lucide/svelte/icons/align-justify';
import ListMusic from '@lucide/svelte/icons/list-music';
</script>
<div class="hidden w-64 shrink-0 items-center justify-end gap-3.5 sm:flex">
<span aria-hidden="true" class="text-fg-3">
<AlignJustify class="h-[15px] w-[15px]" strokeWidth={1.8} />
</span>
<VolumeControl />
<button
type="button"
onclick={() => queuePanel.toggle()}
aria-pressed={queuePanel.open}
aria-label="Cola de reproducción"
class="transition-colors hover:text-fg {queuePanel.open ? 'text-fg' : 'text-fg-2'}"
>
<ListMusic class="h-4 w-4" strokeWidth={1.8} />
</button>
</div>
@@ -0,0 +1,117 @@
<script lang="ts">
import { player } from '$lib/player/player.svelte';
import { queuePanel } from '$lib/player/queuePanel.svelte';
import Cover from '$lib/components/ui/Cover.svelte';
import ArtistLink from '$lib/components/ui/ArtistLink.svelte';
import { fmtTime } from '$lib/format';
import X from '@lucide/svelte/icons/x';
import { fly } from 'svelte/transition';
import { cubicOut } from 'svelte/easing';
const upcoming = $derived.by(() => {
const idx = player.tracks.findIndex((t) => t.id === player.currentId);
return idx === -1 ? player.tracks : player.tracks.slice(idx + 1);
});
</script>
{#if queuePanel.open}
<aside
transition:fly={{ x: 24, duration: 220, easing: cubicOut }}
class="hidden shrink-0 flex-col border-l border-hairline bg-[image:var(--mf-panel-bg)] px-4 pt-5 transition-[background] duration-[600ms] sm:flex"
style="width:var(--mf-queue-w); padding-bottom:calc(var(--mf-player-h) + 2rem)"
>
<div class="mb-5 flex items-center justify-between">
<h2 class="font-display text-[14.5px] font-semibold tracking-[-0.02em] text-fg">En cola</h2>
<button
type="button"
onclick={() => queuePanel.close()}
aria-label="Cerrar cola"
class="text-base text-fg-3 transition-colors hover:text-fg"
>
<X class="h-4 w-4" />
</button>
</div>
{#if player.current.id}
<p class="mb-2.75 text-[10.5px] font-semibold tracking-[0.12em] text-muted uppercase">
Reproduciendo
</p>
<div class="mb-6 flex items-center gap-3 rounded-xl bg-accent-tint p-2.5">
<Cover
trackId={player.current.id}
src={player.current.coverUrl}
size="small"
alt={player.current.title}
class="h-10 w-10 shrink-0 rounded-[9px]"
/>
<div class="min-w-0">
<div class="truncate text-[12.5px] font-semibold text-accent-soft">
{player.current.title}
</div>
<ArtistLink
name={player.current.artist || '—'}
ownerUserId={player.current.ownerUserId}
class="mt-0.75 text-[11px] text-fg-3"
/>
</div>
</div>
{/if}
<div class="mb-2.75 flex items-center justify-between gap-3">
<span
class="min-w-0 flex-1 truncate text-[10.5px] font-semibold tracking-[0.12em] text-muted uppercase"
>
A continuación
</span>
{#if upcoming.length > 0}
<button
type="button"
onclick={() => player.clearUpcoming()}
class="shrink-0 text-[11px] text-muted transition-colors hover:text-fg"
>
Vaciar
</button>
{/if}
</div>
<div class="flex flex-1 flex-col gap-px overflow-y-auto">
{#each upcoming as track, i (track.id)}
{@const index = player.tracks.length - upcoming.length + i}
<div
role="button"
tabindex="0"
onclick={() => player.playQueueIndex(index)}
onkeydown={(e) => {
if (e.key !== 'Enter' && e.key !== ' ') return;
e.preventDefault();
player.playQueueIndex(index);
}}
class="flex items-center gap-3 rounded-[11px] p-2.25 text-left hover:bg-hover"
>
<Cover
trackId={track.id}
src={track.coverUrl}
size="small"
alt={track.title}
class="h-9 w-9 shrink-0 rounded-lg opacity-90"
/>
<div class="min-w-0 flex-1">
<div class="truncate text-[12.5px] font-medium text-fg">{track.title}</div>
<ArtistLink
name={track.artist || '—'}
ownerUserId={track.ownerUserId}
class="mt-0.5 text-[11px] text-fg-3"
/>
</div>
{#if track.duration}
<span class="shrink-0 text-[11px] text-muted tabular-nums"
>{fmtTime(track.duration)}</span
>
{/if}
</div>
{:else}
<p class="px-2 py-6 text-center text-xs text-fg-3">No hay más canciones en la cola.</p>
{/each}
</div>
</aside>
{/if}
@@ -27,26 +27,21 @@
></div>
</button>
{:else}
<div class="flex w-full max-w-172 items-center gap-3">
<span class="text-right text-xs text-muted tabular-nums">
<div class="flex w-full max-w-135 items-center gap-2.75">
<span class="min-w-8 text-right text-[10.5px] text-fg-3 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"
class="group relative h-3.5 flex-1 cursor-pointer"
>
<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-on-art 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>
<div class="h-[3.5px] w-full overflow-hidden rounded-full bg-track">
<div class="h-full rounded-full bg-accent" style="width:{player.progressPercent}%"></div>
</div>
</button>
<span class="text-xs text-muted tabular-nums">
<span class="min-w-8 text-[10.5px] text-fg-3 tabular-nums">
{fmtTime(player.current.duration)}
</span>
</div>
@@ -1,30 +1,71 @@
<script lang="ts">
import { player } from '$lib/player/player.svelte';
import { liked } from '$lib/player/liked.svelte';
import Cover from '$lib/components/ui/Cover.svelte';
import ExplicitBadge from '$lib/components/ui/ExplicitBadge.svelte';
import ArtistLink from '$lib/components/ui/ArtistLink.svelte';
import Heart from '@lucide/svelte/icons/heart';
interface Props {
compact?: boolean;
}
let { compact = false }: Props = $props();
const isLiked = $derived(player.current.id !== '' && liked.isLiked(player.current.id));
function toggleLike() {
if (!player.current.id) return;
liked.toggle({
id: player.current.id,
title: player.current.title,
artist: player.current.artist,
coverUrl: player.current.coverUrl,
source: player.current.source,
explicit: player.current.explicit,
ownerUserId: player.current.ownerUserId
});
}
</script>
<div class="flex min-w-0 items-center gap-2.5 md:gap-3">
<div class="flex min-w-0 items-center gap-2.5 md:gap-3.25">
{#if player.current.id}
<Cover
trackId={player.current.id}
src={player.current.coverUrl}
size="small"
alt={player.current.title}
class="h-11 w-11 shrink-0 rounded-lg shadow-[0_6px_16px_-6px_rgba(0,0,0,0.6)] md:h-13 md:w-13"
class="h-11 w-11 shrink-0 rounded-[11px] shadow-[0_8px_20px_rgba(0,0,0,0.5)] md:h-12 md:w-12"
/>
{:else}
<div
class="h-11 w-11 shrink-0 rounded-lg bg-surface shadow-[0_6px_16px_-6px_rgba(0,0,0,0.6)] md:h-13 md:w-13"
class="h-11 w-11 shrink-0 rounded-[11px] bg-surface shadow-[0_8px_20px_rgba(0,0,0,0.5)] md:h-12 md:w-12"
></div>
{/if}
<div class="min-w-0">
<div class="min-w-0 flex-1">
<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>
<span class="truncate text-[13px] font-semibold tracking-[-0.01em] text-fg"
>{player.current.title}</span
>
</div>
<div class="truncate text-sm text-muted">{player.current.artist || '—'}</div>
<ArtistLink
name={player.current.artist || '—'}
ownerUserId={player.current.ownerUserId}
class="mt-0.5 text-[11.5px] text-fg-3"
/>
</div>
{#if !compact}
<button
type="button"
onclick={toggleLike}
aria-label={isLiked ? 'Quitar de Me gusta' : 'Añadir a Me gusta'}
aria-pressed={isLiked}
class="shrink-0 transition hover:opacity-80 {isLiked ? 'text-accent' : 'text-fg-3'}"
>
<Heart class="h-4 w-4" fill={isLiked ? 'currentColor' : 'none'} strokeWidth={1.7} />
</button>
{/if}
</div>
@@ -1,5 +1,7 @@
<script lang="ts">
import { player } from '$lib/player/player.svelte';
import Shuffle from '@lucide/svelte/icons/shuffle';
import Repeat from '@lucide/svelte/icons/repeat';
interface Props {
compact?: boolean;
@@ -10,11 +12,23 @@
const sideClass = $derived(
compact
? 'grid h-10 w-10 place-items-center text-fg opacity-70 transition active:scale-90'
: 'flex text-fg opacity-70 transition hover:opacity-100 active:scale-90'
: 'flex text-fg-2 transition-colors hover:text-fg active:scale-90'
);
</script>
<div class="flex shrink-0 items-center {compact ? 'gap-0.5' : 'gap-5'}">
<div class="flex shrink-0 items-center {compact ? 'gap-0.5' : 'gap-3.5'}">
{#if !compact}
<button
type="button"
onclick={() => player.toggleShuffle()}
aria-pressed={player.shuffle}
aria-label="Aleatorio"
class="transition-colors hover:text-fg {player.shuffle ? 'text-accent' : 'text-fg-3'}"
>
<Shuffle class="h-[15px] w-[15px]" strokeWidth={1.8} />
</button>
{/if}
<button type="button" onclick={() => player.previous()} aria-label="Anterior" class={sideClass}>
<svg width="16" height="14" viewBox="0 0 16 14" fill="currentColor">
<rect x="0" y="0" width="2.5" height="14" rx="1" />
@@ -26,16 +40,16 @@
type="button"
onclick={() => player.toggle()}
aria-label={player.playing ? 'Pausar' : 'Reproducir'}
class="grid h-[36px] w-[36px] shrink-0 place-items-center rounded-full bg-fg text-bg shadow-lg transition duration-100 active:scale-95"
class="grid h-10 w-10 shrink-0 place-items-center rounded-full bg-cta-strong text-ink transition duration-150 hover:brightness-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 width="15" height="15" viewBox="0 0 15 15" fill="currentColor">
<rect x="3" y="1" width="3" height="13" rx="1" />
<rect x="9" y="1" width="3" height="13" 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 width="15" height="15" viewBox="0 0 15 15" fill="currentColor">
<path d="M3 1.5v12l10-6z" />
</svg>
{/if}
</button>
@@ -46,4 +60,16 @@
<rect x="13.5" y="0" width="2.5" height="14" rx="1" />
</svg>
</button>
{#if !compact}
<button
type="button"
onclick={() => player.toggleRepeat()}
aria-pressed={player.repeat}
aria-label="Repetir"
class="transition-colors hover:text-fg {player.repeat ? 'text-accent' : 'text-fg-3'}"
>
<Repeat class="h-[15px] w-[15px]" strokeWidth={1.8} />
</button>
{/if}
</div>
@@ -1,14 +1,15 @@
<script lang="ts">
import { player } from '$lib/player/player.svelte';
import Volume2 from '@lucide/svelte/icons/volume-2';
function handleVolumeChange(event: Event) {
const target = event.currentTarget as HTMLInputElement;
const newVolume = Number(target.value);
player.setVolume(newVolume);
player.setVolume(Number(target.value));
}
</script>
<div class="flex items-center justify-end gap-3.5">
<div class="flex items-center gap-2.25">
<Volume2 class="h-[15px] w-[15px] shrink-0 text-fg-3" strokeWidth={1.8} />
<input
type="range"
min="0"
@@ -17,7 +18,7 @@
oninput={handleVolumeChange}
aria-label="Volumen"
class="mf-volume"
style="background:linear-gradient(to right, var(--color-accent-soft) {player.volume}%, var(--color-track) {player.volume}%)"
style="background:linear-gradient(to right, var(--mf-text-2) {player.volume}%, var(--mf-track) {player.volume}%)"
/>
</div>
@@ -25,8 +26,8 @@
.mf-volume {
-webkit-appearance: none;
appearance: none;
width: 80px;
height: 4px;
width: 84px;
height: 3.5px;
border-radius: 100px;
outline: none;
cursor: pointer;
@@ -34,17 +35,17 @@
.mf-volume::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 12px;
height: 12px;
width: 11px;
height: 11px;
border-radius: 50%;
background: var(--color-fg);
background: var(--mf-text);
cursor: pointer;
}
.mf-volume::-moz-range-thumb {
width: 12px;
height: 12px;
width: 11px;
height: 11px;
border-radius: 50%;
background: var(--color-fg);
background: var(--mf-text);
border: none;
cursor: pointer;
}