31 Commits
Author SHA1 Message Date
Admin 78e3de9958 Simplify design 2026-09-23 01:33:25 +02:00
Admin ffaa2fe210 Changes on the colors 2026-09-22 22:23:06 +02:00
AdminandClaude Sonnet 5 6fb4da3edb Restore dark-mode accentL to its original 0.74
0.65 was still too dark per feedback. The original concern that made
us drop accentL from 0.74 (accent surfaces stacking too bright over
the darkened spotlight/hero cards) is already fixed architecturally by
the earlier color-mix() conversion, which makes bg-accent-tint/
bg-accent-btn a solid precomputed value that no longer stacks with
whatever's underneath. So there's no reason to keep accentL dimmed for
that reason anymore.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 20:40:23 +02:00
AdminandClaude Sonnet 5 0e2eec9e90 Brighten dark-mode accentL back up slightly
0.55 (dropped from the original 0.74 to fix accent surfaces looking
too bright when stacked on the darkened spotlight/hero cards) made
bg-accent-tint on the plain page background barely visible. Split the
difference at 0.65, closer to how it looked originally.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 20:38:03 +02:00
AdminandClaude Sonnet 5 9f5106aecf Make accent backgrounds solid so they don't stack on tinted surfaces
--mf-accent-soft-bg/--mf-accent-btn-bg/-hover were translucent alpha
overlays, so an accent-tinted element (e.g. the active-row highlight,
or the accent Button) rendered differently depending on what was
already underneath it: fine over the plain page background, but over
an already-tinted surface like the spotlight card, the two translucent
layers stacked and the combined result read brighter/more saturated
than intended.

Same fix as bg-btn earlier: replace the alpha overlay with a solid
color-mix() against --mf-bg, computed once, so the token always
renders identically regardless of what it's placed on top of. Verified
the active-row highlight and the accent button now compute to the
exact same color whether they sit on the plain background or on the
spotlight card.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 20:33:25 +02:00
AdminandClaude Sonnet 5 29ab2e49d9 Darken hero/spotlight cards further and adapt accent buttons to match
Per feedback, --mf-hero-bg/--mf-spotlight-bg in dark mode were still
too bright: drop their tc() base lightness from 42 to 38 and alpha
from 0.2 to 0.1, so the cards read as a subtle wash close to the page
background instead of a distinct bright block.

That made bg-accent-tint (Button's accent variant, ListRow/TrackList's
active-row highlight) look comparatively too light against the now
much darker cards, since accentL was still 0.74. Drop dark's accentL
to 0.55 so every accent-derived surface (rings, accent-tint, accent-btn
hover) darkens to match. Text (titleL, still 0.87) is untouched since
it needs to stay legible regardless of how dark the surface under it
gets.

Cleaned up the now-unused ACCENT_LIGHTNESS import/L constant.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 20:27:17 +02:00
AdminandClaude Sonnet 5 d4c1596e99 Shrink menu item text and drop the border on the account dropdown
MenuItem used text-sm (14px) for its label, which read as oversized
for a dropdown; drop to text-xs (12px). This is a shared component
(AccountMenu, ContextMenu, MobileHeader's menu), so all of them get
the more compact sizing.

Add a border prop to GlassMenu (default true, unchanged for
ContextMenu) and set it false specifically on AccountMenu's dropdown,
per feedback that its border didn't fit.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 20:19:58 +02:00
AdminandClaude Sonnet 5 8ad358a0a2 Soften dark-mode accent colors (buttons and text)
Dark mode's accent chroma came from the ACCENT_CHROMA constant (0.145,
originally meant as a generic default), used unconditionally for every
accent-derived token regardless of the actual song's extracted vividness
- extractAccent() already clamps a song's real chroma to 0.045-0.11, but
buildThemeTokens() never used it, only the hue. That made every accent
button, ring and highlight uniformly vivid. Drop it to 0.09.

Also soften --mf-accent-title (the accent-colored track title text,
e.g. active rows) from 0.07 to 0.05 chroma, since that was called out
specifically as too strong.

Cleaned up the now-unused C/ACCENT_CHROMA import.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 20:14:46 +02:00
AdminandClaude Sonnet 5 e069c16ea2 Dim the dark-mode primary button further
#e6e6e8 still read as too white per feedback. Use #c4c4c8, a clearly
gray tone instead of an off-white, while text-ink still contrasts
comfortably against it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 20:05:03 +02:00
AdminandClaude Sonnet 5 062a643241 Dim the dark-mode primary button from pure white to a soft off-white
--mf-cta-strong-bg was #ffffff in dark mode, reading as too bright/
glaring against the dark surfaces around it. Use #e6e6e8 instead, a
still-white but slightly dimmed tone. Light mode's own value (#17171a,
already dark) is untouched.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 20:02:33 +02:00
AdminandClaude Sonnet 5 3d7dbf58ac Brighten secondary button text for dark-mode legibility
secondary used text-fg-2 (--mf-text-2), which in dark mode is a fairly
dim mid-gray (#8a8a8e) that read poorly against the dark bg-btn fill,
even though the same class looks great in light mode where --mf-text-2
is a dark, high-contrast gray. Use text-fg instead, which is tuned
bright in dark mode and dark in light mode, so both modes get good
contrast from the same class. Drop the now-redundant hover:text-fg
since resting state already uses it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 19:58:39 +02:00
AdminandClaude Sonnet 5 9e2e353054 Make dark-mode hero/spotlight cards a solid color, not a fading gradient
--mf-hero-bg/--mf-spotlight-bg were multi-stop, directional gradients
in dark mode (fading from a vivid corner to transparent), unlike light
mode which uses the same color at both gradient stops so it reads as
flat. Match that: both now use a single tc() tone repeated at both
stops, so the hero and spotlight cards render as one solid tint in
dark mode too.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 19:53:21 +02:00
AdminandClaude Sonnet 5 5a77e05f3d Make the dark-mode ambient glow more subtle and spread further
The two radial glows behind app-backdrop were fairly small (52%/38%
and 46%/34% ellipses) and faded out by half their radius, so they only
covered the top portion of the viewport at a fairly strong 0.26/0.2
alpha. Enlarge both ellipses and push the fade-out point out to 88%,
so the tint reaches much further across the screen like light mode's
flat wash does, while lowering the alpha so it reads as subtle instead
of a bright patch in the corners.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 19:50:23 +02:00
AdminandClaude Sonnet 5 5bdf7cfb06 Let sidebar and queue show the same ambient backdrop as the center
Sidebar and Queue painted their own opaque bg-bg, which sat in front of
the fixed, full-viewport app-backdrop div (z-index: -10) that carries
--mf-ambient's radial-gradient glow in dark mode. Since the main content
area has no background of its own, the glow only ever showed through
there — sidebar and queue blocked it with a flat, gradient-less fill of
the same base color.

Drop bg-bg from both so the same app-backdrop (color + ambient gradient)
shows through underneath them too. In light mode this is a no-op
(--mf-ambient is 'none' and app-backdrop's flat color already matches
what bg-bg painted), so only dark mode is visually affected.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 19:46:25 +02:00
AdminandClaude Sonnet 5 32d0a6811c Revert chrome-bg: sidebar/queue should just be the tinted background
Per feedback, the solid color-mix bar was wrong — sidebar, queue and
the mobile header should stay on the plain --mf-bg (the same subtly
hue-shifting black used everywhere else), exactly like light mode
already does, not a separately-boosted colored panel. Drop --mf-chrome-bg/
--color-chrome and switch those three back to bg-bg.

The general dark-mode richness bump (tc(), --mf-bg/--mf-elevated/etc.)
and the glass-bar background-color fix from the previous commit stay;
only the separate chrome treatment is reverted.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 19:02:10 +02:00
AdminandClaude Sonnet 5 3d9e83c905 Make dark mode more colorful and give chrome bars a full tint
Mirror the earlier light-mode pastel/colorful pass for dark mode:
boost tc()'s lightness and chroma, and richen --mf-bg/--mf-elevated/
--mf-panel-bg/--mf-bar-bg/--mf-hairline the same way, so dark mode
reads as colorful instead of near-grayscale.

Sidebar, Queue, and MobileHeader used bg-bg directly, so in dark mode
they stayed almost black regardless of hue. Add --mf-chrome-bg (an
alias to --mf-bg in light mode, a solid color-mix of --mf-hero-tint
into --mf-bg in dark mode) and switch those three to bg-chrome, so
they read as a full, solid colored bar in dark mode instead of a
fading gradient (per feedback: a whole bar, not a gradient).

Also fixes glass-bar (PlayerBar/TabsBar): it set
`background-image: var(--mf-bar-bg)`, but --mf-bar-bg is a plain
color, not a valid <image>, so the declaration was silently dropped
and these bars never actually showed their tint in either mode. Changed
to background-color.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 18:57:16 +02:00
AdminandClaude Sonnet 5 7536f239b2 Carry track duration through the queue pipeline
ApiTrackLike/QueueItem had no duration field, so toQueueItem(s) always
dropped it and toTrack() hardcoded a new track's duration to 0, relying
entirely on the <audio> element's durationchange/loadedmetadata events
to fill it in once that track actually became current. Upcoming queue
rows never reach that point, so "A continuación" always showed a blank
duration even though the backend already returns it.

Add duration to both types, thread it through toQueueItems/toQueueItem/
trackFromQueueItem, and seed toTrack() from it. The real-time audio
sync still overwrites it once a track actually plays, so this only
fills the gap for tracks that haven't played yet.

Also map MixItemApplicationResponse's durationSeconds through
mixItemTrack, since mixes use a differently-named field.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 18:46:17 +02:00
AdminandClaude Sonnet 5 ecdddaa15e Match the accent button's background to the active-row highlight
Button's accent variant used bg-accent-btn (15% alpha) while the
active-track highlight in ListRow/TrackList uses bg-accent-tint (8%
alpha) with the same text-accent-soft title color, so a button like
"Reproducir" sat at a visibly different tint than the row it plays.
Use bg-accent-tint for accent's resting background too, so both read
as the same accent color; hover still steps up to bg-accent-btn-hover.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 18:41:03 +02:00
AdminandClaude Sonnet 5 8706b8d775 Stop Button's generic transition from lagging its hue-driven background
Button used Tailwind's bare `transition` utility, whose default
property list includes background-color. Since the button's bg is a
CSS custom property recomputed every frame by the same 600ms hue
animation the main background uses, that extra transition smoothed it
on a different (~150ms) timing, so its color change didn't match the
page background's.

Add a btn-transition utility that mirrors Tailwind's default
transition list minus background-color, so the button's background
tracks the hue animation frame-for-frame like the rest of the app,
while hover/focus feedback (text color, ring, filter) still transitions
normally.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 18:08:58 +02:00
AdminandClaude Sonnet 5 f6c82669d9 Make the hero/spotlight surface tint more transparent
--mf-hero-bg/--mf-spotlight-bg's overlay tint was at 16% alpha, which
after the pastel lightness/chroma boost read as a fairly solid block
of color rather than a soft wash. Halve it to 8% so more of the plain
page background shows through underneath.

Keep the secondary button's color-mix ratio (in layout.css) in sync at
8% too, since it's built to replicate exactly what the surface behind
it looks like.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 18:06:11 +02:00
AdminandClaude Sonnet 5 9d68879b01 Sync sidebar/queue/player bar color transitions with the main background
Sidebar, Queue, PlayerBar, TabsBar, and the hero/spotlight surfaces all
had an extra CSS `transition: background 0.5s ease-out` (theme-transition/
glass-bar) layered on top of backgrounds that are already animated
frame-by-frame over 600ms by the hue-interpolation loop in +layout.svelte.
That extra transition made them visibly lag behind and take a different
amount of time than the main app-backdrop, which has no such transition
and therefore tracks the JS animation exactly. Drop the redundant
transition so every hue-driven surface updates in lockstep.

Also fixes a dangling --color-btn-hover: var(--mf-btn-bg-hover) that
referenced an undefined custom property (theme.css never defined
--mf-btn-bg-hover after an earlier partial commit), which silently
broke the secondary button's hover background.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 18:01:25 +02:00
AdminandClaude Sonnet 5 832dc66751 Make the dynamic light-mode hue palette more pastel and colorful
The hue-driven light-mode tokens (page bg, hero/modal/spotlight
gradients, hairlines, logo/cover art gradients) leaned nearly
grayscale, with just a whisper of tint. Push tcLight()'s lightness
and chroma up together so every derived surface stays light and soft
but reads as an actual color instead of a faint gray wash, and bump
chroma on --mf-bg/--mf-elevated/--mf-panel-bg/--mf-bar-bg/--mf-hairline
and the logo/cover gradient stops the same way.

Scoped to light mode only: dark mode's accents are meant to read as
bright highlights against near-black surfaces, where a pastel wash
would just look washed out rather than colorful.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 17:55:39 +02:00
AdminandClaude Sonnet 5 cd913a6d17 Replace secondary button's backdrop-blur with an equivalent fixed color
backdrop-blur samples whatever is actually behind the element, so two
secondary buttons with identical classes could render differently
depending on nearby content (e.g. dark album art bleeding into the
blur near the spotlight card). Replace it with a fixed color-mix that
reproduces the same result mathematically instead of sampling pixels:
45% --mf-elevated over the hero/spotlight surface's own tint color
(16% --mf-hero-tint over --mf-bg, the same ratio hero-surface itself
uses), computed via CSS color-mix() rather than a backdrop-filter.

Verified against the previous blur version: both now compute to the
identical color(srgb 0.9329 0.9611 0.9332), matching what the blurred
button rendered against the same hero background, but fixed so every
secondary button is now guaranteed pixel-identical regardless of what
happens to be nearby.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 17:50:59 +02:00
AdminandClaude Sonnet 5 626283ba50 Drop backdrop-blur on secondary for a consistent hue-tied tint
Sampling whatever's behind the button via backdrop-filter meant two
secondary buttons could render differently depending on what happened
to be nearby (e.g. dark album art bleeding into the blur), so
"Explorar musica" and "Aleatorio" ended up visibly different colors
despite being the same variant. Replace it with a fixed, hue-tied
translucent tint (bg-accent-tint, the same token family accent already
uses) so every secondary button computes to the identical color, ties
into the same dynamic accent hue as the rest of the app, and reads as
a softer sibling of the accent button instead of an unrelated gray.

Hover now steps up to bg-accent-btn (same hue family, stronger) instead
of a flat, hue-agnostic gray. Removed the now-unused --mf-btn-bg-hover/
--color-btn-hover tokens.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 17:34:34 +02:00
AdminandClaude Sonnet 5 df70290631 Make secondary button translucent so it tracks whatever surface it's on
bg-elevated is fully opaque and only hue-matches --mf-bg (the plain
page background), so it stayed stark white on the hero/spotlight cards,
which use a separately-computed, more saturated gradient (--mf-hero-bg/
--mf-spotlight-bg). An opaque fill can never pick up that color no
matter what hue it's tied to.

Make it translucent (bg-elevated/45) with backdrop-blur-lg so it
samples and lightly tints whatever is actually behind it, hero card,
spotlight card, or plain background, instead of only matching one of
them.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 17:31:22 +02:00
AdminandClaude Sonnet 5 28107ac09e Tie secondary button color to the page background instead of a flat tint
secondary was fully bg-transparent (invisible at rest) or a flat neutral
overlay (clashed next to accent-tinted buttons and looked different
depending on what surface it sat on). Use bg-elevated instead, which is
already derived from the same hue as --mf-bg, so the button is always a
faint, minimal step above whatever page background it's on, and shifts
with it automatically.

Drop the now-unused --color-btn/--mf-btn-bg tokens.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 17:23:57 +02:00
AdminandClaude Sonnet 5 39f04f5d57 Make the secondary button adapt to shifting backgrounds
secondary used a flat, static tint that only worked over a plain
surface, so it looked out of place on the hero's hue-shifting gradient.
Add a backdrop blur (no border, per feedback) so it always reads
against whatever's actually behind it. Gave the button its own
background tokens instead of aliasing --mf-surface-2, and dropped the
now-unused --color-btn-glass tokens left over from the removed glass
variant.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 17:15:53 +02:00
Admin b5944d0404 Changes on the design 2026-09-22 17:10:26 +02:00
Admin 57bacabcc4 Revert "Make the glass button variant more transparent and soften accent text"
This reverts commit f52efa0f87.
2026-09-22 16:45:33 +02:00
AdminandClaude Sonnet 5 f52efa0f87 Make the glass button variant more transparent and soften accent text
The secondary/glass Button variant used a flat, fairly opaque black or
white tint with no blur, so it didn't read as glass and clashed with
the hue-shifting hero background. Add a backdrop blur and a hairline
border (matching the app's existing glass-bar/glass-panel treatment)
and lower the tint opacity.

Wire up the already-computed but unused --mf-accent-muted token as
--color-accent-muted, and use it for the accent Button variant's text
instead of the higher-chroma --color-accent-soft, so it reads softer.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 16:42:56 +02:00
AdminandClaude Sonnet 5 ab51d9256e Fix permanent login failure after Zitadel restarts
getOidcConfig() memoized the discovery promise even when it rejected,
so a single failed OIDC discovery (e.g. Zitadel not ready yet when the
dev server starts) permanently broke /auth/login until the process was
restarted. Clear the cached promise on rejection so it retries.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 16:29:58 +02:00
55 changed files with 394 additions and 254 deletions
+2
View File
@@ -9,3 +9,5 @@ package
package-lock.json package-lock.json
src/lib/api/schema.d.ts src/lib/api/schema.d.ts
static static
.agents
skills-lock.json
+1
View File
@@ -3,6 +3,7 @@
"singleQuote": true, "singleQuote": true,
"trailingComma": "none", "trailingComma": "none",
"printWidth": 100, "printWidth": 100,
"endOfLine": "auto",
"plugins": ["prettier-plugin-svelte", "prettier-plugin-tailwindcss"], "plugins": ["prettier-plugin-svelte", "prettier-plugin-tailwindcss"],
"tailwindStylesheet": "./src/routes/layout.css", "tailwindStylesheet": "./src/routes/layout.css",
"overrides": [ "overrides": [
+8
View File
@@ -57,6 +57,14 @@ The design system lives in `src/routes/layout.css` (`@theme` tokens plus `@utili
- Reach for these base components before creating a new one: `Artwork`, `Avatar`, `ListRow`, `MediaIdentity`, `TrackList`, `MediaCard`, `PageHeader`, `ContextMenu`, `ConfirmDialog`, `PlayButton`, `Slider`, `Logo`, `Chip`, `SegmentedControl`, `CoverForm`. - Reach for these base components before creating a new one: `Artwork`, `Avatar`, `ListRow`, `MediaIdentity`, `TrackList`, `MediaCard`, `PageHeader`, `ContextMenu`, `ConfirmDialog`, `PlayButton`, `Slider`, `Logo`, `Chip`, `SegmentedControl`, `CoverForm`.
- Known and accepted gap (documented in `scripts/check-tokens.mjs`): icon `strokeWidth={n}` isn't unified yet. There are 13 different values spread across almost every component, and migrating them to `stroke-thin/regular/bold` recipes safely would require visually confirming that `lucide-svelte` actually respects `stroke-width` through CSS, so that was left out of the last pass. - Known and accepted gap (documented in `scripts/check-tokens.mjs`): icon `strokeWidth={n}` isn't unified yet. There are 13 different values spread across almost every component, and migrating them to `stroke-thin/regular/bold` recipes safely would require visually confirming that `lucide-svelte` actually respects `stroke-width` through CSS, so that was left out of the last pass.
## Motion
Every animation follows one scale of three durations and one easing curve, all defined in `theme.css`. Fast (`--mf-motion-fast`, 150ms) is for hover, focus and press feedback: color, opacity and small scale changes. Plain `transition` or `transition-colors` already uses it because it is the Tailwind default, so never write `duration-150`. Base (`--mf-motion-base`, 200ms) is for things that appear on top of the page or move inside a control: modals, menus, the queue panel, `animate-pop`, `animate-fade` and the segmented control thumb. Slow (`--mf-motion-slow`, 300ms) is for content entering the page and larger movement: `animate-enter`, cover zoom on hover and the player dock padding.
Entrances and movement always use `--mf-ease` (`ease-snappy` in Tailwind, `expoOut` in Svelte transitions), which feels immediate because most of the motion happens at the start. Color transitions keep the Tailwind default curve. Overlays must feel instant: the modal backdrop uses `transition:fade={{ duration: 200 }}` and the panel uses a local `pop` transition (opacity + scale + translateY, `expoOut`, 200ms) mirroring `animate-pop`'s keyframes — both directives so the modal fades and pops on close too, not just on open, and the whole thing settles in 200ms. Staggered lists use `--i` with 40ms steps capped at ten items.
The only exceptions are looping indicators (equalizer bars, pulse, spin, sheen) and the accent hue crossfade in `+layout.svelte` (600ms), which is ambient and meant to be slow. Do not add new durations or curves; if something needs a different feel, pick the closest step of the scale. `prefers-reduced-motion` disables everything globally in `layout.css`.
## Auth ## Auth
`hooks.server.ts` validates the encrypted session cookie (`mf_session`), refreshes the `access_token`, and fills `locals.user` and `locals.accessToken`. To protect a route, check `locals.user` in its `load` and redirect to `/auth?returnTo=…`. When talking to the backend, send `locals.accessToken` as a Bearer token to `API_BASE_URL`. `hooks.server.ts` validates the encrypted session cookie (`mf_session`), refreshes the `access_token`, and fills `locals.user` and `locals.accessToken`. To protect a route, check `locals.user` in its `load` and redirect to `/auth?returnTo=…`. When talking to the backend, send `locals.accessToken` as a Bearer token to `API_BASE_URL`.
+5 -5
View File
@@ -119,10 +119,10 @@ A fixed bottom bar, visible while something is playing. Shows track info, transp
## Available backend (summary) ## Available backend (summary)
| Resource | Endpoints | | Resource | Endpoints |
| --------- | --------------------------------------------------------------------- | | --------- | ----------------------------------------------------------------- |
| Tracks | list/search, detail, by user, stream, create, delete, upload-urls | | Tracks | list/search, detail, by user, stream, create, delete, upload-urls |
| Playlists | CRUD, playlist tracks, add/remove track, upload-picture | | Playlists | CRUD, playlist tracks, add/remove track, upload-picture |
| Users | list/search, detail, create | | Users | list/search, detail, create |
Typed client in `$lib/server/api.ts` (`openapi-fetch`, ErrorOr style). Use it inside `load`/server actions, never from the client. Types live in `src/lib/api/schema.d.ts`. Typed client in `$lib/server/api.ts` (`openapi-fetch`, ErrorOr style). Use it inside `load`/server actions, never from the client. Types live in `src/lib/api/schema.d.ts`.
@@ -6,7 +6,6 @@
import type { SessionUser } from '$lib/types'; import type { SessionUser } from '$lib/types';
import ListRow from '../ui/media/ListRow.svelte'; import ListRow from '../ui/media/ListRow.svelte';
import IconButton from '../ui/primitives/IconButton.svelte'; import IconButton from '../ui/primitives/IconButton.svelte';
import Logo from '../ui/primitives/Logo.svelte';
import Plus from '@lucide/svelte/icons/plus'; import Plus from '@lucide/svelte/icons/plus';
interface SidebarPlaylist { interface SidebarPlaylist {
@@ -31,27 +30,23 @@
</script> </script>
<aside <aside
class="hidden shrink-0 flex-col border-r border-hairline bg-bg p-5 theme-transition lg:flex" class="hidden shrink-0 flex-col border-r border-hairline p-5 lg:flex"
style="width:var(--mf-sidebar-w)" style="width:var(--mf-sidebar-w)"
> >
<a href="/" class="px-3 pb-3">
<Logo size="sm" />
</a>
<nav class="flex flex-col gap-1"> <nav class="flex flex-col gap-1">
{#each navLinks as link (link.href)} {#each navLinks as link (link.href)}
{@const active = isActive(link.href)} {@const active = isActive(link.href)}
<a <a
href={link.href} href={link.href}
aria-current={active ? 'page' : undefined} aria-current={active ? 'page' : undefined}
class="flex items-center gap-3 rounded-control px-3 py-2 text-sm transition-colors duration-150 {active class="flex items-center gap-3 rounded-control px-3 py-2 text-sm transition-colors {active
? 'bg-surface-2 text-fg' ? 'bg-surface-2 text-fg'
: 'text-fg-2 hover:bg-hover hover:text-fg'}" : 'text-fg-2 hover:bg-hover hover:text-fg'}"
> >
<link.icon class="size-4.5 shrink-0" strokeWidth={1.5} /> <link.icon class="size-icon-md shrink-0" strokeWidth={1.5} />
<span class="flex-1 truncate">{link.label}</span> <span class="flex-1 truncate">{link.label}</span>
{#if link.count} {#if link.count}
<span class="text-xs text-muted tabular-nums">{link.count}</span> <span class="text-xs text-fg-2 tabular-nums">{link.count}</span>
{/if} {/if}
</a> </a>
{/each} {/each}
@@ -60,7 +55,7 @@
{#if user} {#if user}
<div class="mx-3 mt-6 mb-3.5 h-px bg-line"></div> <div class="mx-3 mt-6 mb-3.5 h-px bg-line"></div>
<div class="flex items-center justify-between px-3 pb-1.5"> <div class="flex items-center justify-between px-3 pb-1.5">
<span class="text-eyebrow text-muted">Tus playlists</span> <span class="text-eyebrow text-fg-2">Tus playlists</span>
{#if playlists.length > 0} {#if playlists.length > 0}
<IconButton <IconButton
label="Crear playlist" label="Crear playlist"
@@ -68,12 +63,12 @@
size="xs" size="xs"
onclick={() => createPlaylistModal.show()} onclick={() => createPlaylistModal.show()}
> >
<Plus class="size-icon-sm" strokeWidth={1.25} /> <Plus class="size-icon-sm text-fg-2" strokeWidth={1.25} />
</IconButton> </IconButton>
{/if} {/if}
</div> </div>
<div class="flex flex-1 flex-col gap-px overflow-y-auto pb-4.5"> <div class="flex flex-1 flex-col gap-px overflow-y-auto pb-icon-md">
{#each playlists as playlist (playlist.id)} {#each playlists as playlist (playlist.id)}
{@const active = page.url.pathname === `/playlists/${playlist.id}`} {@const active = page.url.pathname === `/playlists/${playlist.id}`}
<ListRow <ListRow
@@ -91,7 +86,7 @@
<button <button
type="button" type="button"
onclick={() => createPlaylistModal.show()} onclick={() => createPlaylistModal.show()}
class="flex items-center gap-2 rounded-control px-3 py-2 text-xs text-muted transition-colors hover:bg-hover hover:text-fg-2" class="flex items-center gap-2 rounded-control px-3 py-2 text-xs text-fg-2 transition-colors hover:bg-hover hover:text-fg-2"
> >
<Plus class="size-3.5" strokeWidth={1.8} /> <Plus class="size-3.5" strokeWidth={1.8} />
Crear tu primera lista Crear tu primera lista
@@ -69,21 +69,21 @@
<label <label
class="flex h-9.5 w-full items-center gap-2.5 rounded-control bg-surface-2 px-3.5 transition focus-within:bg-surface-hover" class="flex h-9.5 w-full items-center gap-2.5 rounded-control bg-surface-2 px-3.5 transition focus-within:bg-surface-hover"
> >
<Search class="size-4 shrink-0 text-fg-3" strokeWidth={1.8} /> <Search class="size-4 shrink-0 text-fg-2" strokeWidth={1.8} />
<input <input
type="text" type="text"
bind:this={searchInput} bind:this={searchInput}
bind:value={query} bind:value={query}
oninput={onInput} oninput={onInput}
placeholder="Canciones, artistas, álbumes o playlists" placeholder="Canciones, artistas, álbumes o playlists"
class="w-full min-w-0 bg-transparent text-sm text-fg placeholder:text-muted focus:outline-none" class="w-full min-w-0 bg-transparent text-sm text-fg placeholder:text-fg-2 focus:outline-none"
/> />
{#if query} {#if query}
<button <button
type="button" type="button"
onclick={clearQuery} onclick={clearQuery}
aria-label="Borrar búsqueda" aria-label="Borrar búsqueda"
class="grid size-5 shrink-0 place-items-center rounded-full text-muted transition hover:text-fg" class="grid size-5 shrink-0 place-items-center rounded-full text-fg-2 transition hover:text-fg"
> >
<X class="size-3.5" /> <X class="size-3.5" />
</button> </button>
@@ -32,7 +32,7 @@
<div class="flex min-w-0 flex-1 flex-col items-center gap-2"> <div class="flex min-w-0 flex-1 flex-col items-center gap-2">
<TransportControls /> <TransportControls />
<div class="flex w-full max-w-hero items-center gap-3"> <div class="flex w-full max-w-hero items-center gap-3">
<span class="min-w-8 text-right text-xs text-fg-3 tabular-nums"> <span class="min-w-8 text-right text-xs text-fg-2 tabular-nums">
{fmtTime(player.progress)} {fmtTime(player.progress)}
</span> </span>
<Slider <Slider
@@ -42,13 +42,13 @@
oninput={seek} oninput={seek}
variant="seek" variant="seek"
/> />
<span class="min-w-8 text-xs text-fg-3 tabular-nums"> <span class="min-w-8 text-xs text-fg-2 tabular-nums">
{fmtTime(player.current.duration)} {fmtTime(player.current.duration)}
</span> </span>
</div> </div>
</div> </div>
<div class="hidden w-(--mf-player-side-w) shrink-0 items-center justify-end gap-3.5 sm:flex"> <div class="hidden w-(--mf-player-side-w) shrink-0 items-center justify-end gap-3.5 sm:flex">
<Volume2 class="size-icon-sm shrink-0 text-fg-3" strokeWidth={1.8} /> <Volume2 class="size-icon-sm shrink-0 text-fg-2" strokeWidth={1.8} />
<Slider <Slider
value={player.volume} value={player.volume}
max={100} max={100}
@@ -1,7 +1,6 @@
<script lang="ts"> <script lang="ts">
import PlayerBar from './PlayerBar.svelte'; import PlayerBar from './PlayerBar.svelte';
import TabsBar from '../layout/TabsBar.svelte'; import TabsBar from '../layout/TabsBar.svelte';
import Alert from '$lib/components/ui/primitives/Alert.svelte';
import { player } from '$lib/player/player.svelte'; import { player } from '$lib/player/player.svelte';
import type { SessionUser } from '$lib/types'; import type { SessionUser } from '$lib/types';
@@ -17,11 +16,6 @@
<div <div
class="pointer-events-none fixed inset-x-0 bottom-0 z-(--z-sticky) flex flex-col lg:left-(--mf-sidebar-w)" class="pointer-events-none fixed inset-x-0 bottom-0 z-(--z-sticky) flex flex-col lg:left-(--mf-sidebar-w)"
> >
{#if player.error}
<div class="pointer-events-auto px-4 pb-2.5">
<Alert tone="danger">{player.error}</Alert>
</div>
{/if}
{#if hasTrack} {#if hasTrack}
<PlayerBar /> <PlayerBar />
{/if} {/if}
@@ -7,7 +7,7 @@
import { fmtTime } from '$lib/utils/format'; import { fmtTime } from '$lib/utils/format';
import X from '@lucide/svelte/icons/x'; import X from '@lucide/svelte/icons/x';
import { fly } from 'svelte/transition'; import { fly } from 'svelte/transition';
import { cubicOut } from 'svelte/easing'; import { expoOut } from 'svelte/easing';
const upcoming = $derived.by(() => { const upcoming = $derived.by(() => {
const idx = player.tracks.findIndex((t) => t.id === player.currentId); const idx = player.tracks.findIndex((t) => t.id === player.currentId);
@@ -17,8 +17,8 @@
{#if queuePanel.open} {#if queuePanel.open}
<aside <aside
transition:fly={{ x: 24, duration: 220, easing: cubicOut }} transition:fly={{ x: 24, duration: 200, easing: expoOut }}
class="hidden shrink-0 flex-col border-l border-hairline bg-bg px-4 pt-5 theme-transition sm:flex" class="hidden shrink-0 flex-col border-l border-hairline px-4 pt-5 sm:flex"
style="width:var(--mf-queue-w); padding-bottom:calc(var(--mf-player-h) + 2rem)" style="width:var(--mf-queue-w); padding-bottom:calc(var(--mf-player-h) + 2rem)"
> >
<div class="mb-5 flex items-center justify-between"> <div class="mb-5 flex items-center justify-between">
@@ -29,8 +29,8 @@
</div> </div>
{#if player.current.id} {#if player.current.id}
<p class="mb-3 text-eyebrow text-fg-3">Reproduciendo</p> <p class="mb-3 text-eyebrow text-fg-2">Reproduciendo</p>
<div class="mb-6 rounded-control bg-accent-tint p-2.5"> <div class="mb-6 rounded-control surface-active p-2.5">
<MediaIdentity <MediaIdentity
trackId={player.current.id} trackId={player.current.id}
title={player.current.title} title={player.current.title}
@@ -42,12 +42,12 @@
{/if} {/if}
<div class="mb-3 flex items-center justify-between gap-3"> <div class="mb-3 flex items-center justify-between gap-3">
<span class="min-w-0 flex-1 truncate text-eyebrow text-fg-3"> A continuación </span> <span class="min-w-0 flex-1 truncate text-eyebrow text-fg-2"> A continuación </span>
{#if upcoming.length > 0} {#if upcoming.length > 0}
<button <button
type="button" type="button"
onclick={() => player.clearUpcoming()} onclick={() => player.clearUpcoming()}
class="shrink-0 text-xs text-fg-3 transition-colors hover:text-fg" class="shrink-0 text-xs text-fg-2 transition-colors hover:text-fg"
> >
Vaciar Vaciar
</button> </button>
@@ -68,13 +68,12 @@
> >
{#snippet trailing()} {#snippet trailing()}
{#if track.duration} {#if track.duration}
<span class="shrink-0 text-xs text-muted tabular-nums">{fmtTime(track.duration)}</span <span class="shrink-0 text-xs text-fg-2 tabular-nums">{fmtTime(track.duration)}</span>
>
{/if} {/if}
{/snippet} {/snippet}
</ListRow> </ListRow>
{:else} {:else}
<p class="px-2 py-6 text-center text-xs text-fg-3">No hay más canciones en la cola.</p> <p class="px-2 py-6 text-center text-xs text-fg-2">No hay más canciones en la cola.</p>
{/each} {/each}
</div> </div>
</aside> </aside>
@@ -49,7 +49,7 @@
<ArtistLink <ArtistLink
name={player.current.artist || '—'} name={player.current.artist || '—'}
ownerUserId={player.current.ownerUserId} ownerUserId={player.current.ownerUserId}
class="mt-0.5 text-xs text-fg-3" class="mt-0.5 text-xs text-fg-2"
/> />
</div> </div>
{#if !compact} {#if !compact}
@@ -13,7 +13,7 @@
const sideClass = $derived( const sideClass = $derived(
compact compact
? 'grid size-10 place-items-center text-fg opacity-70 transition active:scale-90' ? 'grid size-10 place-items-center text-fg opacity-70 transition active:scale-90'
: 'flex text-fg-2 transition-colors hover:text-fg active:scale-90' : 'flex text-fg-2 transition hover:text-fg active:scale-90'
); );
</script> </script>
@@ -24,7 +24,7 @@
onclick={() => player.toggleShuffle()} onclick={() => player.toggleShuffle()}
aria-pressed={player.shuffle} aria-pressed={player.shuffle}
aria-label="Aleatorio" aria-label="Aleatorio"
class="transition-colors hover:text-fg {player.shuffle ? 'text-accent' : 'text-fg-3'}" class="transition-colors hover:text-fg {player.shuffle ? 'text-accent' : 'text-fg-2'}"
> >
<Shuffle class="size-4" strokeWidth={1.8} /> <Shuffle class="size-4" strokeWidth={1.8} />
</button> </button>
@@ -56,7 +56,7 @@
onclick={() => player.toggleRepeat()} onclick={() => player.toggleRepeat()}
aria-pressed={player.repeat} aria-pressed={player.repeat}
aria-label="Repetir" aria-label="Repetir"
class="transition-colors hover:text-fg {player.repeat ? 'text-accent' : 'text-fg-3'}" class="transition-colors hover:text-fg {player.repeat ? 'text-accent' : 'text-fg-2'}"
> >
<Repeat class="size-4" strokeWidth={1.8} /> <Repeat class="size-4" strokeWidth={1.8} />
</button> </button>
@@ -80,7 +80,7 @@
<div class="flex items-center justify-between gap-4"> <div class="flex items-center justify-between gap-4">
{#if helperText} {#if helperText}
<p class="text-sm text-muted">{helperText}</p> <p class="text-sm text-fg-2">{helperText}</p>
{:else} {:else}
<span></span> <span></span>
{/if} {/if}
@@ -68,6 +68,6 @@
class="h-full w-full object-cover" class="h-full w-full object-cover"
/> />
{:else} {:else}
<Icon class="size-icon-xl {gradient ? 'text-on-art/70' : 'text-muted'}" strokeWidth={1.3} /> <Icon class="size-icon-xl {gradient ? 'text-on-art/70' : 'text-fg-2'}" strokeWidth={1.3} />
{/if} {/if}
</label> </label>
@@ -88,7 +88,7 @@
<div class="flex items-center justify-between gap-4 rounded-art bg-surface px-4 py-3.5"> <div class="flex items-center justify-between gap-4 rounded-art bg-surface px-4 py-3.5">
<div class="min-w-0"> <div class="min-w-0">
<div class="text-sm font-medium text-fg-2">Visibilidad</div> <div class="text-sm font-medium text-fg-2">Visibilidad</div>
<div class="mt-0.5 text-xs text-muted"> <div class="mt-0.5 text-xs text-fg-2">
{visibility === 'private' {visibility === 'private'
? 'Solo tú la ves en tu biblioteca' ? 'Solo tú la ves en tu biblioteca'
: 'Cualquiera con el enlace puede verla'} : 'Cualquiera con el enlace puede verla'}
@@ -30,21 +30,21 @@
{title} {title}
</h2> </h2>
{#if subtitle} {#if subtitle}
<p class="mt-1 text-xs text-fg-3">{subtitle}</p> <p class="mt-1 text-xs text-fg-2">{subtitle}</p>
{/if} {/if}
{/snippet} {/snippet}
{#snippet autoActions()} {#snippet autoActions()}
{#if count !== undefined} {#if count !== undefined}
<div class="h-px flex-1 self-center bg-line"></div> <div class="h-px flex-1 self-center bg-line"></div>
<span class="shrink-0 text-xs text-muted tabular-nums"> <span class="shrink-0 text-xs text-fg-2 tabular-nums">
{plural(count, 'resultado', 'resultados')} {plural(count, 'resultado', 'resultados')}
</span> </span>
{/if} {/if}
{#if href} {#if href}
<a <a
{href} {href}
class="shrink-0 text-xs font-medium tracking-wider text-fg-3 uppercase transition-colors hover:text-fg" class="shrink-0 text-xs font-medium tracking-wider text-fg-2 uppercase transition-colors hover:text-fg"
> >
{linkLabel} {linkLabel}
</a> </a>
@@ -118,7 +118,7 @@
/> />
{:else if fallback === 'music'} {:else if fallback === 'music'}
<div class="grid h-full w-full place-items-center bg-surface"> <div class="grid h-full w-full place-items-center bg-surface">
<svg viewBox="0 0 24 24" fill="currentColor" class="h-2/5 w-2/5 text-fg-3"> <svg viewBox="0 0 24 24" fill="currentColor" class="h-2/5 w-2/5 text-fg-2">
<path <path
d="M19.952 1.651a.75.75 0 0 1 .298.599V16.303a3 3 0 0 1-2.176 2.884l-1.32.377a2.553 2.553 0 1 1-1.401-4.909l2.311-.66a1.5 1.5 0 0 0 1.088-1.442V6.994l-9 2.572v9.737a3 3 0 0 1-2.176 2.884l-1.32.377a2.553 2.553 0 1 1-1.402-4.909l2.312-.66a1.5 1.5 0 0 0 1.088-1.442V5.25a.75.75 0 0 1 .544-.721l10.5-3a.75.75 0 0 1 .754.122Z" d="M19.952 1.651a.75.75 0 0 1 .298.599V16.303a3 3 0 0 1-2.176 2.884l-1.32.377a2.553 2.553 0 1 1-1.401-4.909l2.311-.66a1.5 1.5 0 0 0 1.088-1.442V6.994l-9 2.572v9.737a3 3 0 0 1-2.176 2.884l-1.32.377a2.553 2.553 0 1 1-1.402-4.909l2.312-.66a1.5 1.5 0 0 0 1.088-1.442V5.25a.75.75 0 0 1 .544-.721l10.5-3a.75.75 0 0 1 .754.122Z"
/> />
@@ -7,7 +7,7 @@
</script> </script>
<span <span
class="grid size-4 shrink-0 place-items-center rounded-tag bg-fg-3 text-xs leading-4 font-bold text-bg {klass}" class="grid size-4 shrink-0 place-items-center rounded-tag bg-fg-2 text-xs leading-4 font-bold text-bg {klass}"
title="Contenido explícito" title="Contenido explícito"
aria-label="Contenido explícito">E</span aria-label="Contenido explícito">E</span
> >
@@ -50,7 +50,7 @@
const classes = $derived( const classes = $derived(
`flex items-center gap-3 text-left transition-colors ${ `flex items-center gap-3 text-left transition-colors ${
variant === 'card' ? 'rounded-art p-2 pr-3.5' : 'rounded-control p-2' variant === 'card' ? 'rounded-art p-2 pr-3.5' : 'rounded-control p-2'
} ${active ? 'bg-accent-tint' : 'hover:bg-hover'} ${klass}` } ${active ? 'surface-active' : 'hover:bg-hover'} ${klass}`
); );
</script> </script>
@@ -38,7 +38,7 @@
{trackIds} {trackIds}
size="fill" size="fill"
alt={title} alt={title}
class="rounded-art transition duration-300 group-hover:scale-105" class="rounded-art transition duration-300 ease-snappy group-hover:scale-105"
/> />
{/if} {/if}
{#if onPlay} {#if onPlay}
@@ -50,12 +50,12 @@
size="sm" size="sm"
onclick={onPlay} onclick={onPlay}
label="Reproducir {title}" label="Reproducir {title}"
class="absolute right-2.5 bottom-2.5 opacity-0 transition-opacity duration-150 group-hover:opacity-100" class="absolute right-2.5 bottom-2.5 opacity-0 transition-opacity group-hover:opacity-100"
/> />
{/if} {/if}
</div> </div>
<div class="mt-2.5 truncate text-sm text-fg">{title}</div> <div class="mt-2.5 truncate text-sm text-fg">{title}</div>
{#if subtitle} {#if subtitle}
<div class="mt-0.5 truncate text-xs text-fg-3">{subtitle}</div> <div class="mt-0.5 truncate text-xs text-fg-2">{subtitle}</div>
{/if} {/if}
</a> </a>
@@ -63,6 +63,6 @@
> >
{@render badge?.()} {@render badge?.()}
</div> </div>
<ArtistLink name={artist} {ownerUserId} class="mt-0.5 text-xs text-fg-3" /> <ArtistLink name={artist} {ownerUserId} class="mt-0.5 text-xs text-fg-2" />
</div> </div>
</div> </div>
@@ -26,12 +26,12 @@
}; };
const VARIANT: Record<'strong' | 'glass', string> = { const VARIANT: Record<'strong' | 'glass', string> = {
strong: 'bg-cta-strong text-ink hover:brightness-95', strong: 'bg-cta-strong text-ink hover:bg-cta-strong-hover',
glass: 'border border-on-art/12 bg-ink/76 text-fg backdrop-blur' glass: 'border border-on-art/12 bg-ink/76 text-fg backdrop-blur'
}; };
const classes = $derived( const classes = $derived(
`grid shrink-0 place-items-center rounded-full transition duration-150 ${SIZE[size]} ${VARIANT[variant]} ${klass}` `grid shrink-0 place-items-center rounded-full transition ${SIZE[size]} ${VARIANT[variant]} ${klass}`
); );
</script> </script>
@@ -91,7 +91,7 @@
<div class="overflow-x-auto {klass}"> <div class="overflow-x-auto {klass}">
<div <div
class="grid track-grid items-center gap-3 border-b border-line pr-4 pb-3 pl-2 text-sm text-fg-3 sm:gap-8 sm:pr-6 sm:pl-3" class="grid track-grid items-center gap-3 border-b border-line pr-4 pb-3 pl-2 text-sm text-fg-2 sm:gap-8 sm:pr-6 sm:pl-3"
style="--mf-track-cols:{gridColumns}; --mf-track-cols-mobile:{gridColumnsMobile}" style="--mf-track-cols:{gridColumns}; --mf-track-cols-mobile:{gridColumnsMobile}"
> >
{#if index} {#if index}
@@ -106,13 +106,14 @@
<span></span> <span></span>
{/if} {/if}
</div> </div>
<div class="mt-1 flex flex-col gap-0.5"> <div class="mt-1 flex flex-col gap-0.5" role="list">
{#each tracks as track, i (track.id)} {#each tracks as track, i (track.id)}
{@const active = player.current.id === track.id} {@const active = player.current.id === track.id}
<div <div
role="listitem"
class="group grid track-grid items-center gap-3 rounded-control py-3 pr-4 pl-2 transition-colors sm:gap-8 sm:pr-6 sm:pl-3 {active class="group grid track-grid items-center gap-3 rounded-control py-3 pr-4 pl-2 transition-colors sm:gap-8 sm:pr-6 sm:pl-3 {active
? 'bg-accent-tint' ? 'surface-active'
: 'hover:bg-hover'}" : 'hover:bg-hover'}"
style="--mf-track-cols:{gridColumns}; --mf-track-cols-mobile:{gridColumnsMobile}" style="--mf-track-cols:{gridColumns}; --mf-track-cols-mobile:{gridColumnsMobile}"
oncontextmenu={oncontextmenu ? (event) => oncontextmenu(event, track, i) : undefined} oncontextmenu={oncontextmenu ? (event) => oncontextmenu(event, track, i) : undefined}
@@ -122,7 +123,7 @@
type="button" type="button"
onclick={() => onPlay(i)} onclick={() => onPlay(i)}
aria-label={active && player.playing ? 'Pausar' : 'Reproducir'} aria-label={active && player.playing ? 'Pausar' : 'Reproducir'}
class="relative grid size-8 place-items-center rounded-tag text-xs text-muted tabular-nums" class="relative grid size-8 place-items-center rounded-tag text-xs text-fg-2 tabular-nums"
> >
{#if active} {#if active}
<EqBars paused={!player.playing} /> <EqBars paused={!player.playing} />
@@ -138,6 +139,7 @@
tabindex="0" tabindex="0"
use:pressable={() => onPlay(i)} use:pressable={() => onPlay(i)}
onclick={(event) => event.stopPropagation()} onclick={(event) => event.stopPropagation()}
onkeydown={(event) => event.stopPropagation()}
class="flex min-w-0 flex-1 text-left" class="flex min-w-0 flex-1 text-left"
> >
<MediaIdentity <MediaIdentity
@@ -156,7 +158,7 @@
</MediaIdentity> </MediaIdentity>
</div> </div>
{#each columns as column (column)} {#each columns as column (column)}
<span class="hidden truncate text-center text-xs text-muted tabular-nums sm:block"> <span class="hidden truncate text-center text-xs text-fg-2 tabular-nums sm:block">
{#if column === 'plays'} {#if column === 'plays'}
{Number(track.listensCount ?? 0)} {Number(track.listensCount ?? 0)}
{:else if column === 'added' && track.addedAt} {:else if column === 'added' && track.addedAt}
@@ -166,7 +168,7 @@
{/if} {/if}
</span> </span>
{/each} {/each}
<span class="truncate text-center text-xs text-muted tabular-nums"> <span class="truncate text-center text-xs text-fg-2 tabular-nums">
{fmtTime(Number(track.duration))} {fmtTime(Number(track.duration))}
</span> </span>
{#if rowAction} {#if rowAction}
@@ -44,7 +44,10 @@
{@render trigger({ toggle, open })} {@render trigger({ toggle, open })}
{#if open} {#if open}
<GlassMenu class="absolute top-full right-0 z-(--z-menu) mt-2 overflow-hidden {WIDTH[width]}"> <GlassMenu
border={false}
class="absolute top-full right-0 z-(--z-menu) mt-2 overflow-hidden {WIDTH[width]}"
>
<MenuItem icon={User} label="Ver perfil" href="/user/{user.sub}" onclick={close} /> <MenuItem icon={User} label="Ver perfil" href="/user/{user.sub}" onclick={close} />
{@render extraItems?.({ close })} {@render extraItems?.({ close })}
{#if accountUrl} {#if accountUrl}
@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import Modal from './Modal.svelte'; import Dialog from './Dialog.svelte';
import Button from '../primitives/Button.svelte'; import Button from '../primitives/Button.svelte';
interface Props { interface Props {
@@ -14,13 +14,11 @@
let { open, onClose, title, description, confirmLabel, action }: Props = $props(); let { open, onClose, title, description, confirmLabel, action }: Props = $props();
</script> </script>
<Modal {open} {onClose} maxWidth="max-w-sm"> <Dialog {open} {onClose} {title} {description}>
<h2 class="text-display-3 text-fg">{title}</h2> {#snippet actions()}
<p class="mt-2.5 text-body text-fg-2">{description}</p>
<div class="mt-6 flex items-center justify-end gap-3">
<Button variant="secondary" onclick={onClose}>Cancelar</Button> <Button variant="secondary" onclick={onClose}>Cancelar</Button>
<form method="POST" {action}> <form method="POST" {action}>
<Button type="submit" variant="danger">{confirmLabel}</Button> <Button type="submit" variant="danger">{confirmLabel}</Button>
</form> </form>
</div> {/snippet}
</Modal> </Dialog>
@@ -63,7 +63,7 @@
class="flex items-center gap-3 rounded-control px-2 py-2.5 text-sm font-medium text-fg-2 transition group-hover/addmenu:bg-hover" class="flex items-center gap-3 rounded-control px-2 py-2.5 text-sm font-medium text-fg-2 transition group-hover/addmenu:bg-hover"
> >
<span class="flex-1 cursor-default">{playlistAction.label}</span> <span class="flex-1 cursor-default">{playlistAction.label}</span>
<ChevronRight class="size-icon-sm shrink-0 text-fg-3" /> <ChevronRight class="size-icon-sm shrink-0 text-fg-2" />
</div> </div>
<div <div
class="invisible absolute top-0 z-(--z-submenu) opacity-0 transition group-hover/addmenu:visible group-hover/addmenu:opacity-100 max-sm:top-full max-sm:right-0 max-sm:left-0 max-sm:px-0 max-sm:pt-1.5 {openLeft class="invisible absolute top-0 z-(--z-submenu) opacity-0 transition group-hover/addmenu:visible group-hover/addmenu:opacity-100 max-sm:top-full max-sm:right-0 max-sm:left-0 max-sm:px-0 max-sm:pt-1.5 {openLeft
@@ -75,7 +75,7 @@
> >
<div class="max-h-80 overflow-y-auto"> <div class="max-h-80 overflow-y-auto">
{#if playlists.length === 0} {#if playlists.length === 0}
<p class="px-2 py-2.5 text-sm text-fg-3">Todavía no tienes playlists.</p> <p class="px-2 py-2.5 text-sm text-fg-2">Todavía no tienes playlists.</p>
{/if} {/if}
{#each playlists as playlist (playlist.id)} {#each playlists as playlist (playlist.id)}
<form <form
@@ -0,0 +1,40 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import CircleAlert from '@lucide/svelte/icons/circle-alert';
import Modal from './Modal.svelte';
interface Props {
open: boolean;
onClose: () => void;
title: string;
description?: string;
tone?: 'neutral' | 'danger';
children?: Snippet;
actions?: Snippet;
}
let { open, onClose, title, description, tone = 'neutral', children, actions }: Props = $props();
</script>
<Modal
{open}
{onClose}
maxWidth="max-w-sm"
panelClass={tone === 'danger' ? 'glass-panel-danger' : ''}
>
{#if tone === 'danger'}
<span class="mb-4 grid size-10 place-items-center rounded-full bg-danger/12 text-danger-fg">
<CircleAlert class="size-icon-md" strokeWidth={2} />
</span>
{/if}
<h2 class="text-display-3 text-fg">{title}</h2>
{#if description}
<p class="mt-2.5 text-body text-fg-2">{description}</p>
{/if}
{@render children?.()}
{#if actions}
<div class="mt-6 flex flex-wrap items-center justify-end gap-3">
{@render actions()}
</div>
{/if}
</Modal>
@@ -0,0 +1,34 @@
<script lang="ts">
import Dialog from './Dialog.svelte';
import Button from '../primitives/Button.svelte';
import { dialog, type DialogAction, type DialogOptions } from '$lib/state/dialog.svelte';
// Keep the last dialog's content so the exit transition doesn't render an empty panel.
let last = $state<DialogOptions | null>(null);
$effect.pre(() => {
if (dialog.current) last = dialog.current;
});
const current = $derived(dialog.current ?? last);
const buttons: DialogAction[] = $derived(
current?.actions?.length ? current.actions : [{ label: 'Entendido' }]
);
</script>
<Dialog
open={dialog.current !== null}
onClose={() => dialog.close()}
title={current?.title ?? ''}
description={current?.description}
tone={current?.tone}
>
{#snippet actions()}
{#each buttons as action, i (action.label)}
<Button
variant={action.variant ?? (i === buttons.length - 1 ? 'primary' : 'secondary')}
onclick={() => dialog.run(action)}
>
{action.label}
</Button>
{/each}
{/snippet}
</Dialog>
@@ -4,12 +4,18 @@
interface Props { interface Props {
class?: string; class?: string;
style?: string; style?: string;
border?: boolean;
children: Snippet; children: Snippet;
} }
let { class: klass = '', style = '', children }: Props = $props(); let { class: klass = '', style = '', border = true, children }: Props = $props();
</script> </script>
<div class="glass-panel animate-pop rounded-panel border border-hairline p-1.5 {klass}" {style}> <div
class="glass-panel animate-pop rounded-panel p-1.5 {border
? 'border border-hairline'
: ''} {klass}"
{style}
>
{@render children()} {@render children()}
</div> </div>
@@ -24,7 +24,7 @@
}: Props = $props(); }: Props = $props();
const classes = $derived( const classes = $derived(
`flex w-full items-center gap-3 rounded-control px-2 py-2.5 text-left text-sm font-medium text-fg-2 transition hover:bg-hover ${klass}` `flex w-full items-center gap-3 rounded-control px-2 py-2.5 text-left text-xs font-medium text-fg-2 transition hover:bg-hover ${klass}`
); );
</script> </script>
@@ -2,6 +2,9 @@
import type { Snippet } from 'svelte'; import type { Snippet } from 'svelte';
import X from '@lucide/svelte/icons/x'; import X from '@lucide/svelte/icons/x';
import IconButton from '../primitives/IconButton.svelte'; import IconButton from '../primitives/IconButton.svelte';
import { fade } from 'svelte/transition';
import { expoOut } from 'svelte/easing';
import type { TransitionConfig } from 'svelte/transition';
interface Props { interface Props {
open: boolean; open: boolean;
@@ -45,6 +48,15 @@
); );
} }
function pop(node: Element, { duration = 200 }: { duration?: number } = {}): TransitionConfig {
return {
duration,
easing: expoOut,
css: (t) =>
`opacity: ${t}; transform: scale(${0.98 + 0.02 * t}) translateY(${0.25 * (1 - t)}rem);`
};
}
function onWindowKeydown(event: KeyboardEvent) { function onWindowKeydown(event: KeyboardEvent) {
if (!open) return; if (!open) return;
if (event.key === 'Escape') { if (event.key === 'Escape') {
@@ -70,13 +82,15 @@
{#if open} {#if open}
<div <div
class="animate-fade fixed inset-0 z-(--z-modal) grid place-items-center bg-scrim/70 p-4 backdrop-blur-sm" transition:fade={{ duration: 200 }}
class="fixed inset-0 z-(--z-modal) grid place-items-center bg-scrim/70 p-4 backdrop-blur-sm"
role="presentation" role="presentation"
onclick={onBackdropClick} onclick={onBackdropClick}
> >
<div <div
bind:this={panel} bind:this={panel}
class="glass-panel animate-pop max-h-[85dvh] w-full overflow-y-auto {maxWidth} rounded-panel p-5 outline-none sm:p-6 {panelClass}" transition:pop={{ duration: 200 }}
class="glass-panel max-h-[85dvh] w-full overflow-y-auto {maxWidth} rounded-panel p-5 outline-none sm:p-6 {panelClass}"
role="dialog" role="dialog"
aria-modal="true" aria-modal="true"
aria-label={title} aria-label={title}
@@ -86,7 +100,7 @@
<div class="flex items-center justify-between gap-4"> <div class="flex items-center justify-between gap-4">
<div class="min-w-0"> <div class="min-w-0">
{#if eyebrow} {#if eyebrow}
<p class="mb-2.5 text-eyebrow text-muted"> <p class="mb-2.5 text-eyebrow text-fg-2">
{eyebrow} {eyebrow}
</p> </p>
{/if} {/if}
@@ -24,7 +24,7 @@
<a <a
href={target.url} href={target.url}
{onclick} {onclick}
class="inline-flex items-center text-fg-3 transition-colors hover:text-accent-soft" class="inline-flex items-center text-fg-2 transition-colors hover:text-accent-soft"
> >
<span class="grid size-8 place-items-center"> <span class="grid size-8 place-items-center">
<ArrowLeft class="size-icon-sm" /> <ArrowLeft class="size-icon-sm" />
@@ -3,7 +3,7 @@
import LoaderCircle from '@lucide/svelte/icons/loader-circle'; import LoaderCircle from '@lucide/svelte/icons/loader-circle';
interface Props { interface Props {
variant?: 'primary' | 'secondary' | 'glass' | 'accent' | 'strong' | 'danger'; variant?: 'primary' | 'secondary' | 'accent' | 'danger';
size?: 'sm' | 'md' | 'lg'; size?: 'sm' | 'md' | 'lg';
type?: 'button' | 'submit'; type?: 'button' | 'submit';
href?: string; href?: string;
@@ -29,25 +29,20 @@
}: Props = $props(); }: Props = $props();
const sizes = { const sizes = {
sm: 'px-4 py-2.5 text-xs', sm: 'rounded-control px-4 py-2 text-xs',
md: 'px-5 py-3 text-sm', md: 'rounded-btn-sm px-4.5 py-2.5 text-control-sm',
lg: 'px-6 py-3.5 text-sm' lg: 'rounded-btn px-6 py-3.5 text-sm'
}; };
const subtle = 'bg-btn text-fg-2 hover:bg-btn-hover hover:text-fg';
const glass = 'font-normal bg-btn-glass text-fg-2 hover:bg-btn-glass-hover hover:text-fg';
const variants = { const variants = {
primary: subtle, primary: 'bg-cta-strong text-ink hover:bg-cta-strong-hover',
secondary: subtle, secondary: 'bg-btn text-fg hover:bg-btn-hover',
glass, accent: 'surface-active text-accent-soft hover:surface-active-hover',
accent: 'bg-accent-btn text-accent-soft hover:bg-accent-btn-hover',
strong: 'bg-cta-strong text-ink hover:brightness-95',
danger: 'bg-danger text-on-art hover:brightness-110' danger: 'bg-danger text-on-art hover:brightness-110'
}; };
const classes = $derived( const classes = $derived(
`inline-flex items-center justify-center gap-2 rounded-control transition focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-bg focus-visible:outline-none disabled:opacity-40 ${sizes[size]} ${variants[variant]} ${klass}` `inline-flex items-center justify-center gap-2 font-medium btn-transition focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-bg focus-visible:outline-none disabled:opacity-40 ${sizes[size]} ${variants[variant]} ${klass}`
); );
</script> </script>
@@ -15,9 +15,7 @@
href href
? 'rounded-full border border-line-strong px-3.5 py-2 text-xs text-fg-2 transition-colors hover:bg-hover hover:text-fg' ? 'rounded-full border border-line-strong px-3.5 py-2 text-xs text-fg-2 transition-colors hover:bg-hover hover:text-fg'
: `flex items-center gap-2 rounded-full px-3.5 py-2 text-xs font-medium transition ${ : `flex items-center gap-2 rounded-full px-3.5 py-2 text-xs font-medium transition ${
selected selected ? 'bg-cta-strong text-ink' : 'bg-btn text-fg-2 hover:bg-btn-hover hover:text-fg'
? 'bg-cta-strong text-ink'
: 'bg-surface-2 text-fg-2 hover:bg-surface-hover hover:text-fg'
}` }`
); );
</script> </script>
@@ -25,7 +23,7 @@
{#snippet content()} {#snippet content()}
{@render children()} {@render children()}
{#if count !== undefined} {#if count !== undefined}
<span class="tabular-nums {selected ? 'opacity-55' : 'text-muted'}">{count}</span> <span class="tabular-nums {selected ? 'opacity-55' : 'text-fg-2'}">{count}</span>
{/if} {/if}
{/snippet} {/snippet}
@@ -20,7 +20,7 @@
{/if} {/if}
{@render children()} {@render children()}
{#if hint} {#if hint}
<div class="mt-2 flex items-center justify-between gap-3 text-xs text-muted"> <div class="mt-2 flex items-center justify-between gap-3 text-xs text-fg-2">
{@render hint()} {@render hint()}
</div> </div>
{/if} {/if}
@@ -41,7 +41,7 @@
}; };
const TEXT: Record<'muted' | 'subtle' | 'plain', string> = { const TEXT: Record<'muted' | 'subtle' | 'plain', string> = {
muted: 'text-muted hover:text-fg', muted: 'text-fg-2 hover:text-fg',
subtle: 'text-fg-2 hover:text-fg', subtle: 'text-fg-2 hover:text-fg',
plain: 'text-fg-2 hover:text-fg' plain: 'text-fg-2 hover:text-fg'
}; };
@@ -34,6 +34,6 @@
<div bind:this={sentinel} class="flex min-h-12 items-center justify-center py-8"> <div bind:this={sentinel} class="flex min-h-12 items-center justify-center py-8">
{#if loading} {#if loading}
<LoaderCircle class="size-5 animate-spin text-muted" /> <LoaderCircle class="size-5 animate-spin text-fg-2" />
{/if} {/if}
</div> </div>
@@ -36,7 +36,7 @@
<div class="relative w-full"> <div class="relative w-full">
{#if Icon} {#if Icon}
<span class="pointer-events-none absolute inset-y-0 left-4 grid place-items-center text-muted"> <span class="pointer-events-none absolute inset-y-0 left-4 grid place-items-center text-fg-2">
<Icon class="size-icon-lg" /> <Icon class="size-icon-lg" />
</span> </span>
{/if} {/if}
@@ -17,6 +17,6 @@
</script> </script>
<span class="flex items-center gap-3"> <span class="flex items-center gap-3">
<span class="{MARK[size]} bg-[image:var(--mf-logo-grad)]"></span> <span class="{MARK[size]} bg-(image:--mf-logo-grad)"></span>
<span class={TEXT[size]}>Musify</span> <span class={TEXT[size]}>Musify</span>
</span> </span>
@@ -16,7 +16,7 @@
<div class="relative flex shrink-0 rounded-full bg-surface-2 p-1"> <div class="relative flex shrink-0 rounded-full bg-surface-2 p-1">
<div <div
class="absolute inset-y-1 left-1 rounded-full bg-fg transition-transform duration-300 ease-out" class="absolute inset-y-1 left-1 rounded-full bg-fg transition-transform duration-200 ease-snappy"
style="width:calc((100% - 0.5rem) / {options.length}); transform:translateX(calc({index} * 100%))" style="width:calc((100% - 0.5rem) / {options.length}); transform:translateX(calc({index} * 100%))"
></div> ></div>
{#each options as option (option.value)} {#each options as option (option.value)}
@@ -26,7 +26,7 @@
class="relative z-(--z-raised) flex-1 px-4 py-1.5 text-center text-xs font-medium transition-colors {value === class="relative z-(--z-raised) flex-1 px-4 py-1.5 text-center text-xs font-medium transition-colors {value ===
option.value option.value
? 'text-ink' ? 'text-ink'
: 'text-muted hover:text-fg-2'}" : 'text-fg-2 hover:text-fg-2'}"
> >
{option.label} {option.label}
</button> </button>
+1
View File
@@ -13,6 +13,7 @@ export function mixItemTrack(item: MixItem): ApiTrackLike {
id: item.trackId, id: item.trackId,
title: item.title, title: item.title,
artist: item.artist, artist: item.artist,
duration: item.durationSeconds,
listensCount: item.listensCount listensCount: item.listensCount
}; };
} }
+20 -7
View File
@@ -2,6 +2,7 @@ import { browser } from '$app/environment';
import { DEFAULT_ACCENT } from '$lib/theme/color'; import { DEFAULT_ACCENT } from '$lib/theme/color';
import { extractAccent, type Accent } from '$lib/theme/palette'; import { extractAccent, type Accent } from '$lib/theme/palette';
import { shuffle } from '$lib/data/collections'; import { shuffle } from '$lib/data/collections';
import { dialog } from '$lib/state/dialog.svelte';
export interface PlayerTrack { export interface PlayerTrack {
id: string | number; id: string | number;
@@ -23,6 +24,7 @@ export interface QueueItem {
id: string | number; id: string | number;
title: string; title: string;
artist?: string; artist?: string;
duration?: number | string;
explicit?: boolean; explicit?: boolean;
ownerUserId?: string | number | null; ownerUserId?: string | number | null;
listensCount?: number | string; listensCount?: number | string;
@@ -32,6 +34,7 @@ export interface ApiTrackLike {
id: string | number; id: string | number;
title: string; title: string;
artist?: string | null; artist?: string | null;
duration?: number | string;
isExplicit?: boolean; isExplicit?: boolean;
ownerUserId?: string | number | null; ownerUserId?: string | number | null;
listensCount?: number | string; listensCount?: number | string;
@@ -42,6 +45,7 @@ export function toQueueItems(tracks: ApiTrackLike[]): QueueItem[] {
id: track.id, id: track.id,
title: track.title, title: track.title,
artist: track.artist ?? undefined, artist: track.artist ?? undefined,
duration: track.duration,
explicit: track.isExplicit, explicit: track.isExplicit,
ownerUserId: track.ownerUserId, ownerUserId: track.ownerUserId,
listensCount: track.listensCount listensCount: track.listensCount
@@ -57,6 +61,7 @@ export function trackFromQueueItem(item: QueueItem): ApiTrackLike {
id: item.id, id: item.id,
title: item.title, title: item.title,
artist: item.artist, artist: item.artist,
duration: item.duration,
isExplicit: item.explicit, isExplicit: item.explicit,
ownerUserId: item.ownerUserId, ownerUserId: item.ownerUserId,
listensCount: item.listensCount listensCount: item.listensCount
@@ -101,7 +106,7 @@ function toTrack(item: QueueItem): PlayerTrack {
id: item.id, id: item.id,
title: item.title, title: item.title,
artist: item.artist ?? '', artist: item.artist ?? '',
duration: 0, duration: Number(item.duration) || 0,
explicit: item.explicit, explicit: item.explicit,
ownerUserId: item.ownerUserId, ownerUserId: item.ownerUserId,
listensCount: item.listensCount listensCount: item.listensCount
@@ -115,7 +120,6 @@ class PlayerState {
progress = $state(0); progress = $state(0);
volume = $state(browser ? Number(localStorage.getItem('player.volume') ?? 100) : 100); volume = $state(browser ? Number(localStorage.getItem('player.volume') ?? 100) : 100);
loading = $state(false); loading = $state(false);
error = $state('');
recentlyPlayed = $state<PlayerTrack[]>([]); recentlyPlayed = $state<PlayerTrack[]>([]);
playlists = $state<Playlist[]>([]); playlists = $state<Playlist[]>([]);
shuffle = $state(false); shuffle = $state(false);
@@ -169,7 +173,7 @@ class PlayerState {
this.#stopProgressLoop(); this.#stopProgressLoop();
this.loading = false; this.loading = false;
this.playing = false; this.playing = false;
this.error = 'No se pudo reproducir la pista.'; this.#fail();
}); });
this.#audio = audio; this.#audio = audio;
this.#setupMediaSession(audio); this.#setupMediaSession(audio);
@@ -264,7 +268,6 @@ class PlayerState {
if (track) this.#syncMediaSessionMetadata(track); if (track) this.#syncMediaSessionMetadata(track);
const token = ++this.#loadToken; const token = ++this.#loadToken;
this.loading = true; this.loading = true;
this.error = '';
this.progress = 0; this.progress = 0;
try { try {
const src = await this.#resolveLocalSrc(String(id)); const src = await this.#resolveLocalSrc(String(id));
@@ -279,13 +282,23 @@ class PlayerState {
if (token !== this.#loadToken) return; if (token !== this.#loadToken) return;
this.loading = false; this.loading = false;
this.playing = false; this.playing = false;
this.error = if (exception instanceof DOMException && exception.name === 'AbortError') return;
exception instanceof Error && exception.message this.#fail(
exception instanceof Error && !(exception instanceof DOMException)
? exception.message ? exception.message
: 'No se pudo reproducir la pista.'; : undefined
);
} }
} }
#fail(detail?: string) {
dialog.error(
'No se pudo reproducir la pista',
detail || 'El archivo no está disponible o su formato no es compatible.',
[{ label: 'Cerrar' }]
);
}
async #resolveLocalSrc(id: string): Promise<string> { async #resolveLocalSrc(id: string): Promise<string> {
const res = await fetch(`/api/tracks/${id}/stream`); const res = await fetch(`/api/tracks/${id}/stream`);
if (!res.ok) throw new Error(await readErrorMessage(res)); if (!res.ok) throw new Error(await readErrorMessage(res));
+5 -2
View File
@@ -17,7 +17,7 @@ export interface Session extends SessionUser {
expiresAt: number; expiresAt: number;
} }
let configPromise: Promise<client.Configuration>; let configPromise: Promise<client.Configuration> | undefined;
export function getOidcConfig(): Promise<client.Configuration> { export function getOidcConfig(): Promise<client.Configuration> {
configPromise ??= (async () => { configPromise ??= (async () => {
const issuer = new URL(authConfig.issuer); const issuer = new URL(authConfig.issuer);
@@ -28,7 +28,10 @@ export function getOidcConfig(): Promise<client.Configuration> {
return secret return secret
? client.discovery(issuer, authConfig.clientId, secret, undefined, options) ? client.discovery(issuer, authConfig.clientId, secret, undefined, options)
: client.discovery(issuer, authConfig.clientId, undefined, client.None(), options); : client.discovery(issuer, authConfig.clientId, undefined, client.None(), options);
})(); })().catch((exception) => {
configPromise = undefined;
throw exception;
});
return configPromise; return configPromise;
} }
+35
View File
@@ -0,0 +1,35 @@
export interface DialogAction {
label: string;
variant?: 'primary' | 'secondary' | 'accent' | 'danger';
onclick?: () => void;
}
export interface DialogOptions {
title: string;
description?: string;
tone?: 'neutral' | 'danger';
actions?: DialogAction[];
}
class DialogStore {
current = $state<DialogOptions | null>(null);
show(options: DialogOptions) {
this.current = options;
}
error(title: string, description?: string, actions?: DialogAction[]) {
this.show({ title, description, tone: 'danger', actions });
}
close() {
this.current = null;
}
run(action: DialogAction) {
this.close();
action.onclick?.();
}
}
export const dialog = new DialogStore();
+4 -1
View File
@@ -1,4 +1,7 @@
import { contextMenuPosition, type MenuPosition } from '$lib/components/ui/overlay/ContextMenu.svelte'; import {
contextMenuPosition,
type MenuPosition
} from '$lib/components/ui/overlay/ContextMenu.svelte';
import { player, toQueueItem, type ApiTrackLike } from '../player/player.svelte'; import { player, toQueueItem, type ApiTrackLike } from '../player/player.svelte';
export type TrackMenuState = { track: ApiTrackLike } & MenuPosition; export type TrackMenuState = { track: ApiTrackLike } & MenuPosition;
+2 -2
View File
@@ -1,5 +1,5 @@
export const ACCENT_LIGHTNESS = 74; export const ACCENT_LIGHTNESS = 74;
export const ACCENT_CHROMA = 0.145; export const ACCENT_CHROMA = 0.06;
export const ACCENT_HUE = 145; export const ACCENT_HUE = 30;
export const DEFAULT_ACCENT = `oklch(${ACCENT_LIGHTNESS}% ${ACCENT_CHROMA} ${ACCENT_HUE})`; export const DEFAULT_ACCENT = `oklch(${ACCENT_LIGHTNESS}% ${ACCENT_CHROMA} ${ACCENT_HUE})`;
+23 -20
View File
@@ -7,17 +7,14 @@
--mf-tile-shade-c: 0.035; --mf-tile-shade-c: 0.035;
--mf-text: #d9d8dc; --mf-text: #d9d8dc;
--mf-text-2: #8a8a8e; --mf-text-2: #b1b0b5;
--mf-text-3: #7d7d81;
--mf-text-4: #646468;
--mf-surface: rgba(255, 255, 255, 0.022); --mf-surface: rgba(255, 255, 255, 0.022);
--mf-surface-2: rgba(255, 255, 255, 0.045); --mf-surface-2: rgba(255, 255, 255, 0.055);
--mf-surface-hover: rgba(255, 255, 255, 0.06); --mf-surface-hover: rgba(255, 255, 255, 0.06);
--mf-btn-bg: var(--mf-surface-2); --mf-row-hover: rgba(255, 255, 255, 0.035);
--mf-btn-bg-hover: var(--mf-surface-hover); --mf-btn-bg: rgba(255, 255, 255, 0.055);
--mf-btn-secondary-bg: rgba(255, 255, 255, 0.045); --mf-btn-bg-hover: rgba(255, 255, 255, 0.09);
--mf-btn-secondary-bg-hover: rgba(255, 255, 255, 0.07);
--mf-border: rgba(255, 255, 255, 0.04); --mf-border: rgba(255, 255, 255, 0.04);
--mf-border-strong: rgba(255, 255, 255, 0.09); --mf-border-strong: rgba(255, 255, 255, 0.09);
@@ -27,7 +24,14 @@
--mf-on-art: #fefdfb; --mf-on-art: #fefdfb;
--mf-on-art-2: #cac8c3; --mf-on-art-2: #cac8c3;
--mf-ink: #0a0a0d; --mf-ink: #0a0a0d;
--mf-cta-strong-bg: #ffffff; --mf-cta-strong-bg: #c6c4cb;
--mf-cta-strong-hover: #d8d7dc;
--mf-danger-fg: oklch(0.885 0.062 18.334);
--mf-motion-fast: 150ms;
--mf-motion-base: 200ms;
--mf-motion-slow: 300ms;
--mf-ease: cubic-bezier(0.16, 1, 0.3, 1);
--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-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");
@@ -74,18 +78,15 @@
} }
:root[data-theme='light'] { :root[data-theme='light'] {
--mf-text: #17171a; --mf-text: #17161c;
--mf-text-2: #4a4a4e; --mf-text-2: #5d5c66;
--mf-text-3: #6b6b6f;
--mf-text-4: #8c8c90;
--mf-surface: rgba(0, 0, 0, 0.035); --mf-surface: rgba(0, 0, 0, 0.035);
--mf-surface-2: rgba(0, 0, 0, 0.06); --mf-surface-2: rgba(0, 0, 0, 0.06);
--mf-surface-hover: #e8eceb; --mf-surface-hover: rgba(26, 24, 38, 0.06);
--mf-btn-bg: var(--mf-surface-2); --mf-row-hover: rgba(26, 24, 38, 0.04);
--mf-btn-bg-hover: var(--mf-surface-hover); --mf-btn-bg: rgba(255, 255, 255, 0.7);
--mf-btn-secondary-bg: rgba(0, 0, 0, 0.09); --mf-btn-bg-hover: #ffffff;
--mf-btn-secondary-bg-hover: rgba(0, 0, 0, 0.14);
--mf-scrollbar: rgba(0, 0, 0, 0.18); --mf-scrollbar: rgba(0, 0, 0, 0.18);
--mf-scrollbar-hover: rgba(0, 0, 0, 0.32); --mf-scrollbar-hover: rgba(0, 0, 0, 0.32);
@@ -95,8 +96,10 @@
--mf-track: rgba(0, 0, 0, 0.12); --mf-track: rgba(0, 0, 0, 0.12);
--mf-scrim: #ffffff; --mf-scrim: #ffffff;
--mf-ink: #ffffff; --mf-ink: #f7f6f9;
--mf-cta-strong-bg: #17171a; --mf-cta-strong-bg: #1b1a20;
--mf-cta-strong-hover: #2e2d36;
--mf-danger-fg: oklch(0.5 0.16 25);
--mf-tile-l: 0.58; --mf-tile-l: 0.58;
--mf-tile-c: 0.085; --mf-tile-c: 0.085;
+45 -48
View File
@@ -1,75 +1,72 @@
import { ACCENT_CHROMA, ACCENT_LIGHTNESS } from './color';
const L = ACCENT_LIGHTNESS / 100;
const C = ACCENT_CHROMA;
function r(value: number, digits = 4): string { function r(value: number, digits = 4): string {
return value.toFixed(digits); return value.toFixed(digits);
} }
function tc(alpha: number, l: number, hue: number): string {
const lightness = 0.34 - l / 380;
const chroma = Math.max(0, 0.11 - l / 1600);
return `oklch(${r(lightness)} ${r(chroma)} ${r(hue, 1)} / ${alpha})`;
}
function tcLight(alpha: number, l: number, hue: number): string {
const depth = (88 - l) / 58;
const lightness = 0.88 - 0.16 * depth;
const chroma = 0.03 + 0.11 * depth;
return `oklch(${r(lightness)} ${r(chroma)} ${r(hue, 1)} / ${alpha})`;
}
export interface ThemeTokens { export interface ThemeTokens {
[cssVar: string]: string; [cssVar: string]: string;
} }
export function buildThemeTokens(hue: number, mode: 'dark' | 'light' = 'dark'): ThemeTokens { export function buildThemeTokens(hue: number, mode: 'dark' | 'light' = 'dark'): ThemeTokens {
const h = ((hue % 360) + 360) % 360; const h = ((hue % 360) + 360) % 360;
const h2 = (h + 40) % 360;
const H = r(h, 1); const H = r(h, 1);
const H2 = r(h2, 1);
const light = mode === 'light'; const light = mode === 'light';
const accentL = light ? 0.52 : L; const accentL = light ? 0.5 : 0.72;
const accentC = light ? 0.16 : C; const accentC = light ? 0.07 : 0.06;
const titleL = light ? 0.4 : 0.87; const titleL = light ? 0.36 : 0.86;
const titleC = light ? 0.13 : 0.07; const titleC = light ? 0.06 : 0.03;
const mutedL = light ? 0.48 : 0.79; const mutedL = light ? 0.42 : 0.8;
const mutedC = light ? 0.06 : 0.035; const mutedC = light ? 0.05 : 0.02;
const accent = `oklch(${r(accentL)} ${r(accentC)} ${H})`;
const g = (l1: number, c1: number, a1: number, l2: number, c2: number, a2: number) =>
`linear-gradient(90deg, oklch(${l1} ${c1} ${H} / ${a1}), oklch(${l2} ${c2} ${H} / ${a2}))`;
const gActive = light
? g(0.82, 0.05, 0.55, 0.87, 0.035, 0.36)
: g(0.46, 0.06, 0.34, 0.4, 0.045, 0.22);
const gHover = light
? g(0.86, 0.035, 0.38, 0.9, 0.025, 0.24)
: g(0.42, 0.045, 0.26, 0.38, 0.035, 0.17);
const glow = (l: number, c: number, a: number) => `oklch(${l} ${c} ${H} / ${a})`;
// Fixed danger hue so error dialogs read red whatever the theme hue is.
const dangerGlow = (l: number, c: number, a: number) => `oklch(${l} ${c} 25 / ${a})`;
return { return {
'--mf-accent': `oklch(${r(accentL)} ${r(accentC)} ${H})`, '--mf-accent': accent,
'--mf-accent-title': `oklch(${r(titleL)} ${r(titleC)} ${H})`, '--mf-accent-title': `oklch(${r(titleL)} ${r(titleC)} ${H})`,
'--mf-accent-muted': `oklch(${r(mutedL)} ${r(mutedC)} ${H})`, '--mf-accent-muted': `oklch(${r(mutedL)} ${r(mutedC)} ${H})`,
'--mf-accent-soft-bg': `oklch(${r(accentL)} ${r(accentC)} ${H} / 0.08)`, '--mf-accent-soft-bg': `color-mix(in srgb, ${accent} 12%, var(--mf-bg))`,
'--mf-accent-line': `oklch(${r(accentL)} ${r(accentC)} ${H} / 0.2)`, '--mf-accent-line': `oklch(${r(accentL)} ${r(accentC)} ${H} / 0.2)`,
'--mf-accent-hair': `oklch(${r(accentL)} ${r(accentC)} ${H} / 0.09)`, '--mf-accent-hair': `oklch(${r(accentL)} ${r(accentC)} ${H} / 0.09)`,
'--mf-accent-btn-bg': `oklch(${r(accentL)} ${r(accentC)} ${H} / 0.15)`, '--mf-accent-btn-bg': `color-mix(in srgb, ${accent} 18%, var(--mf-bg))`,
'--mf-accent-btn-bg-hover': `oklch(${r(accentL)} ${r(accentC)} ${H} / 0.24)`, '--mf-accent-btn-bg-hover': `color-mix(in srgb, ${accent} 26%, var(--mf-bg))`,
'--mf-bg': light ? `oklch(0.97 0.006 ${H})` : `oklch(0.068 0.008 ${H})`, '--mf-g-active': gActive,
'--mf-elevated': light ? `oklch(0.99 0.003 ${H})` : `oklch(0.11 0.012 ${H})`, '--mf-g-hover': gHover,
'--mf-panel-bg': light ? `oklch(0.97 0.006 ${H} / 0.7)` : `oklch(0.09 0.01 ${H} / 0.28)`, '--mf-bg': light ? `oklch(0.975 0.012 ${H})` : `oklch(0.068 0.008 ${H})`,
'--mf-bar-bg': light ? `oklch(0.97 0.006 ${H} / 0.9)` : `oklch(0.105 0.012 ${H} / 0.9)`, '--mf-elevated': light ? `oklch(0.99 0.008 ${H})` : `oklch(0.12 0.012 ${H})`,
'--mf-hairline': light ? `oklch(0.3 0.02 ${H} / 0.1)` : `oklch(0.62 0.03 ${H} / 0.08)`, '--mf-panel-bg': light ? 'rgba(255, 255, 255, 0.28)' : 'rgba(0, 0, 0, 0.16)',
'--mf-bar-bg': light ? `oklch(0.99 0.01 ${H} / 0.86)` : `oklch(0.105 0.012 ${H} / 0.9)`,
'--mf-hairline': light ? `oklch(0.3 0.02 ${H} / 0.06)` : `oklch(0.62 0.02 ${H} / 0.05)`,
'--mf-ambient': light '--mf-ambient': light
? 'none' ? `radial-gradient(80% 70% at 12% 0%, ${glow(0.9, 0.05, 0.55)}, transparent 75%), radial-gradient(70% 60% at 88% 8%, ${glow(0.92, 0.035, 0.4)}, transparent 75%), radial-gradient(90% 70% at 50% 110%, ${glow(0.93, 0.03, 0.35)}, transparent 80%), linear-gradient(180deg, ${glow(0.96, 0.02, 0.4)}, transparent 60%)`
: `radial-gradient(52% 38% at 14% 0%, ${tc(0.26, 30, h)}, transparent 54%), radial-gradient(46% 34% at 78% 0%, ${tc(0.2, 40, h)}, transparent 56%)`, : `radial-gradient(75% 65% at 12% 0%, ${glow(0.3, 0.045, 0.5)}, transparent 75%), radial-gradient(65% 55% at 85% 5%, ${glow(0.24, 0.035, 0.35)}, transparent 75%), radial-gradient(90% 70% at 50% 105%, ${glow(0.18, 0.025, 0.3)}, transparent 80%), linear-gradient(180deg, ${glow(0.14, 0.02, 0.35)}, transparent 55%)`,
'--mf-hero-tint': light ? `oklch(0.8 0.05 ${H})` : `oklch(0.3 0.04 ${H})`,
'--mf-hero-bg': light '--mf-hero-bg': light
? `linear-gradient(${tcLight(0.16, 55, h)}, ${tcLight(0.16, 55, h)})` ? `linear-gradient(120deg, ${glow(0.9, 0.04, 0.6)}, ${glow(0.96, 0.02, 0.4)} 60%)`
: `linear-gradient(105deg, ${tc(0.34, 42, h)} 0%, ${tc(0.11, 70, h)} 37%, ${tc(0.03, 88, h)} 65%, rgba(6,6,9,0) 92%)`, : `linear-gradient(120deg, ${glow(0.24, 0.04, 0.55)}, ${glow(0.14, 0.02, 0.35)} 60%, ${glow(0.12, 0.015, 0.25)})`,
'--mf-modal-glow': light '--mf-modal-glow': light
? `linear-gradient(135deg, ${tcLight(0.34, 42, h)} 0%, ${tcLight(0.2, 55, h)} 30%, ${tcLight(0.1, 70, h)} 55%, ${tcLight(0.04, 85, h)} 80%, ${tcLight(0.02, 88, h)} 100%)` ? `linear-gradient(135deg, ${glow(0.88, 0.05, 0.5)} 0%, ${glow(0.93, 0.03, 0.2)} 40%, transparent 75%)`
: `linear-gradient(135deg, ${tc(0.34, 42, h)} 0%, ${tc(0.11, 70, h)} 28%, ${tc(0.03, 88, h)} 50%, rgba(6,6,9,0) 70%)`, : `linear-gradient(135deg, ${glow(0.3, 0.045, 0.4)} 0%, ${glow(0.2, 0.03, 0.15)} 35%, transparent 70%)`,
'--mf-modal-glow-danger': light
? `linear-gradient(135deg, ${dangerGlow(0.86, 0.07, 0.55)} 0%, ${dangerGlow(0.92, 0.04, 0.22)} 40%, transparent 75%)`
: `linear-gradient(135deg, ${dangerGlow(0.34, 0.1, 0.45)} 0%, ${dangerGlow(0.22, 0.06, 0.18)} 35%, transparent 70%)`,
'--mf-spotlight-bg': light '--mf-spotlight-bg': light
? `linear-gradient(${tcLight(0.16, 55, h)}, ${tcLight(0.16, 55, h)})` ? `linear-gradient(120deg, ${glow(0.9, 0.04, 0.55)}, ${glow(0.96, 0.02, 0.35)} 55%)`
: `linear-gradient(100deg, ${tc(0.28, 46, h)} 0%, ${tc(0.1, 74, h)} 40%, ${tc(0.03, 88, h)} 69%, rgba(6,6,9,0) 96%)`, : `linear-gradient(120deg, ${glow(0.22, 0.04, 0.5)}, ${glow(0.13, 0.02, 0.3)} 55%, ${glow(0.11, 0.015, 0.2)})`,
'--mf-logo-grad': light '--mf-logo-grad': `linear-gradient(${accent}, ${accent})`,
? `linear-gradient(140deg, oklch(0.68 0.16 ${H}), oklch(0.5 0.14 ${H2}) 92%)`
: `linear-gradient(140deg, oklch(0.78 0.15 ${H}), oklch(0.62 0.13 ${H2}) 92%)`,
'--mf-cover-grad': light '--mf-cover-grad': light
? `linear-gradient(150deg, oklch(0.62 0.14 ${H}), oklch(0.44 0.08 ${H}))` ? `linear-gradient(150deg, oklch(0.84 0.03 ${H}), oklch(0.74 0.02 ${H}))`
: `linear-gradient(150deg, oklch(0.5 0.13 ${H}), oklch(0.16 0.035 ${H}))` : `linear-gradient(150deg, oklch(0.34 0.03 ${H}), oklch(0.18 0.015 ${H}))`
}; };
} }
@@ -2,10 +2,6 @@
import { player, toQueueItems, isQueueCurrent, playAllOrToggle } from '$lib/player/player.svelte'; import { player, toQueueItems, isQueueCurrent, playAllOrToggle } from '$lib/player/player.svelte';
import X from '@lucide/svelte/icons/x'; import X from '@lucide/svelte/icons/x';
import Plus from '@lucide/svelte/icons/plus'; import Plus from '@lucide/svelte/icons/plus';
import Play from '@lucide/svelte/icons/play';
import Pause from '@lucide/svelte/icons/pause';
import SquarePencil from '@lucide/svelte/icons/square-pen';
import Trash from '@lucide/svelte/icons/trash';
import Page from '$lib/components/ui/layout/Page.svelte'; import Page from '$lib/components/ui/layout/Page.svelte';
import PageHeader from '$lib/components/ui/layout/PageHeader.svelte'; import PageHeader from '$lib/components/ui/layout/PageHeader.svelte';
import Modal from '$lib/components/ui/overlay/Modal.svelte'; import Modal from '$lib/components/ui/overlay/Modal.svelte';
@@ -76,20 +72,14 @@
{#snippet actions()} {#snippet actions()}
<Button size="sm" onclick={playAll} disabled={tracks.length === 0}> <Button size="sm" onclick={playAll} disabled={tracks.length === 0}>
{#if isCurrentQueue && player.playing} {#if isCurrentQueue && player.playing}
<Pause class="size-4" strokeWidth={1.5} />
Pausar Pausar
{:else} {:else}
<Play class="size-4" strokeWidth={1.5} />
Reproducir Reproducir
{/if} {/if}
</Button> </Button>
{#if isOwner} {#if isOwner}
<Button size="sm" variant="secondary" onclick={() => (editing = true)}> <Button size="sm" variant="secondary" onclick={() => (editing = true)}>Editar</Button>
<SquarePencil class="size-4" strokeWidth={1.5} />
Editar
</Button>
<Button size="sm" variant="secondary" onclick={() => (confirmingDelete = true)}> <Button size="sm" variant="secondary" onclick={() => (confirmingDelete = true)}>
<Trash class="size-4" strokeWidth={1.5} />
Eliminar Eliminar
</Button> </Button>
{/if} {/if}
@@ -249,7 +249,7 @@
{/if} {/if}
<div class="flex h-22 min-w-0 flex-1 flex-col justify-center gap-3"> <div class="flex h-22 min-w-0 flex-1 flex-col justify-center gap-3">
<div class="text-eyebrow leading-none font-semibold text-fg-3"> <div class="text-eyebrow leading-none font-semibold text-fg-2">
Mejor resultado · {TOP_RESULT_KIND_LABEL[topResult.kind]} Mejor resultado · {TOP_RESULT_KIND_LABEL[topResult.kind]}
</div> </div>
<div <div
@@ -264,7 +264,7 @@
: topResult.user.name} : topResult.user.name}
</div> </div>
{#if topResult.kind === 'track' && topResult.track.artist} {#if topResult.kind === 'track' && topResult.track.artist}
<div class="truncate text-sm leading-none text-fg-3"> <div class="truncate text-sm leading-none text-fg-2">
{topResult.track.artist} {topResult.track.artist}
</div> </div>
{/if} {/if}
@@ -276,6 +276,7 @@
<svelte:element <svelte:element
this={topResult.kind === 'track' ? 'button' : 'a'} this={topResult.kind === 'track' ? 'button' : 'a'}
type={topResult.kind === 'track' ? 'button' : undefined} type={topResult.kind === 'track' ? 'button' : undefined}
role={topResult.kind === 'track' ? 'button' : 'link'}
href={topResult.kind === 'track' ? undefined : topResultHref} href={topResult.kind === 'track' ? undefined : topResultHref}
onclick={topResult.kind === 'track' ? playTopResult : undefined} onclick={topResult.kind === 'track' ? playTopResult : undefined}
class="group/top animate-pop mt-6.5 flex w-full items-center gap-5 rounded-panel-lg bg-surface p-4.5 text-left transition hover:bg-surface-hover" class="group/top animate-pop mt-6.5 flex w-full items-center gap-5 rounded-panel-lg bg-surface p-4.5 text-left transition hover:bg-surface-hover"
@@ -348,7 +349,7 @@
{/if} {/if}
{/snippet} {/snippet}
{#snippet trailing()} {#snippet trailing()}
<span class="hidden shrink-0 text-xs text-muted tabular-nums sm:block"> <span class="hidden shrink-0 text-xs text-fg-2 tabular-nums sm:block">
{fmtTime(seconds)} · {fmtPlays(track.listensCount)} {fmtTime(seconds)} · {fmtPlays(track.listensCount)}
</span> </span>
{/snippet} {/snippet}
@@ -383,7 +384,7 @@
oncontextmenu={(e) => openAlbumMenu(e, album.id)} oncontextmenu={(e) => openAlbumMenu(e, album.id)}
> >
{#snippet trailing()} {#snippet trailing()}
<span class="hidden shrink-0 text-xs text-muted tabular-nums sm:block"> <span class="hidden shrink-0 text-xs text-fg-2 tabular-nums sm:block">
{Number(album.trackCount)} canciones {Number(album.trackCount)} canciones
</span> </span>
{/snippet} {/snippet}
@@ -1,6 +1,4 @@
<script lang="ts"> <script lang="ts">
import Play from '@lucide/svelte/icons/play';
import Pause from '@lucide/svelte/icons/pause';
import Shuffle from '@lucide/svelte/icons/shuffle'; import Shuffle from '@lucide/svelte/icons/shuffle';
import { import {
player, player,
@@ -75,10 +73,8 @@
{#snippet actions()} {#snippet actions()}
<Button size="sm" onclick={() => playAllOrToggle(queue)} disabled={queue.length === 0}> <Button size="sm" onclick={() => playAllOrToggle(queue)} disabled={queue.length === 0}>
{#if isCurrentQueue && player.playing} {#if isCurrentQueue && player.playing}
<Pause class="size-4" strokeWidth={1.5} />
Pausar Pausar
{:else} {:else}
<Play class="size-4" strokeWidth={1.5} />
Reproducir Reproducir
{/if} {/if}
</Button> </Button>
@@ -1,10 +1,6 @@
<script lang="ts"> <script lang="ts">
import ListMusic from '@lucide/svelte/icons/list-music'; import ListMusic from '@lucide/svelte/icons/list-music';
import X from '@lucide/svelte/icons/x'; import X from '@lucide/svelte/icons/x';
import Play from '@lucide/svelte/icons/play';
import Pause from '@lucide/svelte/icons/pause';
import SquarePencil from '@lucide/svelte/icons/square-pen';
import Trash from '@lucide/svelte/icons/trash';
import { player, toQueueItems, isQueueCurrent, playAllOrToggle } from '$lib/player/player.svelte'; import { player, toQueueItems, isQueueCurrent, playAllOrToggle } from '$lib/player/player.svelte';
import Page from '$lib/components/ui/layout/Page.svelte'; import Page from '$lib/components/ui/layout/Page.svelte';
import PageHeader from '$lib/components/ui/layout/PageHeader.svelte'; import PageHeader from '$lib/components/ui/layout/PageHeader.svelte';
@@ -66,19 +62,13 @@
{#snippet actions()} {#snippet actions()}
<Button size="sm" onclick={playAll} disabled={tracks.length === 0}> <Button size="sm" onclick={playAll} disabled={tracks.length === 0}>
{#if isCurrentQueue && player.playing} {#if isCurrentQueue && player.playing}
<Pause class="size-4" strokeWidth={1.5} />
Pausar Pausar
{:else} {:else}
<Play class="size-4" strokeWidth={1.5} />
Reproducir Reproducir
{/if} {/if}
</Button> </Button>
<Button size="sm" variant="secondary" onclick={() => (editing = true)}> <Button size="sm" variant="secondary" onclick={() => (editing = true)}>Editar</Button>
<SquarePencil class="size-4" strokeWidth={1.5} />
Editar
</Button>
<Button size="sm" variant="secondary" onclick={() => (confirmingDelete = true)}> <Button size="sm" variant="secondary" onclick={() => (confirmingDelete = true)}>
<Trash class="size-4" strokeWidth={1.5} />
Eliminar Eliminar
</Button> </Button>
{/snippet} {/snippet}
@@ -123,7 +123,7 @@
</span> </span>
<span class="text-sm font-medium text-fg">{step.label}</span> <span class="text-sm font-medium text-fg">{step.label}</span>
</div> </div>
<p class="mt-2.5 text-xs text-muted">{step.hint}</p> <p class="mt-2.5 text-xs text-fg-2">{step.hint}</p>
</Surface> </Surface>
</li> </li>
{/each} {/each}
@@ -191,7 +191,7 @@
aria-label="Seleccionar archivo de audio" aria-label="Seleccionar archivo de audio"
/> />
{#if audioName === ''} {#if audioName === ''}
<Headphones class="size-9 text-muted sm:size-10" strokeWidth={1.4} /> <Headphones class="size-9 text-fg-2 sm:size-10" strokeWidth={1.4} />
{:else} {:else}
<Music class="size-9 text-accent-soft sm:size-10" strokeWidth={1.4} /> <Music class="size-9 text-accent-soft sm:size-10" strokeWidth={1.4} />
{/if} {/if}
@@ -199,12 +199,12 @@
<p class="mt-4 text-sm font-medium text-fg"> <p class="mt-4 text-sm font-medium text-fg">
Arrastra tu audio aquí o haz clic para elegir Arrastra tu audio aquí o haz clic para elegir
</p> </p>
<p class="mt-1.5 text-xs text-muted">MP3, FLAC, WAV…</p> <p class="mt-1.5 text-xs text-fg-2">MP3, FLAC, WAV…</p>
{:else} {:else}
<p class="mt-4 max-w-full truncate text-sm font-medium text-fg"> <p class="mt-4 max-w-full truncate text-sm font-medium text-fg">
{audioName} {audioName}
</p> </p>
<p class="mt-1.5 text-xs text-muted">{formatSize(audioSize)} · Listo para subir</p> <p class="mt-1.5 text-xs text-fg-2">{formatSize(audioSize)} · Listo para subir</p>
{/if} {/if}
</div> </div>
@@ -251,7 +251,7 @@
<Surface padding="sm"> <Surface padding="sm">
<div class="flex items-center justify-between text-sm"> <div class="flex items-center justify-between text-sm">
<span class="font-medium text-fg">Subiendo y creando la pista…</span> <span class="font-medium text-fg">Subiendo y creando la pista…</span>
<span class="text-xs text-muted">un momento</span> <span class="text-xs text-fg-2">un momento</span>
</div> </div>
<div class="mt-3 h-2 overflow-hidden rounded-full bg-surface-2"> <div class="mt-3 h-2 overflow-hidden rounded-full bg-surface-2">
<div class="h-full w-full animate-pulse rounded-full bg-accent-soft"></div> <div class="h-full w-full animate-pulse rounded-full bg-accent-soft"></div>
+4
View File
@@ -10,6 +10,7 @@
import TopBar from '$lib/components/layout/TopBar.svelte'; import TopBar from '$lib/components/layout/TopBar.svelte';
import MobileHeader from '$lib/components/layout/MobileHeader.svelte'; import MobileHeader from '$lib/components/layout/MobileHeader.svelte';
import Modal from '$lib/components/ui/overlay/Modal.svelte'; import Modal from '$lib/components/ui/overlay/Modal.svelte';
import DialogHost from '$lib/components/ui/overlay/DialogHost.svelte';
import PlaylistForm from '$lib/components/ui/forms/PlaylistForm.svelte'; import PlaylistForm from '$lib/components/ui/forms/PlaylistForm.svelte';
import { player } from '$lib/player/player.svelte'; import { player } from '$lib/player/player.svelte';
import { queuePanel } from '$lib/player/queuePanel.svelte'; import { queuePanel } from '$lib/player/queuePanel.svelte';
@@ -87,6 +88,7 @@
<svelte:head> <svelte:head>
<link rel="icon" href={favicon} /> <link rel="icon" href={favicon} />
<!-- eslint-disable-next-line svelte/no-at-html-tags -- CSS generado a partir de números, sin entrada de usuario -->
{@html `<style>${initialThemeCss}</style>`} {@html `<style>${initialThemeCss}</style>`}
</svelte:head> </svelte:head>
@@ -134,3 +136,5 @@
</Modal> </Modal>
</div> </div>
{/if} {/if}
<DialogHost />
+13 -15
View File
@@ -134,9 +134,7 @@
</svelte:head> </svelte:head>
<Page> <Page>
<div <div class="relative overflow-hidden rounded-panel-lg hero-surface px-6 py-8 sm:px-9 sm:py-8.5">
class="relative overflow-hidden rounded-panel-lg hero-surface px-6 py-8 theme-transition sm:px-9 sm:py-8.5"
>
<div <div
class="relative flex flex-col items-start gap-7 lg:flex-row lg:flex-wrap lg:items-end lg:justify-between" class="relative flex flex-col items-start gap-7 lg:flex-row lg:flex-wrap lg:items-end lg:justify-between"
> >
@@ -151,12 +149,12 @@
</p> </p>
<div class="mt-6.5 flex flex-wrap items-center gap-2.5"> <div class="mt-6.5 flex flex-wrap items-center gap-2.5">
{#if spotlight} {#if spotlight}
<Button variant="strong" onclick={playSpotlight}> <Button variant="primary" onclick={playSpotlight}>
{heroPlaying ? 'Pausar' : 'Reanudar'} {heroPlaying ? 'Pausar' : 'Reanudar'}
{spotlight.name} {spotlight.name}
</Button> </Button>
{/if} {/if}
<Button href="/explore" variant="glass">Explorar música</Button> <Button href="/explore" variant="secondary">Explorar música</Button>
</div> </div>
</div> </div>
@@ -173,7 +171,7 @@
{#each statsList as stat (stat.label)} {#each statsList as stat (stat.label)}
<div> <div>
<div class="text-display-2 text-fg">{stat.value}</div> <div class="text-display-2 text-fg">{stat.value}</div>
<div class="mt-1 text-xs text-fg-3">{stat.label}</div> <div class="mt-1 text-xs text-fg-2">{stat.label}</div>
</div> </div>
{/each} {/each}
</div> </div>
@@ -211,7 +209,7 @@
{#if spotlight} {#if spotlight}
<section> <section>
<div <div
class="grid grid-cols-1 overflow-hidden rounded-panel-lg spotlight-surface theme-transition lg:grid-cols-2" class="grid grid-cols-1 overflow-hidden rounded-panel-lg spotlight-surface lg:grid-cols-2"
> >
<div class="flex flex-col justify-center p-8 sm:p-9"> <div class="flex flex-col justify-center p-8 sm:p-9">
<div <div
@@ -242,10 +240,10 @@
</p> </p>
<div class="mt-5 flex gap-2.5"> <div class="mt-5 flex gap-2.5">
<Button variant="accent" onclick={playSpotlight}>Reproducir</Button> <Button variant="accent" onclick={playSpotlight}>Reproducir</Button>
<Button variant="glass" onclick={shuffleSpotlight}>Aleatorio</Button> <Button variant="secondary" onclick={shuffleSpotlight}>Aleatorio</Button>
</div> </div>
</div> </div>
<div class="flex flex-col gap-0.5 p-5.5 sm:p-6"> <div class="flex flex-col gap-2 p-5.5 sm:p-6">
{#each spotlightRows as { track, seconds }, i (track.id)} {#each spotlightRows as { track, seconds }, i (track.id)}
<ListRow <ListRow
onclick={() => playSpotlightTrack(i)} onclick={() => playSpotlightTrack(i)}
@@ -256,16 +254,16 @@
subtitleHref={track.ownerUserId} subtitleHref={track.ownerUserId}
> >
{#snippet leading()} {#snippet leading()}
<span class="w-5 shrink-0 text-center text-xs text-muted tabular-nums" <span class="w-5 shrink-0 text-center text-xs text-fg-2 tabular-nums"
>{i + 1}</span >{i + 1}</span
> >
{/snippet} {/snippet}
{#snippet trailing()} {#snippet trailing()}
<span class="shrink-0 text-xs text-muted tabular-nums">{fmtTime(seconds)}</span> <span class="shrink-0 text-xs text-fg-2 tabular-nums">{fmtTime(seconds)}</span>
{/snippet} {/snippet}
</ListRow> </ListRow>
{:else} {:else}
<p class="p-2 text-sm text-fg-3">Esta playlist todavía no tiene canciones.</p> <p class="p-2 text-sm text-fg-2">Esta playlist todavía no tiene canciones.</p>
{/each} {/each}
</div> </div>
</div> </div>
@@ -314,16 +312,16 @@
trackId={track.id} trackId={track.id}
> >
{#snippet leading()} {#snippet leading()}
<span class="flex h-3.5 w-4.5 shrink-0 items-end justify-center"> <span class="flex h-3.5 w-icon-md shrink-0 items-end justify-center">
{#if isCurrent} {#if isCurrent}
<EqBars size={13} paused={!player.playing} /> <EqBars size={13} paused={!player.playing} />
{:else} {:else}
<span class="text-xs text-muted tabular-nums">{i + 1}</span> <span class="text-xs text-fg-2 tabular-nums">{i + 1}</span>
{/if} {/if}
</span> </span>
{/snippet} {/snippet}
{#snippet trailing()} {#snippet trailing()}
<span class="hidden shrink-0 text-xs text-muted tabular-nums sm:block"> <span class="hidden shrink-0 text-xs text-fg-2 tabular-nums sm:block">
{fmtPlays(track.listensCount)} {fmtPlays(track.listensCount)}
</span> </span>
{/snippet} {/snippet}
+35 -18
View File
@@ -9,15 +9,13 @@
--color-surface: var(--mf-surface); --color-surface: var(--mf-surface);
--color-surface-2: var(--mf-surface-2); --color-surface-2: var(--mf-surface-2);
--color-surface-hover: var(--mf-surface-hover); --color-surface-hover: var(--mf-surface-hover);
--color-hover: var(--mf-surface-2); --color-hover: var(--mf-row-hover);
--color-line: var(--mf-border); --color-line: var(--mf-border);
--color-line-strong: var(--mf-border-strong); --color-line-strong: var(--mf-border-strong);
--color-hairline: var(--mf-hairline); --color-hairline: var(--mf-hairline);
--color-track: var(--mf-track); --color-track: var(--mf-track);
--color-fg: var(--mf-text); --color-fg: var(--mf-text);
--color-fg-2: var(--mf-text-2); --color-fg-2: var(--mf-text-2);
--color-fg-3: var(--mf-text-3);
--color-muted: var(--mf-text-4);
--color-accent: var(--mf-accent); --color-accent: var(--mf-accent);
--color-accent-soft: var(--mf-accent-title); --color-accent-soft: var(--mf-accent-title);
--color-accent-tint: var(--mf-accent-soft-bg); --color-accent-tint: var(--mf-accent-soft-bg);
@@ -29,15 +27,17 @@
--color-on-art: var(--mf-on-art); --color-on-art: var(--mf-on-art);
--color-on-art-2: var(--mf-on-art-2); --color-on-art-2: var(--mf-on-art-2);
--color-danger: oklch(63.7% 0.15 25.331); --color-danger: oklch(63.7% 0.15 25.331);
--color-danger-fg: oklch(88.5% 0.062 18.334); --color-danger-fg: var(--mf-danger-fg);
--color-btn: var(--mf-btn-bg); --color-btn: var(--mf-btn-bg);
--color-btn-hover: var(--mf-btn-bg-hover); --color-btn-hover: var(--mf-btn-bg-hover);
--color-btn-glass: var(--mf-btn-secondary-bg); --color-cta-strong-hover: var(--mf-cta-strong-hover);
--color-btn-glass-hover: var(--mf-btn-secondary-bg-hover); --color-row-hover: var(--mf-row-hover);
--radius-tag: 0.25rem; --radius-tag: 0.25rem;
--radius-thumb: 0.5rem; --radius-thumb: 0.5rem;
--radius-control: 0.625rem; --radius-control: 0.625rem;
--radius-btn-sm: 0.6875rem;
--radius-btn: 0.75rem;
--radius-art: 0.8125rem; --radius-art: 0.8125rem;
--radius-art-lg: 1.125rem; --radius-art-lg: 1.125rem;
--radius-panel: 1.125rem; --radius-panel: 1.125rem;
@@ -64,6 +64,11 @@
--container-search: 30rem; --container-search: 30rem;
--container-cta: 20rem; --container-cta: 20rem;
--text-control-sm: 0.78125rem;
--ease-snappy: var(--mf-ease);
--default-transition-duration: var(--mf-motion-fast);
--tracking-display: -0.02em; --tracking-display: -0.02em;
--tracking-eyebrow: 0.1em; --tracking-eyebrow: 0.1em;
--leading-body: 1.65; --leading-body: 1.65;
@@ -119,14 +124,16 @@
@apply text-xs tabular-nums; @apply text-xs tabular-nums;
} }
@utility theme-transition { @utility btn-transition {
transition: background 0.5s ease-out; transition-property:
color, background-color, border-color, outline-color, opacity, box-shadow, filter;
transition-timing-function: var(--default-transition-timing-function);
transition-duration: var(--default-transition-duration);
} }
@utility glass-bar { @utility glass-bar {
background-image: var(--mf-bar-bg); background-color: var(--mf-bar-bg);
border-top: 1px solid var(--mf-hairline); border-top: 1px solid var(--mf-hairline);
backdrop-filter: blur(24px); backdrop-filter: blur(24px);
transition: background 0.5s ease-out;
} }
@utility grid-cards { @utility grid-cards {
display: grid; display: grid;
@@ -181,7 +188,7 @@
@utility app-main { @utility app-main {
--mf-dock-h: 0px; --mf-dock-h: 0px;
padding-bottom: calc(var(--mf-nav-h) + var(--mf-safe-b) + var(--mf-dock-h)); padding-bottom: calc(var(--mf-nav-h) + var(--mf-safe-b) + var(--mf-dock-h));
transition: padding 0.3s; transition: padding var(--mf-motion-slow) var(--mf-ease);
&[data-has-track] { &[data-has-track] {
--mf-dock-h: var(--mf-player-h); --mf-dock-h: var(--mf-player-h);
@@ -194,6 +201,12 @@
oklch(var(--mf-tile-shade-l) var(--mf-tile-shade-c) var(--tile-hue)) oklch(var(--mf-tile-shade-l) var(--mf-tile-shade-c) var(--tile-hue))
); );
} }
@utility surface-active {
background-image: var(--mf-g-active);
}
@utility surface-active-hover {
background-image: var(--mf-g-hover);
}
@utility hero-surface { @utility hero-surface {
background-image: var(--mf-hero-bg); background-image: var(--mf-hero-bg);
} }
@@ -208,7 +221,7 @@
); );
} }
@utility field-control { @utility field-control {
@apply w-full rounded-control bg-surface py-3 text-sm text-fg transition placeholder:text-muted focus:bg-surface-2 focus:outline-none; @apply w-full rounded-control bg-surface py-3 text-sm text-fg transition placeholder:text-fg-2 focus:bg-surface-2 focus:outline-none;
} }
@layer base { @layer base {
@@ -255,7 +268,7 @@ button:disabled {
@keyframes pop-in { @keyframes pop-in {
from { from {
opacity: 0; opacity: 0;
transform: scale(0.96) translateY(0.5rem); transform: scale(0.98) translateY(0.25rem);
} }
to { to {
opacity: 1; opacity: 1;
@@ -304,16 +317,16 @@ button:disabled {
} }
.animate-enter { .animate-enter {
animation: fade-up 0.35s cubic-bezier(0.16, 1, 0.3, 1) both; animation: fade-up var(--mf-motion-slow) var(--mf-ease) both;
animation-delay: calc(min(var(--i, 0), 10) * 45ms); animation-delay: calc(min(var(--i, 0), 10) * 40ms);
} }
.animate-pop { .animate-pop {
animation: pop-in 0.3s cubic-bezier(0.16, 1, 0.3, 1) both; animation: pop-in var(--mf-motion-base) var(--mf-ease) both;
} }
.animate-fade { .animate-fade {
animation: fade-in 0.4s ease both; animation: fade-in var(--mf-motion-base) var(--mf-ease) both;
} }
.glass-panel { .glass-panel {
@@ -321,7 +334,11 @@ button:disabled {
background-image: var(--mf-modal-glow); background-image: var(--mf-modal-glow);
backdrop-filter: blur(22px); backdrop-filter: blur(22px);
-webkit-backdrop-filter: blur(22px); -webkit-backdrop-filter: blur(22px);
transition: background 0.6s; transition: background var(--mf-motion-slow) var(--mf-ease);
}
.glass-panel.glass-panel-danger {
background-image: var(--mf-modal-glow-danger);
} }
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {