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

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

Frontend (web-player):
- New design system pass: theme tokens/mode (light/dark), glass menu,
  tabs bar, player dock/extras/queue components, mix tiles, artist link
- New routes: Liked, public user profile (/u/[id]), likes API proxy
- Search refactor: shared SearchBox component used by TopBar and
  MobileHeader (search now reachable on mobile), live results as you
  type instead of requiring Enter, dropped the "Buscando…" quick-result
  preview
- Removed now-unused components (MobileNav, CoverBadge, MixBentoTile,
  PlayPauseOverlay, SuggestionsTable, userDirectory)
- Design handoff reference docs added under web-player/design_handoff_musify_player
  and web-player/docs/design-system-refactor-plan.md
This commit is contained in:
2026-09-14 23:42:45 +02:00
parent 389b3b9244
commit dc1ba89a6d
166 changed files with 14271 additions and 1982 deletions
+367 -8
View File
@@ -194,6 +194,40 @@ export interface paths {
patch?: never;
trace?: never;
};
"/likes": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Get The Current User'S Liked Tracks. */
get: operations["GetLikedTracks"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/likes/toggle": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** Like Or Unlike A Track, By Id Or By External Source, Provisioning It In The Background If Needed. */
post: operations["ToggleTrackLike"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/mixes": {
parameters: {
query?: never;
@@ -556,6 +590,75 @@ export interface paths {
patch?: never;
trace?: never;
};
"/users/{id}/listening-stats": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Get A User'S Listening Stats For The Current Week And Streak. */
get: operations["GetListeningStats"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/users/{id}/profile": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Get A User'S Public Profile, Including Follower Counts. */
get: operations["GetUserProfile"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/users/{id}/is-following": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Check Whether The Current User Follows Another User. */
get: operations["IsFollowingUser"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/users/{id}/follow": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** Follow A User. */
post: operations["FollowUser"];
/** Unfollow A User. */
delete: operations["UnfollowUser"];
options?: never;
head?: never;
patch?: never;
trace?: never;
};
}
export type webhooks = Record<string, never>;
export interface components {
@@ -576,8 +679,7 @@ export interface components {
description: null | string;
/** Format: int32 */
releaseYear: null | number | string;
/** Format: int64 */
ownerUserId: number | string;
ownerUserId: string;
/** Format: int32 */
trackCount: number | string;
smallImageKeyName: null | string;
@@ -600,8 +702,7 @@ export interface components {
description: null | string;
/** Format: int32 */
releaseYear: null | number | string;
/** Format: int64 */
ownerUserId: number | string;
ownerUserId: string;
/** Format: int32 */
trackCount: number | string;
smallImageKeyName: null | string;
@@ -658,6 +759,7 @@ export interface components {
description: null | string;
/** Format: uuid */
pictureIntentId: null | string;
visibility?: components["schemas"]["PlaylistVisibility"];
};
CreateTrackRequest: {
title: string;
@@ -684,6 +786,14 @@ export interface components {
[key: string]: string[];
};
};
ListeningStatsResponse: {
/** Format: int32 */
tracksThisWeek: number | string;
/** Format: int32 */
secondsThisWeek: number | string;
/** Format: int32 */
streakDays: number | string;
};
MixApplicationResponse: {
/** Format: uuid */
id: string;
@@ -772,6 +882,7 @@ export interface components {
smallImageKeyName: null | string;
mediumImageKeyName: null | string;
largeImageKeyName: null | string;
visibility: components["schemas"]["PlaylistVisibility"];
/** Format: date-time */
createdAt: string;
/** Format: date-time */
@@ -790,6 +901,8 @@ export interface components {
uploadUrl: string;
};
/** @enum {unknown} */
PlaylistVisibility: "Private" | "Public";
/** @enum {unknown} */
ProcessingStatus: "Pending" | "Processing" | "Completed" | "Failed";
RequestAlbumPictureUploadRequest: {
fileType: string;
@@ -813,6 +926,12 @@ export interface components {
/** Format: int64 */
expectedAudioSizeBytes?: null | number | string;
};
ToggleLikeRequest: {
/** Format: uuid */
trackId: null | string;
source: null | components["schemas"]["TrackSource"];
externalId: null | string;
};
TrackApplicationResponse: components["schemas"]["TrackApplicationResponseLocalTrackApplicationResponse"] | components["schemas"]["TrackApplicationResponseExternalTrackApplicationResponse"];
TrackApplicationResponseExternalTrackApplicationResponse: {
/** @enum {string} */
@@ -835,8 +954,7 @@ export interface components {
TrackApplicationResponseLocalTrackApplicationResponse: {
/** @enum {string} */
source?: "Local";
/** Format: int64 */
ownerUserId: number | string;
ownerUserId: string;
/** Format: uuid */
id: string;
title: string;
@@ -910,10 +1028,10 @@ export interface components {
newDescription: null | string;
/** Format: uuid */
newPictureIntentId: null | string;
newVisibility?: null | components["schemas"]["PlaylistVisibility"];
};
UserApplicationResponse: {
/** Format: int64 */
id: number | string;
id: string;
name: string;
firstName: null | string;
secondName: null | string;
@@ -923,6 +1041,19 @@ export interface components {
/** Format: date-time */
updatedAt: string;
};
UserProfileResponse: {
id: string;
name: string;
firstName: null | string;
secondName: null | string;
profilePictureUrl: null | string;
/** Format: date-time */
createdAt: string;
/** Format: int32 */
followersCount: number | string;
/** Format: int32 */
followingCount: number | string;
};
YouTubeAlbumDetail: {
album: components["schemas"]["YouTubeAlbumResult"];
description: string;
@@ -1465,6 +1596,83 @@ export interface operations {
};
};
};
GetLikedTracks: {
parameters: {
query?: {
pageNumber?: number | string;
pageSize?: number | string;
};
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["PaginatedResponseOfTrackApplicationResponse"];
};
};
/** @description Unauthorized */
401: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
ToggleTrackLike: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["ToggleLikeRequest"];
};
};
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": boolean;
};
};
/** @description Bad Request */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["HttpValidationProblemDetails"];
};
};
/** @description Unauthorized */
401: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description Not Found */
404: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
GetMixes: {
parameters: {
query?: never;
@@ -1633,6 +1841,7 @@ export interface operations {
name?: string;
pageNumber?: number | string;
pageSize?: number | string;
onlyPublic?: boolean;
};
header?: never;
path: {
@@ -2395,4 +2604,154 @@ export interface operations {
};
};
};
GetListeningStats: {
parameters: {
query?: never;
header?: never;
path: {
id: number | string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ListeningStatsResponse"];
};
};
};
};
GetUserProfile: {
parameters: {
query?: never;
header?: never;
path: {
id: number | string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["UserProfileResponse"];
};
};
/** @description Not Found */
404: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
IsFollowingUser: {
parameters: {
query?: never;
header?: never;
path: {
id: number | string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": boolean;
};
};
/** @description Unauthorized */
401: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
FollowUser: {
parameters: {
query?: never;
header?: never;
path: {
id: number | string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description No Content */
204: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description Bad Request */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["HttpValidationProblemDetails"];
};
};
/** @description Unauthorized */
401: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description Not Found */
404: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
UnfollowUser: {
parameters: {
query?: never;
header?: never;
path: {
id: number | string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description No Content */
204: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description Unauthorized */
401: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
}
@@ -28,6 +28,7 @@
import ListPlus from '@lucide/svelte/icons/list-plus';
import ListEnd from '@lucide/svelte/icons/list-end';
import PlaylistArt from '$lib/components/ui/PlaylistArt.svelte';
import GlassMenu from '$lib/components/ui/GlassMenu.svelte';
interface Props {
menu: AlbumMenuState;
@@ -55,32 +56,29 @@
onClose();
}}
></div>
<div
class="fixed z-40 w-60 rounded-panel border border-line bg-elevated p-1.5 shadow-menu"
style="left:{menu.x}px; top:{menu.y}px;"
>
<GlassMenu class="fixed z-40 w-60" style="left:{menu.x}px; top:{menu.y}px;">
<button
type="button"
onclick={onPlayNext}
class="flex w-full items-center gap-3 rounded-control px-2 py-2.5 text-left text-sm text-fg-2 transition hover:bg-hover"
class="flex w-full items-center gap-3 rounded-control px-2 py-2.5 text-left text-[13.5px] font-medium text-fg-2 transition hover:bg-hover"
>
<ListPlus class="h-5 w-5 shrink-0" strokeWidth={2} />
<span class="text-base">Reproducir a continuación</span>
<ListPlus class="h-[18px] w-[18px] shrink-0" strokeWidth={2} />
<span>Reproducir a continuación</span>
</button>
<button
type="button"
onclick={onAddToQueue}
class="flex w-full items-center gap-3 rounded-control px-2 py-2.5 text-left text-sm text-fg-2 transition hover:bg-hover"
class="flex w-full items-center gap-3 rounded-control px-2 py-2.5 text-left text-[13.5px] font-medium text-fg-2 transition hover:bg-hover"
>
<ListEnd class="h-5 w-5 shrink-0" strokeWidth={2} />
<span class="text-base">Añadir a la cola</span>
<ListEnd class="h-[18px] w-[18px] shrink-0" strokeWidth={2} />
<span>Añadir a la cola</span>
</button>
{#if playlists.length > 0}
<div class="group/addmenu relative">
<div
class="flex items-center gap-3 rounded-control px-2 py-2.5 text-sm text-fg-2 transition group-hover/addmenu:bg-hover"
class="flex items-center gap-3 rounded-control px-2 py-2.5 text-[13.5px] font-medium text-fg-2 transition group-hover/addmenu:bg-hover"
>
<span class="flex-1 cursor-default text-base">Añadir álbum a una playlist</span>
<span class="flex-1 cursor-default">Añadir álbum a una playlist</span>
<ChevronRight class="h-4 w-4 shrink-0 text-fg-3" />
</div>
<div
@@ -108,7 +106,7 @@
<input type="hidden" name="albumId" value={menu.albumId} />
<button
type="submit"
class="flex w-full items-center gap-3 rounded-control px-2 py-2.5 text-left text-base text-fg transition hover:bg-hover"
class="flex w-full items-center gap-3 rounded-control px-2 py-2.5 text-left text-[13.5px] font-medium text-fg transition hover:bg-hover"
>
<PlaylistArt
playlistId={playlist.id}
@@ -127,4 +125,4 @@
{:else}
<p class="px-2 py-2.5 text-sm text-fg-3">No tienes playlists todavía.</p>
{/if}
</div>
</GlassMenu>
@@ -4,13 +4,21 @@
import Folder from '@lucide/svelte/icons/folder';
import LogOut from '@lucide/svelte/icons/log-out';
import LogIn from '@lucide/svelte/icons/log-in';
import User from '@lucide/svelte/icons/user';
import Settings from '@lucide/svelte/icons/settings';
import Sun from '@lucide/svelte/icons/sun';
import Moon from '@lucide/svelte/icons/moon';
import MenuItem from './ui/MenuItem.svelte';
import GlassMenu from './ui/GlassMenu.svelte';
import SearchBox from './SearchBox.svelte';
import { themeMode } from '$lib/theme/mode.svelte';
interface Props {
user: SessionUser | null;
accountUrl?: string | null;
}
let { user }: Props = $props();
let { user, accountUrl }: Props = $props();
let menuOpen = $state(false);
let menuRef: HTMLDivElement | undefined = $state();
@@ -21,61 +29,92 @@
</script>
<header
class="sticky top-0 z-40 flex h-14 items-center justify-between border-b border-line bg-bg/85 page-x backdrop-blur-md md:hidden"
class="sticky top-0 z-40 flex flex-col gap-2.5 border-b border-hairline bg-bg/85 page-x pt-3 pb-3 backdrop-blur-md sm:hidden"
>
<a href="/" class="font-display text-lg font-semibold tracking-tight text-fg">Musify</a>
<div class="flex h-9 items-center justify-between">
<a
href="/"
class="flex items-center gap-2 font-display text-[17.5px] font-semibold tracking-[-0.02em] text-fg"
>
<span
class="h-5.5 w-5.5 rounded-[7px] bg-[image:var(--mf-logo-grad)] shadow-[var(--mf-logo-glow)]"
></span>
Musify
</a>
<div class="relative" bind:this={menuRef}>
{#if user}
<button
type="button"
onclick={() => (menuOpen = !menuOpen)}
aria-label="Tu cuenta"
aria-expanded={menuOpen}
class="flex items-center rounded-full"
>
{#if user.picture}
<img src={user.picture} alt="" class="h-9 w-9 rounded-full object-cover" />
{:else}
<span class="grid h-9 w-9 place-items-center rounded-full bg-surface-2 text-sm uppercase">
{user.name.charAt(0)}
</span>
{/if}
</button>
{#if menuOpen}
<div
class="animate-pop absolute top-full right-0 mt-2 w-56 overflow-hidden rounded-panel border border-line bg-elevated p-1.5 shadow-menu"
<div class="relative" bind:this={menuRef}>
{#if user}
<button
type="button"
onclick={() => (menuOpen = !menuOpen)}
aria-label="Tu cuenta"
aria-expanded={menuOpen}
class="flex items-center rounded-full"
>
<MenuItem
icon={Folder}
label="Canciones subidas"
href="/library"
onclick={() => (menuOpen = false)}
/>
<MenuItem
icon={Upload}
label="Subir música"
href="/upload"
onclick={() => (menuOpen = false)}
/>
<div class="-mx-1.5 my-1.5 border-t border-line"></div>
<form method="POST" action="/logout" data-sveltekit-reload>
<MenuItem icon={LogOut} label="Salir" type="submit" />
</form>
</div>
{#if user.picture}
<img src={user.picture} alt="" class="h-9 w-9 rounded-full object-cover" />
{:else}
<span
class="grid h-9 w-9 place-items-center rounded-full bg-surface-2 text-sm uppercase"
>
{user.name.charAt(0)}
</span>
{/if}
</button>
{#if menuOpen}
<GlassMenu class="absolute top-full right-0 mt-2 w-56 overflow-hidden">
<MenuItem
icon={User}
label="Ver perfil"
href="/u/{user.sub}"
onclick={() => (menuOpen = false)}
/>
<MenuItem
icon={Folder}
label="Canciones subidas"
href="/library"
onclick={() => (menuOpen = false)}
/>
<MenuItem
icon={Upload}
label="Subir música"
href="/upload"
onclick={() => (menuOpen = false)}
/>
{#if accountUrl}
<MenuItem
icon={Settings}
label="Ajustes"
href={accountUrl}
target="_blank"
onclick={() => (menuOpen = false)}
/>
{/if}
<MenuItem
icon={themeMode.current === 'dark' ? Sun : Moon}
label={themeMode.current === 'dark' ? 'Modo blanco' : 'Modo oscuro'}
onclick={() => themeMode.toggle()}
/>
<form method="POST" action="/logout" data-sveltekit-reload>
<MenuItem icon={LogOut} label="Cerrar sesión" type="submit" />
</form>
</GlassMenu>
{/if}
{:else}
<a
href="/auth/login"
data-sveltekit-reload
aria-label="Iniciar sesión"
class="flex items-center rounded-full p-1.5 text-fg-3 transition hover:bg-hover hover:text-fg"
>
<LogIn class="h-6 w-6" strokeWidth={2} />
</a>
{/if}
{:else}
<a
href="/auth/login"
data-sveltekit-reload
aria-label="Iniciar sesión"
class="flex items-center rounded-full p-1.5 text-fg-3 transition hover:bg-hover hover:text-fg"
>
<LogIn class="h-6 w-6" strokeWidth={2} />
</a>
{/if}
</div>
</div>
<SearchBox class="w-full" />
</header>
<svelte:window onclick={onDocumentClick} />
@@ -1,53 +0,0 @@
<script lang="ts">
import { page } from '$app/state';
import { isSectionActive } from '$lib/navigation.svelte';
import type { SessionUser } from '$lib/types';
import Home from '@lucide/svelte/icons/house';
import Search from '@lucide/svelte/icons/search';
import Music from '@lucide/svelte/icons/music';
import Folder from '@lucide/svelte/icons/folder';
interface Props {
user: SessionUser | null;
}
let { user }: Props = $props();
const links = $derived([
{ href: '/', label: 'Inicio', icon: Home },
{ href: '/explore', label: 'Buscar', icon: Search },
...(user
? [
{ href: '/playlists', label: 'Listas', icon: Music },
{ href: '/library', label: 'Subidas', icon: Folder }
]
: [])
]);
function isActive(href: string) {
return isSectionActive(href, page.url.pathname, page.data.section);
}
</script>
<nav
class="fixed inset-x-0 bottom-0 z-50 border-t border-line bg-bg/95 backdrop-blur-md md:hidden"
style="padding-bottom:var(--mf-safe-b)"
>
<ul class="grid h-[var(--mf-nav-h)]" style="grid-template-columns:repeat({links.length}, 1fr)">
{#each links as link (link.href)}
{@const active = isActive(link.href)}
<li>
<a
href={link.href}
aria-current={active ? 'page' : undefined}
class="flex h-full flex-col items-center justify-center gap-1 transition-colors {active
? 'text-fg'
: 'text-fg-3'}"
>
<link.icon class="h-[22px] w-[22px] {active ? 'text-accent-soft' : ''}" />
<span class="text-[11px] leading-none font-medium">{link.label}</span>
</a>
</li>
{/each}
</ul>
</nav>
@@ -0,0 +1,86 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/state';
import { SEARCH_DEBOUNCE_MS, SEARCH_MIN_LENGTH } from '$lib/config';
import Search from '@lucide/svelte/icons/search';
import X from '@lucide/svelte/icons/x';
interface Props {
class?: string;
placeholder?: string;
}
let { class: klass = '', placeholder = 'Canciones, artistas, álbumes o playlists' }: Props =
$props();
let query = $state('');
let searchInput: HTMLInputElement | undefined = $state();
let searchTimeout: ReturnType<typeof setTimeout> | undefined;
function navigateSearch() {
const term = query.trim();
goto(term ? `/explore?q=${encodeURIComponent(term)}` : '/explore', {
replaceState: page.url.pathname === '/explore',
keepFocus: true,
noScroll: true
});
}
function onInput() {
clearTimeout(searchTimeout);
const term = query.trim();
const onExplore = page.url.pathname === '/explore';
if (term.length > 0 && term.length < SEARCH_MIN_LENGTH) return;
if (term.length === 0 && !onExplore) return;
searchTimeout = setTimeout(navigateSearch, SEARCH_DEBOUNCE_MS);
}
function clearQuery() {
clearTimeout(searchTimeout);
query = '';
if (page.url.pathname === '/explore') navigateSearch();
searchInput?.focus();
}
function onSubmit(event: SubmitEvent) {
event.preventDefault();
clearTimeout(searchTimeout);
navigateSearch();
}
function onKeydown(event: KeyboardEvent) {
if (event.key !== 'Enter') return;
event.preventDefault();
clearTimeout(searchTimeout);
navigateSearch();
}
</script>
<div class="relative min-w-0 {klass}">
<form onsubmit={onSubmit}>
<label
class="flex h-9.5 w-full items-center gap-2.5 rounded-[11px] bg-white/[3.5%] px-3.5 transition focus-within:bg-white/[6%]"
>
<Search class="h-[15px] w-[15px] shrink-0 text-fg-3" strokeWidth={1.8} />
<input
type="text"
bind:this={searchInput}
bind:value={query}
oninput={onInput}
onkeydown={onKeydown}
{placeholder}
class="w-full min-w-0 bg-transparent text-[13px] text-fg placeholder:text-muted focus:outline-none"
/>
{#if query}
<button
type="button"
onclick={clearQuery}
aria-label="Borrar búsqueda"
class="grid h-5 w-5 shrink-0 place-items-center rounded-full text-muted transition hover:text-fg"
>
<X class="h-3.5 w-3.5" />
</button>
{/if}
</label>
</form>
</div>
+96 -84
View File
@@ -1,117 +1,129 @@
<script lang="ts">
import { page } from '$app/state';
import { isSectionActive } from '$lib/navigation.svelte';
import { liked } from '$lib/player/liked.svelte';
import { createPlaylistModal } from '$lib/playlists.svelte';
import type { SessionUser } from '$lib/types';
import PlaylistArt from './ui/PlaylistArt.svelte';
import Home from '@lucide/svelte/icons/house';
import Folder from '@lucide/svelte/icons/folder';
import Compass from '@lucide/svelte/icons/compass';
import ListMusic from '@lucide/svelte/icons/list-music';
import Heart from '@lucide/svelte/icons/heart';
import Disc from '@lucide/svelte/icons/disc-2';
import Music from '@lucide/svelte/icons/music';
import Folder from '@lucide/svelte/icons/folder';
import Upload from '@lucide/svelte/icons/upload';
import Plus from '@lucide/svelte/icons/plus';
interface SidebarPlaylist {
id: string;
name: string;
coverTrackIds: (string | number)[];
updatedAt: string;
}
interface Props {
user: SessionUser | null;
playlists: SidebarPlaylist[];
}
let { user }: Props = $props();
let { user, playlists }: Props = $props();
let navBox: HTMLElement | undefined = $state();
let pillTop = $state(0);
let pillHeight = $state(0);
let pillVisible = $state(false);
let animate = $state(false);
const mainLinks = $derived([
{ href: '/', label: 'Inicio', icon: Home },
...(user ? [{ href: '/playlists', label: 'Listas', icon: Music }] : [])
]);
const secondaryLinks = $derived(
const navLinks = $derived(
user
? [
{ href: '/', label: 'Inicio', icon: Home },
{ href: '/explore', label: 'Descubrir', icon: Compass },
{ href: '/playlists', label: 'Playlists', icon: ListMusic },
{ href: '/liked', label: 'Me gusta', icon: Heart, count: liked.count },
{ href: '/albums', label: 'Mis álbumes', icon: Disc },
{ href: '/library', label: 'Canciones subidas', icon: Folder },
{ href: '/upload', label: 'Subir música', icon: Upload }
]
: []
: [{ href: '/explore', label: 'Descubrir', icon: Compass }]
);
function isActive(href: string) {
return isSectionActive(href, page.url.pathname, page.data.section);
}
$effect(() => {
void page.url.pathname;
const box = navBox;
if (!box) return;
const active = box.querySelector<HTMLElement>('[data-active="true"]');
if (!active) {
pillVisible = false;
return;
}
const boxRect = box.getBoundingClientRect();
const rect = active.getBoundingClientRect();
pillTop = rect.top - boxRect.top;
pillHeight = rect.height;
pillVisible = true;
if (!animate) requestAnimationFrame(() => (animate = true));
});
</script>
{#snippet navLink(link: { href: string; label: string; icon: typeof Home })}
{@const active = isActive(link.href)}
<a
href={link.href}
data-active={active}
class="relative flex items-center gap-3.5 rounded-control px-4 py-2.5 text-sm transition-colors duration-100 {active
? 'text-fg'
: 'text-fg-2 hover:bg-hover hover:text-fg'}"
>
<link.icon class="h-5 w-5 shrink-0 {active ? 'text-accent-soft' : ''}" />
{link.label}
</a>
{/snippet}
{#snippet groupLabel(label: string)}
<p class="mb-4 px-4 text-sm font-semibold tracking-[0.12em] text-fg-2 uppercase">
{label}
</p>
{/snippet}
<aside
class="sticky top-0 hidden h-screen shrink-0 flex-col border-r border-line bg-bg md:flex"
class="hidden shrink-0 flex-col border-r border-hairline bg-[image:var(--mf-sidebar-bg)] pt-5.5 pr-3 pl-5 transition-[background] duration-[600ms] ease-out lg:flex"
style="width:var(--mf-sidebar-w)"
>
<div
class="relative flex flex-1 flex-col gap-6 overflow-y-auto px-4.5 py-6 md:pt-8"
bind:this={navBox}
>
<div
class="pointer-events-none absolute inset-x-4.5 z-0 overflow-hidden rounded-control border border-line bg-surface {animate
? 'transition-[top,height] duration-200 ease-in-out'
: ''}"
style="top:{pillTop}px; height:{pillHeight}px; opacity:{pillVisible ? 1 : 0}"
>
<span class="absolute inset-y-0 left-0 w-0.75 rounded-r-full bg-accent-soft"></span>
<a href="/" class="flex items-center gap-2.5 px-1.5 pb-6.5">
<span class="font-display text-[17.5px] font-semibold tracking-[-0.02em] text-fg">Musify</span>
</a>
<nav class="flex flex-col gap-0.5">
{#each navLinks as link (link.href)}
{@const active = isActive(link.href)}
<a
href={link.href}
aria-current={active ? 'page' : undefined}
class="flex items-center gap-3 rounded-[10px] px-3 py-2.25 text-[13.5px] font-medium transition-colors duration-150 {active
? 'bg-surface-hover text-fg'
: 'text-fg-2 hover:bg-hover hover:text-fg'}"
>
<link.icon class="h-[17px] w-[17px] shrink-0" strokeWidth={1.6} />
<span class="flex-1 truncate">{link.label}</span>
{#if link.count}
<span class="text-[11px] text-muted tabular-nums">{link.count}</span>
{/if}
</a>
{/each}
</nav>
{#if user}
<div class="mx-3 mt-6 mb-3.5 h-px bg-white/5"></div>
<div class="flex items-center justify-between px-3 pb-1.5">
<span class="text-[10.5px] font-semibold tracking-[0.13em] text-muted uppercase"
>Tus playlists</span
>
{#if playlists.length > 0}
<button
type="button"
onclick={() => createPlaylistModal.show()}
aria-label="Crear playlist"
class="text-base text-muted transition-colors hover:text-fg"
>
+
</button>
{/if}
</div>
<div>
{@render groupLabel('Navegación')}
<nav class="relative z-10 flex flex-col gap-2">
{#each mainLinks as link (link.href)}
{@render navLink(link)}
{/each}
</nav>
<div class="flex flex-1 flex-col gap-px overflow-y-auto pb-4.5">
{#each playlists as playlist (playlist.id)}
{@const active = page.url.pathname === `/playlists/${playlist.id}`}
<a
href="/playlists/{playlist.id}"
class="flex items-center gap-2.75 rounded-[10px] px-3 py-1.75 transition-colors duration-150 {active
? 'bg-surface-hover'
: 'hover:bg-hover'}"
>
<PlaylistArt
playlistId={playlist.id}
trackIds={playlist.coverTrackIds}
version={playlist.updatedAt}
size="small"
class="h-8.5 w-8.5 shrink-0 overflow-hidden rounded-lg opacity-90"
/>
<div class="min-w-0 flex-1">
<div class="truncate text-[13.5px] font-medium {active ? 'text-fg' : 'text-fg-2'}">
{playlist.name}
</div>
</div>
</a>
{:else}
<button
type="button"
onclick={() => createPlaylistModal.show()}
class="flex items-center gap-2 rounded-[10px] px-3 py-2 text-[12px] text-muted transition-colors hover:bg-hover hover:text-fg-2"
>
<Plus class="h-3.5 w-3.5" strokeWidth={1.8} />
Crear tu primera lista
</button>
{/each}
</div>
{#if secondaryLinks.length > 0}
<div>
{@render groupLabel('Tu música')}
<nav class="relative z-10 flex flex-col gap-1">
{#each secondaryLinks as link (link.href)}
{@render navLink(link)}
{/each}
</nav>
</div>
{/if}
</div>
{/if}
</aside>
@@ -0,0 +1,50 @@
<script lang="ts">
import { page } from '$app/state';
import { isSectionActive } from '$lib/navigation.svelte';
import type { SessionUser } from '$lib/types';
import Home from '@lucide/svelte/icons/house';
import Compass from '@lucide/svelte/icons/compass';
import ListMusic from '@lucide/svelte/icons/list-music';
import Heart from '@lucide/svelte/icons/heart';
interface Props {
user: SessionUser | null;
class?: string;
}
let { user, class: klass = '' }: Props = $props();
const links = $derived(
user
? [
{ href: '/', label: 'Inicio', icon: Home },
{ href: '/explore', label: 'Descubrir', icon: Compass },
{ href: '/playlists', label: 'Playlists', icon: ListMusic },
{ href: '/liked', label: 'Me gusta', icon: Heart }
]
: [{ href: '/explore', label: 'Descubrir', icon: Compass }]
);
function isActive(href: string) {
return isSectionActive(href, page.url.pathname, page.data.section);
}
</script>
<nav
class="pointer-events-auto flex items-center justify-around rounded-[18px] border border-hairline bg-[image:var(--mf-bar-bg)] p-1.75 backdrop-blur-[22px] transition-[background] duration-[600ms] lg:hidden {klass}"
style="padding-bottom:calc(0.4375rem + var(--mf-safe-b))"
>
{#each links as link (link.href)}
{@const active = isActive(link.href)}
<a
href={link.href}
aria-current={active ? 'page' : undefined}
class="flex flex-col items-center gap-1.25 rounded-xl px-2.5 py-1.5 {active
? 'text-fg'
: 'text-fg-2'}"
>
<link.icon class="h-[17px] w-[17px]" strokeWidth={1.6} />
<span class="text-[10px] font-medium">{link.label}</span>
</a>
{/each}
</nav>
+75 -191
View File
@@ -1,220 +1,104 @@
<script lang="ts">
import { goto } from '$app/navigation';
import type { SessionUser, YouTubeSong } from '$lib/types';
import { player } from '$lib/player/player.svelte';
import {
queueItemForTarget,
targetArtist,
targetCoverSrc,
targetExplicit,
targetId,
targetTitle,
type TrackTarget
} from '$lib/tracks';
import { SEARCH_DEBOUNCE_MS, SEARCH_MIN_LENGTH } from '$lib/config';
import Search from '@lucide/svelte/icons/search';
import X from '@lucide/svelte/icons/x';
import type { SessionUser } from '$lib/types';
import ChevronLeft from '@lucide/svelte/icons/chevron-left';
import ChevronRight from '@lucide/svelte/icons/chevron-right';
import LogOut from '@lucide/svelte/icons/log-out';
import User from '@lucide/svelte/icons/user';
import Settings from '@lucide/svelte/icons/settings';
import Sun from '@lucide/svelte/icons/sun';
import Moon from '@lucide/svelte/icons/moon';
import MenuItem from './ui/MenuItem.svelte';
import Cover from './ui/Cover.svelte';
import ExplicitBadge from './ui/ExplicitBadge.svelte';
import GlassMenu from './ui/GlassMenu.svelte';
import SearchBox from './SearchBox.svelte';
import { themeMode } from '$lib/theme/mode.svelte';
interface Props {
user: SessionUser | null;
accountUrl?: string | null;
}
const QUICK_RESULTS_LIMIT = 5;
let { user, accountUrl }: Props = $props();
interface QuickSearchTrack {
id: string;
title: string;
artist?: string | null;
isExplicit?: boolean;
}
interface QuickSearchResponse {
items: { track?: QuickSearchTrack | null; youTubeSong?: YouTubeSong | null }[];
}
let { user }: Props = $props();
let query = $state('');
let results = $state<TrackTarget[]>([]);
let resultsOpen = $state(false);
let searching = $state(false);
let menuOpen = $state(false);
let menuRef: HTMLDivElement | undefined = $state();
let searchRef: HTMLDivElement | undefined = $state();
let searchInput: HTMLInputElement | undefined = $state();
let searchTimeout: ReturnType<typeof setTimeout> | undefined;
let searchToken = 0;
function clearQuery() {
query = '';
results = [];
resultsOpen = false;
searchInput?.focus();
}
function onInput() {
clearTimeout(searchTimeout);
const term = query.trim();
if (term.length < SEARCH_MIN_LENGTH) {
results = [];
resultsOpen = false;
return;
}
resultsOpen = true;
searchTimeout = setTimeout(() => runQuickSearch(term), SEARCH_DEBOUNCE_MS);
}
async function runQuickSearch(term: string) {
const token = ++searchToken;
searching = true;
try {
const params = new URLSearchParams({ name: term, pageSize: String(QUICK_RESULTS_LIMIT) });
const res = await fetch(`/api/tracks?${params}`);
if (!res.ok) throw new Error(String(res.status));
const data = (await res.json()) as QuickSearchResponse;
if (token !== searchToken) return;
results = data.items
.flatMap((item): TrackTarget[] => {
if (item.track) return [{ kind: 'local', track: item.track }];
if (item.youTubeSong) return [{ kind: 'youtube', song: item.youTubeSong }];
return [];
})
.slice(0, QUICK_RESULTS_LIMIT);
} catch {
if (token === searchToken) results = [];
} finally {
if (token === searchToken) searching = false;
}
}
function playResult(index: number) {
player.playOrToggle(results.map(queueItemForTarget), index);
resultsOpen = false;
}
function onSubmit(event: SubmitEvent) {
event.preventDefault();
clearTimeout(searchTimeout);
resultsOpen = false;
const q = query.trim();
goto(q ? `/explore?q=${encodeURIComponent(q)}` : '/explore');
}
function onDocumentClick(event: MouseEvent) {
if (menuRef && !menuRef.contains(event.target as Node)) menuOpen = false;
if (searchRef && !searchRef.contains(event.target as Node)) resultsOpen = false;
}
</script>
<header
class="sticky top-0 z-30 hidden grid-cols-[1fr_minmax(0,3fr)_1fr] items-center gap-4 border-b border-line bg-bg/70 px-8 py-4 backdrop-blur-lg md:grid"
>
<div></div>
<div class="relative mx-auto w-full max-w-2xl" bind:this={searchRef}>
<form onsubmit={onSubmit}>
<label
class="flex w-full items-center gap-2.5 rounded-full border border-line bg-surface px-4 py-2.5 transition focus-within:border-accent-soft/60"
>
<Search class="h-4 w-4 shrink-0 text-fg-2" />
<input
type="text"
bind:this={searchInput}
bind:value={query}
oninput={onInput}
onfocus={() => (resultsOpen = results.length > 0)}
placeholder="Buscar"
class="w-full bg-transparent text-sm text-fg placeholder:text-fg-2 focus:outline-none"
/>
{#if query}
<button
type="button"
onclick={clearQuery}
aria-label="Borrar búsqueda"
class="grid h-5 w-5 shrink-0 place-items-center rounded-full text-fg-2 transition hover:text-fg"
>
<X class="h-3.5 w-3.5" />
</button>
{/if}
</label>
</form>
{#if resultsOpen && (results.length > 0 || searching)}
<div
class="animate-pop absolute top-full right-0 left-0 z-20 mt-2 overflow-hidden rounded-panel border border-line bg-elevated p-1.5 shadow-menu"
>
{#if searching && results.length === 0}
<p class="px-3 py-2.5 text-sm text-fg-2">Buscando…</p>
{:else}
{#each results as target, i (targetId(target))}
<button
type="button"
onclick={() => playResult(i)}
class="group/result flex w-full min-w-0 items-center gap-3 rounded-control p-2 text-left transition hover:bg-hover"
>
<Cover
trackId={targetId(target)}
src={targetCoverSrc(target)}
size="small"
alt={targetTitle(target)}
class="h-9 w-9 shrink-0 rounded-control shadow-art"
/>
<div class="min-w-0 flex-1">
<div class="flex min-w-0 items-center gap-1.5">
{#if targetExplicit(target)}
<ExplicitBadge />
{/if}
<span class="truncate text-sm text-fg">{targetTitle(target)}</span>
</div>
{#if targetArtist(target)}
<div class="truncate text-xs text-fg-2">{targetArtist(target)}</div>
{/if}
</div>
</button>
{/each}
{/if}
</div>
{/if}
<header class="hidden items-center gap-3.5 px-6.5 pt-4 pb-3 sm:flex">
<div class="flex shrink-0 gap-1.5">
<button
type="button"
onclick={() => history.back()}
aria-label="Atrás"
class="grid h-7.5 w-7.5 place-items-center rounded-full bg-white/[3.5%] text-fg-2 transition-colors hover:bg-white/7 hover:text-fg"
>
<ChevronLeft class="h-[15px] w-[15px]" strokeWidth={1.9} />
</button>
<span
aria-hidden="true"
class="grid h-7.5 w-7.5 place-items-center rounded-full bg-white/[3.5%] text-muted"
>
<ChevronRight class="h-[15px] w-[15px]" strokeWidth={1.9} />
</span>
</div>
<div class="flex items-center justify-end gap-6">
<SearchBox class="max-w-[480px] flex-1" />
<div class="flex-1"></div>
<div class="relative shrink-0" bind:this={menuRef}>
{#if user}
<div class="relative" bind:this={menuRef}>
<button
type="button"
onclick={() => (menuOpen = !menuOpen)}
aria-label="Tu cuenta"
class="block shrink-0 rounded-full transition active:scale-95"
>
{#if user.picture}
<img src={user.picture} alt="" class="h-9 w-9 rounded-full object-cover" />
{:else}
<span
class="grid h-9 w-9 place-items-center rounded-full bg-surface-2 text-sm font-semibold text-fg uppercase"
>
{user.name.charAt(0)}
</span>
{/if}
</button>
{#if menuOpen}
<div
class="animate-pop absolute top-full right-0 z-20 mt-2 w-40 overflow-hidden rounded-panel border border-line bg-elevated p-1.5 shadow-menu"
<button
type="button"
onclick={() => (menuOpen = !menuOpen)}
aria-label="Tu cuenta"
class="block h-8.25 w-8.25 shrink-0 overflow-hidden rounded-full border border-white/10 transition active:scale-95"
>
{#if user.picture}
<img src={user.picture} alt="" class="h-full w-full object-cover opacity-90" />
{:else}
<span
class="grid h-full w-full place-items-center bg-surface-2 text-xs font-semibold text-fg uppercase"
>
<form method="POST" action="/logout" data-sveltekit-reload>
<MenuItem icon={LogOut} label="Salir" type="submit" />
</form>
</div>
{user.name.charAt(0)}
</span>
{/if}
</div>
</button>
{#if menuOpen}
<GlassMenu class="absolute top-full right-0 z-20 mt-2 w-52 overflow-hidden">
<MenuItem
icon={User}
label="Ver perfil"
href="/u/{user.sub}"
onclick={() => (menuOpen = false)}
/>
{#if accountUrl}
<MenuItem
icon={Settings}
label="Ajustes"
href={accountUrl}
target="_blank"
onclick={() => (menuOpen = false)}
/>
{/if}
<MenuItem
icon={themeMode.current === 'dark' ? Sun : Moon}
label={themeMode.current === 'dark' ? 'Modo blanco' : 'Modo oscuro'}
onclick={() => themeMode.toggle()}
/>
<form method="POST" action="/logout" data-sveltekit-reload>
<MenuItem icon={LogOut} label="Cerrar sesión" type="submit" />
</form>
</GlassMenu>
{/if}
{:else}
<a
href="/auth/login"
data-sveltekit-reload
class="shrink-0 rounded-full bg-accent-soft px-4 py-2 text-sm font-semibold text-on-accent transition hover:brightness-110"
class="shrink-0 rounded-full bg-accent-soft px-4 py-2 text-sm font-semibold text-ink transition hover:brightness-110"
>
Iniciar sesión
</a>
@@ -22,6 +22,7 @@
import ListPlus from '@lucide/svelte/icons/list-plus';
import ChevronRight from '@lucide/svelte/icons/chevron-right';
import PlaylistArt from '$lib/components/ui/PlaylistArt.svelte';
import GlassMenu from '$lib/components/ui/GlassMenu.svelte';
interface Props {
menu: ContextMenuState;
@@ -48,24 +49,21 @@
onClose();
}}
></div>
<div
class="fixed z-40 w-60 rounded-panel border border-line bg-elevated p-1.5 shadow-menu"
style="left:{menu.x}px; top:{menu.y}px;"
>
<GlassMenu class="fixed z-40 w-60" style="left:{menu.x}px; top:{menu.y}px;">
<button
type="button"
onclick={onAddToQueue}
class="flex w-full items-center gap-3 rounded-control px-2 py-2.5 text-left text-sm text-fg-2 transition hover:bg-hover"
class="flex w-full items-center gap-3 rounded-control px-2 py-2.5 text-left text-[13.5px] font-medium text-fg-2 transition hover:bg-hover"
>
<ListPlus class="h-5 w-5 shrink-0" strokeWidth={2} />
<span class="text-base">Reproducir a continuación</span>
<ListPlus class="h-[18px] w-[18px] shrink-0" strokeWidth={2} />
<span>Reproducir a continuación</span>
</button>
{#if playlists.length > 0}
<div class="group/addmenu relative">
<div
class="flex items-center gap-3 rounded-control px-2 py-2.5 text-sm text-fg-2 transition group-hover/addmenu:bg-hover"
class="flex items-center gap-3 rounded-control px-2 py-2.5 text-[13.5px] font-medium text-fg-2 transition group-hover/addmenu:bg-hover"
>
<span class="flex-1 cursor-default text-base">Añadir a una playlist</span>
<span class="flex-1 cursor-default">Añadir a una playlist</span>
<ChevronRight class="h-4 w-4 shrink-0 text-fg-3" />
</div>
<div
@@ -99,7 +97,7 @@
{/if}
<button
type="submit"
class="flex w-full items-center gap-3 rounded-control px-2 py-2.5 text-left text-base text-fg transition hover:bg-hover"
class="flex w-full items-center gap-3 rounded-control px-2 py-2.5 text-left text-[13.5px] font-medium text-fg transition hover:bg-hover"
>
<PlaylistArt
playlistId={playlist.id}
@@ -116,4 +114,4 @@
</div>
</div>
{/if}
</div>
</GlassMenu>
@@ -2,44 +2,27 @@
import TrackInfo from './TrackInfo.svelte';
import TransportControls from './TransportControls.svelte';
import SeekBar from './SeekBar.svelte';
import VolumeControl from './VolumeControl.svelte';
import Alert from '$lib/components/ui/Alert.svelte';
import { player } from '$lib/player/player.svelte';
import PlayerExtras from './PlayerExtras.svelte';
</script>
{#if player.error}
<div
class="fixed inset-x-0 z-40 page-x py-2 md:left-[var(--mf-sidebar-w)] md:z-50"
style="bottom:calc(var(--mf-nav-h) + var(--mf-safe-b) + var(--mf-player-h))"
>
<Alert tone="danger">{player.error}</Alert>
<div
class="animate-enter pointer-events-auto flex items-center gap-4.5 rounded-[18px] border border-hairline bg-[image:var(--mf-bar-bg)] px-4 py-2.75 shadow-player backdrop-blur-[22px] transition-[background] duration-[600ms] sm:hidden"
>
<div class="min-w-0 flex-1">
<TrackInfo compact />
</div>
{/if}
<TransportControls compact />
</div>
<div
class="animate-enter fixed right-0 left-0 z-40 h-[var(--mf-player-h)] border-t border-line bg-bg md:left-[var(--mf-sidebar-w)] md:z-50"
style="bottom:calc(var(--mf-nav-h) + var(--mf-safe-b))"
class="animate-enter pointer-events-auto hidden items-center gap-5 rounded-[18px] border border-hairline bg-[image:var(--mf-bar-bg)] px-4 py-2.75 shadow-player backdrop-blur-[22px] transition-[background] duration-[600ms] sm:flex"
>
<div class="absolute inset-x-0 top-0 md:hidden">
<SeekBar compact />
</div>
<div class="flex h-full items-center gap-3 page-x md:hidden">
<div class="min-w-0 flex-1">
<TrackInfo />
</div>
<TransportControls compact />
</div>
<div
class="hidden h-full items-center gap-5 px-8 md:grid"
style="grid-template-columns:minmax(200px,1fr) minmax(0,3fr) minmax(200px,1fr)"
>
<div class="w-64 min-w-0 shrink-0">
<TrackInfo />
<div class="flex flex-col items-center gap-2">
<TransportControls />
<SeekBar />
</div>
<VolumeControl />
</div>
<div class="flex min-w-0 flex-1 flex-col items-center gap-2">
<TransportControls />
<SeekBar />
</div>
<PlayerExtras />
</div>
@@ -0,0 +1,29 @@
<script lang="ts">
import PlayerBar from './PlayerBar.svelte';
import TabsBar from '../TabsBar.svelte';
import Alert from '$lib/components/ui/Alert.svelte';
import { player } from '$lib/player/player.svelte';
import type { SessionUser } from '$lib/types';
interface Props {
user: SessionUser | null;
}
let { user }: Props = $props();
const hasTrack = $derived(player.currentId !== null);
</script>
<div
class="pointer-events-none fixed inset-x-0 bottom-0 z-40 flex flex-col gap-2.5 px-4 pb-4 lg:left-[var(--mf-sidebar-w)]"
>
{#if player.error}
<div class="pointer-events-auto">
<Alert tone="danger">{player.error}</Alert>
</div>
{/if}
{#if hasTrack}
<PlayerBar />
{/if}
<TabsBar {user} />
</div>
@@ -0,0 +1,22 @@
<script lang="ts">
import VolumeControl from './VolumeControl.svelte';
import { queuePanel } from '$lib/player/queuePanel.svelte';
import AlignJustify from '@lucide/svelte/icons/align-justify';
import ListMusic from '@lucide/svelte/icons/list-music';
</script>
<div class="hidden w-64 shrink-0 items-center justify-end gap-3.5 sm:flex">
<span aria-hidden="true" class="text-fg-3">
<AlignJustify class="h-[15px] w-[15px]" strokeWidth={1.8} />
</span>
<VolumeControl />
<button
type="button"
onclick={() => queuePanel.toggle()}
aria-pressed={queuePanel.open}
aria-label="Cola de reproducción"
class="transition-colors hover:text-fg {queuePanel.open ? 'text-fg' : 'text-fg-2'}"
>
<ListMusic class="h-4 w-4" strokeWidth={1.8} />
</button>
</div>
@@ -0,0 +1,117 @@
<script lang="ts">
import { player } from '$lib/player/player.svelte';
import { queuePanel } from '$lib/player/queuePanel.svelte';
import Cover from '$lib/components/ui/Cover.svelte';
import ArtistLink from '$lib/components/ui/ArtistLink.svelte';
import { fmtTime } from '$lib/format';
import X from '@lucide/svelte/icons/x';
import { fly } from 'svelte/transition';
import { cubicOut } from 'svelte/easing';
const upcoming = $derived.by(() => {
const idx = player.tracks.findIndex((t) => t.id === player.currentId);
return idx === -1 ? player.tracks : player.tracks.slice(idx + 1);
});
</script>
{#if queuePanel.open}
<aside
transition:fly={{ x: 24, duration: 220, easing: cubicOut }}
class="hidden shrink-0 flex-col border-l border-hairline bg-[image:var(--mf-panel-bg)] px-4 pt-5 transition-[background] duration-[600ms] sm:flex"
style="width:var(--mf-queue-w); padding-bottom:calc(var(--mf-player-h) + 2rem)"
>
<div class="mb-5 flex items-center justify-between">
<h2 class="font-display text-[14.5px] font-semibold tracking-[-0.02em] text-fg">En cola</h2>
<button
type="button"
onclick={() => queuePanel.close()}
aria-label="Cerrar cola"
class="text-base text-fg-3 transition-colors hover:text-fg"
>
<X class="h-4 w-4" />
</button>
</div>
{#if player.current.id}
<p class="mb-2.75 text-[10.5px] font-semibold tracking-[0.12em] text-muted uppercase">
Reproduciendo
</p>
<div class="mb-6 flex items-center gap-3 rounded-xl bg-accent-tint p-2.5">
<Cover
trackId={player.current.id}
src={player.current.coverUrl}
size="small"
alt={player.current.title}
class="h-10 w-10 shrink-0 rounded-[9px]"
/>
<div class="min-w-0">
<div class="truncate text-[12.5px] font-semibold text-accent-soft">
{player.current.title}
</div>
<ArtistLink
name={player.current.artist || '—'}
ownerUserId={player.current.ownerUserId}
class="mt-0.75 text-[11px] text-fg-3"
/>
</div>
</div>
{/if}
<div class="mb-2.75 flex items-center justify-between gap-3">
<span
class="min-w-0 flex-1 truncate text-[10.5px] font-semibold tracking-[0.12em] text-muted uppercase"
>
A continuación
</span>
{#if upcoming.length > 0}
<button
type="button"
onclick={() => player.clearUpcoming()}
class="shrink-0 text-[11px] text-muted transition-colors hover:text-fg"
>
Vaciar
</button>
{/if}
</div>
<div class="flex flex-1 flex-col gap-px overflow-y-auto">
{#each upcoming as track, i (track.id)}
{@const index = player.tracks.length - upcoming.length + i}
<div
role="button"
tabindex="0"
onclick={() => player.playQueueIndex(index)}
onkeydown={(e) => {
if (e.key !== 'Enter' && e.key !== ' ') return;
e.preventDefault();
player.playQueueIndex(index);
}}
class="flex items-center gap-3 rounded-[11px] p-2.25 text-left hover:bg-hover"
>
<Cover
trackId={track.id}
src={track.coverUrl}
size="small"
alt={track.title}
class="h-9 w-9 shrink-0 rounded-lg opacity-90"
/>
<div class="min-w-0 flex-1">
<div class="truncate text-[12.5px] font-medium text-fg">{track.title}</div>
<ArtistLink
name={track.artist || '—'}
ownerUserId={track.ownerUserId}
class="mt-0.5 text-[11px] text-fg-3"
/>
</div>
{#if track.duration}
<span class="shrink-0 text-[11px] text-muted tabular-nums"
>{fmtTime(track.duration)}</span
>
{/if}
</div>
{:else}
<p class="px-2 py-6 text-center text-xs text-fg-3">No hay más canciones en la cola.</p>
{/each}
</div>
</aside>
{/if}
@@ -27,26 +27,21 @@
></div>
</button>
{:else}
<div class="flex w-full max-w-172 items-center gap-3">
<span class="text-right text-xs text-muted tabular-nums">
<div class="flex w-full max-w-135 items-center gap-2.75">
<span class="min-w-8 text-right text-[10.5px] text-fg-3 tabular-nums">
{fmtTime(player.progress)}
</span>
<button
type="button"
onclick={seek}
aria-label="Barra de progreso"
class="group relative h-1.5 flex-1 cursor-pointer rounded-full bg-track"
class="group relative h-3.5 flex-1 cursor-pointer"
>
<div
class="absolute inset-y-0 left-0 rounded-full bg-accent/60 group-hover:brightness-110"
style="width:{player.progressPercent}%"
></div>
<span
class="pointer-events-none absolute top-1/2 h-3 w-3 -translate-x-1/2 -translate-y-1/2 rounded-full bg-on-art opacity-0 shadow-[0_1px_4px_rgba(0,0,0,0.5)] transition-opacity duration-75 group-hover:opacity-100"
style="left:{player.progressPercent}%"
></span>
<div class="h-[3.5px] w-full overflow-hidden rounded-full bg-track">
<div class="h-full rounded-full bg-accent" style="width:{player.progressPercent}%"></div>
</div>
</button>
<span class="text-xs text-muted tabular-nums">
<span class="min-w-8 text-[10.5px] text-fg-3 tabular-nums">
{fmtTime(player.current.duration)}
</span>
</div>
@@ -1,30 +1,71 @@
<script lang="ts">
import { player } from '$lib/player/player.svelte';
import { liked } from '$lib/player/liked.svelte';
import Cover from '$lib/components/ui/Cover.svelte';
import ExplicitBadge from '$lib/components/ui/ExplicitBadge.svelte';
import ArtistLink from '$lib/components/ui/ArtistLink.svelte';
import Heart from '@lucide/svelte/icons/heart';
interface Props {
compact?: boolean;
}
let { compact = false }: Props = $props();
const isLiked = $derived(player.current.id !== '' && liked.isLiked(player.current.id));
function toggleLike() {
if (!player.current.id) return;
liked.toggle({
id: player.current.id,
title: player.current.title,
artist: player.current.artist,
coverUrl: player.current.coverUrl,
source: player.current.source,
explicit: player.current.explicit,
ownerUserId: player.current.ownerUserId
});
}
</script>
<div class="flex min-w-0 items-center gap-2.5 md:gap-3">
<div class="flex min-w-0 items-center gap-2.5 md:gap-3.25">
{#if player.current.id}
<Cover
trackId={player.current.id}
src={player.current.coverUrl}
size="small"
alt={player.current.title}
class="h-11 w-11 shrink-0 rounded-lg shadow-[0_6px_16px_-6px_rgba(0,0,0,0.6)] md:h-13 md:w-13"
class="h-11 w-11 shrink-0 rounded-[11px] shadow-[0_8px_20px_rgba(0,0,0,0.5)] md:h-12 md:w-12"
/>
{:else}
<div
class="h-11 w-11 shrink-0 rounded-lg bg-surface shadow-[0_6px_16px_-6px_rgba(0,0,0,0.6)] md:h-13 md:w-13"
class="h-11 w-11 shrink-0 rounded-[11px] bg-surface shadow-[0_8px_20px_rgba(0,0,0,0.5)] md:h-12 md:w-12"
></div>
{/if}
<div class="min-w-0">
<div class="min-w-0 flex-1">
<div class="flex min-w-0 items-center gap-1.5">
{#if player.current.explicit}
<ExplicitBadge />
{/if}
<span class="truncate text-fg">{player.current.title}</span>
<span class="truncate text-[13px] font-semibold tracking-[-0.01em] text-fg"
>{player.current.title}</span
>
</div>
<div class="truncate text-sm text-muted">{player.current.artist || '—'}</div>
<ArtistLink
name={player.current.artist || '—'}
ownerUserId={player.current.ownerUserId}
class="mt-0.5 text-[11.5px] text-fg-3"
/>
</div>
{#if !compact}
<button
type="button"
onclick={toggleLike}
aria-label={isLiked ? 'Quitar de Me gusta' : 'Añadir a Me gusta'}
aria-pressed={isLiked}
class="shrink-0 transition hover:opacity-80 {isLiked ? 'text-accent' : 'text-fg-3'}"
>
<Heart class="h-4 w-4" fill={isLiked ? 'currentColor' : 'none'} strokeWidth={1.7} />
</button>
{/if}
</div>
@@ -1,5 +1,7 @@
<script lang="ts">
import { player } from '$lib/player/player.svelte';
import Shuffle from '@lucide/svelte/icons/shuffle';
import Repeat from '@lucide/svelte/icons/repeat';
interface Props {
compact?: boolean;
@@ -10,11 +12,23 @@
const sideClass = $derived(
compact
? 'grid h-10 w-10 place-items-center text-fg opacity-70 transition active:scale-90'
: 'flex text-fg opacity-70 transition hover:opacity-100 active:scale-90'
: 'flex text-fg-2 transition-colors hover:text-fg active:scale-90'
);
</script>
<div class="flex shrink-0 items-center {compact ? 'gap-0.5' : 'gap-5'}">
<div class="flex shrink-0 items-center {compact ? 'gap-0.5' : 'gap-3.5'}">
{#if !compact}
<button
type="button"
onclick={() => player.toggleShuffle()}
aria-pressed={player.shuffle}
aria-label="Aleatorio"
class="transition-colors hover:text-fg {player.shuffle ? 'text-accent' : 'text-fg-3'}"
>
<Shuffle class="h-[15px] w-[15px]" strokeWidth={1.8} />
</button>
{/if}
<button type="button" onclick={() => player.previous()} aria-label="Anterior" class={sideClass}>
<svg width="16" height="14" viewBox="0 0 16 14" fill="currentColor">
<rect x="0" y="0" width="2.5" height="14" rx="1" />
@@ -26,16 +40,16 @@
type="button"
onclick={() => player.toggle()}
aria-label={player.playing ? 'Pausar' : 'Reproducir'}
class="grid h-[36px] w-[36px] shrink-0 place-items-center rounded-full bg-fg text-bg shadow-lg transition duration-100 active:scale-95"
class="grid h-10 w-10 shrink-0 place-items-center rounded-full bg-cta-strong text-ink transition duration-150 hover:brightness-95"
>
{#if player.playing}
<svg width="12" height="12" viewBox="0 0 12 12" fill="currentColor">
<rect x="1.5" y="0" width="3.5" height="12" rx="1" />
<rect x="7" y="0" width="3.5" height="12" rx="1" />
<svg width="15" height="15" viewBox="0 0 15 15" fill="currentColor">
<rect x="3" y="1" width="3" height="13" rx="1" />
<rect x="9" y="1" width="3" height="13" rx="1" />
</svg>
{:else}
<svg width="12" height="12" viewBox="0 0 12 12" fill="currentColor">
<path d="M2.5 0 12 6l-9.5 6z" />
<svg width="15" height="15" viewBox="0 0 15 15" fill="currentColor">
<path d="M3 1.5v12l10-6z" />
</svg>
{/if}
</button>
@@ -46,4 +60,16 @@
<rect x="13.5" y="0" width="2.5" height="14" rx="1" />
</svg>
</button>
{#if !compact}
<button
type="button"
onclick={() => player.toggleRepeat()}
aria-pressed={player.repeat}
aria-label="Repetir"
class="transition-colors hover:text-fg {player.repeat ? 'text-accent' : 'text-fg-3'}"
>
<Repeat class="h-[15px] w-[15px]" strokeWidth={1.8} />
</button>
{/if}
</div>
@@ -1,14 +1,15 @@
<script lang="ts">
import { player } from '$lib/player/player.svelte';
import Volume2 from '@lucide/svelte/icons/volume-2';
function handleVolumeChange(event: Event) {
const target = event.currentTarget as HTMLInputElement;
const newVolume = Number(target.value);
player.setVolume(newVolume);
player.setVolume(Number(target.value));
}
</script>
<div class="flex items-center justify-end gap-3.5">
<div class="flex items-center gap-2.25">
<Volume2 class="h-[15px] w-[15px] shrink-0 text-fg-3" strokeWidth={1.8} />
<input
type="range"
min="0"
@@ -17,7 +18,7 @@
oninput={handleVolumeChange}
aria-label="Volumen"
class="mf-volume"
style="background:linear-gradient(to right, var(--color-accent-soft) {player.volume}%, var(--color-track) {player.volume}%)"
style="background:linear-gradient(to right, var(--mf-text-2) {player.volume}%, var(--mf-track) {player.volume}%)"
/>
</div>
@@ -25,8 +26,8 @@
.mf-volume {
-webkit-appearance: none;
appearance: none;
width: 80px;
height: 4px;
width: 84px;
height: 3.5px;
border-radius: 100px;
outline: none;
cursor: pointer;
@@ -34,17 +35,17 @@
.mf-volume::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 12px;
height: 12px;
width: 11px;
height: 11px;
border-radius: 50%;
background: var(--color-fg);
background: var(--mf-text);
cursor: pointer;
}
.mf-volume::-moz-range-thumb {
width: 12px;
height: 12px;
width: 11px;
height: 11px;
border-radius: 50%;
background: var(--color-fg);
background: var(--mf-text);
border: none;
cursor: pointer;
}
@@ -1,6 +1,7 @@
<script lang="ts">
import { untrack } from 'svelte';
import { enhance } from '$app/forms';
import Disc3 from '@lucide/svelte/icons/disc-3';
import ImageDropzone from './ImageDropzone.svelte';
import Button from './Button.svelte';
import Field from './Field.svelte';
@@ -59,11 +60,18 @@
if (result.type === 'success') onSuccess?.();
};
}}
class="mt-4 w-full max-w-2xl space-y-4"
class="mt-5 w-full max-w-2xl space-y-5"
>
<div class="flex flex-col items-stretch gap-5 sm:flex-row sm:gap-8">
<Field label="Portada" optional={!requireCover}>
<ImageDropzone name="cover" fallbackUrl={coverFallbackUrl} required={requireCover} />
<Field>
<ImageDropzone
name="cover"
fallbackUrl={coverFallbackUrl}
required={requireCover}
icon={Disc3}
gradient
class="h-36 w-36 rounded-[16px]"
/>
</Field>
<div class="flex flex-1 flex-col gap-4">
@@ -78,7 +86,7 @@
/>
</Field>
<Field label="Año de publicación" for="album-year" optional>
<Field label="Año de publicación" for="album-year">
<Input
id="album-year"
name="releaseYear"
@@ -92,7 +100,7 @@
</div>
</div>
<Field label="Descripción" for="album-description" optional>
<Field label="Descripción" for="album-description">
<Textarea
id="album-description"
name="description"
@@ -11,10 +11,14 @@
const tones = {
neutral: 'border-line bg-surface text-fg-2',
danger: 'border-danger/40 bg-danger/10 text-danger-fg'
danger: 'border-danger/20 bg-danger/10 text-danger-fg'
};
</script>
<p class="rounded-control border px-4 py-3 text-base {tones[tone]} {klass}">
<p
class="rounded-control border px-4 py-3 text-sm shadow-menu backdrop-blur-md {tones[
tone
]} {klass}"
>
{@render children()}
</p>
@@ -1,10 +1,11 @@
<script lang="ts">
interface Props {
name: string;
imageUrl?: string | null;
size?: number;
}
let { name, size = 108 }: Props = $props();
let { name, imageUrl, size = 108 }: Props = $props();
const initials = $derived(
name
@@ -21,11 +22,15 @@
class="relative grid shrink-0 place-items-center overflow-hidden rounded-full bg-surface shadow-art ring-1 ring-line transition duration-300 ease-out ring-inset group-hover/artist:shadow-art-lg"
style="width:{size}px;height:{size}px"
>
<div
class="pointer-events-none absolute inset-0"
style="background:repeating-linear-gradient(135deg, rgba(255,255,255,0.12) 0 2px, transparent 2px 10px)"
></div>
<span class="relative text-lg font-semibold text-white/90">{initials}</span>
{#if imageUrl}
<img src={imageUrl} alt="" class="h-full w-full object-cover" />
{:else}
<div
class="pointer-events-none absolute inset-0"
style="background:repeating-linear-gradient(135deg, rgba(255,255,255,0.12) 0 2px, transparent 2px 10px)"
></div>
<span class="relative text-lg font-semibold text-white/90">{initials}</span>
{/if}
</div>
<div class="w-full truncate text-sm text-fg">{name}</div>
</div>
@@ -0,0 +1,23 @@
<script lang="ts">
interface Props {
name?: string | null;
ownerUserId?: string | number | null;
class?: string;
}
let { name, ownerUserId, class: klass = '' }: Props = $props();
</script>
{#if name}
{#if ownerUserId}
<a
href="/u/{ownerUserId}"
onclick={(e) => e.stopPropagation()}
class="inline-block max-w-full truncate align-top hover:text-fg-2 hover:underline {klass}"
>
{name}
</a>
{:else}
<span class="block truncate {klass}">{name}</span>
{/if}
{/if}
+10 -8
View File
@@ -3,7 +3,7 @@
import LoaderCircle from '@lucide/svelte/icons/loader-circle';
interface Props {
variant?: 'primary' | 'secondary' | 'subtle' | 'danger';
variant?: 'primary' | 'secondary' | 'accent' | 'danger';
size?: 'sm' | 'md' | 'lg';
type?: 'button' | 'submit';
href?: string;
@@ -29,20 +29,22 @@
}: Props = $props();
const sizes = {
sm: 'px-4 py-2 text-base',
md: 'px-5 py-2.5 text-base',
lg: 'px-8 py-3.5 text-base'
sm: 'px-4 py-2.5 text-[12.5px]',
md: 'px-5 py-3 text-[13px]',
lg: 'px-6 py-3.5 text-sm'
};
const subtleButton = 'bg-white/[6%] text-fg-2 hover:bg-white/[9%] hover:text-fg';
const variants = {
primary: 'bg-accent-soft text-on-accent active:scale-[0.97]',
secondary: 'bg-bg text-fg ring-1 ring-line hover:bg-surface',
subtle: 'bg-white/10 text-fg hover:bg-accent/18',
primary: subtleButton,
secondary: subtleButton,
accent: 'bg-accent-btn text-accent-soft hover:bg-accent-btn-hover',
danger: 'bg-danger text-on-accent hover:brightness-110'
};
const classes = $derived(
`inline-flex items-center justify-center gap-2 rounded-control font-medium 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 rounded-xl font-semibold 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>
@@ -12,7 +12,7 @@
let { checked = $bindable(false), name, class: klass = '', children }: Props = $props();
</script>
<label class="flex items-center gap-3 text-base text-fg-2 {klass}">
<label class="flex items-center gap-3 text-sm text-fg-2 {klass}">
<span class="relative inline-grid h-5 w-5 shrink-0 place-items-center">
<input
type="checkbox"
@@ -21,7 +21,7 @@
class="peer h-5 w-5 cursor-pointer appearance-none rounded border border-line bg-surface transition checked:border-accent-soft checked:bg-accent-soft focus-visible:border-accent/60 focus-visible:outline-none"
/>
<Check
class="pointer-events-none absolute h-3.5 w-3.5 text-on-accent opacity-0 transition peer-checked:opacity-100"
class="pointer-events-none absolute h-3.5 w-3.5 text-ink opacity-0 transition peer-checked:opacity-100"
strokeWidth={3}
/>
</span>
@@ -1,14 +0,0 @@
<script lang="ts">
interface Props {
label: string;
class?: string;
}
let { label, class: klass = '' }: Props = $props();
</script>
<span
class="pointer-events-none absolute top-2 left-2 rounded-full bg-black/45 px-2 py-0.5 text-[10px] font-semibold tracking-[0.08em] text-white uppercase backdrop-blur-sm {klass}"
>
{label}
</span>
@@ -13,24 +13,19 @@
let { icon: Icon, title, description, class: klass = '', actions }: Props = $props();
</script>
<div
class="flex flex-col items-center rounded-panel border border-dashed border-line px-6 py-16 text-center {klass}"
>
<div class="flex flex-col items-center rounded-panel px-6 py-16 text-center {klass}">
{#if Icon}
<span
class="grid h-16 w-16 place-items-center rounded-full text-on-accent"
style="background:radial-gradient(circle at 30% 30%, color-mix(in oklch, var(--color-accent), white 15%), var(--color-accent))"
>
<Icon class="h-7 w-7" />
</span>
<Icon class="h-9 w-9 text-fg-2" strokeWidth={1.3} />
{/if}
{#if title}
<h3 class="mt-6 font-display text-2xl font-medium tracking-tight text-fg">{title}</h3>
<h3 class="mt-6 font-display text-xl font-semibold tracking-[-0.025em] text-fg">
{title}
</h3>
{/if}
{#if description}
<p class="mt-2 max-w-sm text-fg-2">{description}</p>
<p class="mt-2.5 max-w-sm text-[13px] leading-[1.65] text-fg-2">{description}</p>
{/if}
{#if actions}
<div class="mt-6 flex items-center gap-3">{@render actions()}</div>
<div class="mt-6 flex flex-wrap items-center justify-center gap-2.5">{@render actions()}</div>
{/if}
</div>
@@ -0,0 +1,23 @@
<script lang="ts">
interface Props {
size?: number;
barWidth?: number;
paused?: boolean;
class?: string;
}
let { size = 13, barWidth = 2, paused = false, class: klass = '' }: Props = $props();
const delays = [0, 0.3, 0.6];
</script>
<div class="flex items-end justify-center gap-[2px] {klass}" style="height:{size}px">
{#each delays as delay (delay)}
<span
class="rounded-full bg-accent"
style="width:{barWidth}px; height:100%; animation:equalize .9s ease-in-out {delay}s infinite; animation-play-state:{paused
? 'paused'
: 'running'}"
></span>
{/each}
</div>
@@ -2,25 +2,25 @@
import type { Snippet } from 'svelte';
interface Props {
label: string;
label?: string;
for?: string;
optional?: boolean;
class?: string;
hint?: Snippet;
children: Snippet;
}
let { label, for: forId, optional = false, class: klass = '', hint, children }: Props = $props();
let { label, for: forId, class: klass = '', hint, children }: Props = $props();
</script>
<div class="flex min-w-0 flex-col {klass}">
<label for={forId} class="mb-2 block text-base font-medium text-fg-2">
{label}
{#if optional}<span class="text-muted">(Opcional)</span>{/if}
</label>
{#if label}
<label for={forId} class="mb-2 block text-sm font-medium text-fg-2">
{label}
</label>
{/if}
{@render children()}
{#if hint}
<div class="mt-2 flex items-center justify-between gap-3 text-sm text-muted">
<div class="mt-2 flex items-center justify-between gap-3 text-xs text-muted">
{@render hint()}
</div>
{/if}
@@ -0,0 +1,15 @@
<script lang="ts">
import type { Snippet } from 'svelte';
interface Props {
class?: string;
style?: string;
children: Snippet;
}
let { class: klass = '', style = '', children }: Props = $props();
</script>
<div class="glass-panel animate-pop rounded-panel p-1.5 shadow-menu {klass}" {style}>
{@render children()}
</div>
@@ -1,10 +1,13 @@
<script lang="ts">
import ImageIcon from '@lucide/svelte/icons/image';
import type { LucideIcon } from '@lucide/svelte';
interface Props {
name: string;
fallbackUrl?: string;
required?: boolean;
icon?: LucideIcon;
gradient?: boolean;
class?: string;
}
@@ -12,13 +15,16 @@
name,
fallbackUrl,
required = false,
class:
klass = 'h-40 w-40 sm:h-52 sm:w-52 rounded-control border border-line bg-surface hover:border-accent/50'
icon: Icon = ImageIcon,
gradient = false,
class: klass = 'h-40 w-40 sm:h-52 sm:w-52 rounded-control'
}: Props = $props();
let preview = $state('');
let fallbackFailed = $state(false);
const hasImage = $derived(preview !== '' || (!!fallbackUrl && !fallbackFailed));
function onInput(event: Event) {
const file = (event.currentTarget as HTMLInputElement).files?.[0];
if (file) preview = URL.createObjectURL(file);
@@ -26,7 +32,10 @@
</script>
<label
class="relative grid cursor-pointer place-items-center overflow-hidden transition-colors {klass}"
class="relative grid cursor-pointer place-items-center overflow-hidden transition-colors {gradient
? ''
: 'border border-line bg-surface hover:border-accent/50'} {klass}"
style={gradient && !hasImage ? 'background:var(--mf-cover-grad)' : undefined}
>
<input
type="file"
@@ -48,6 +57,6 @@
class="h-full w-full object-cover"
/>
{:else}
<ImageIcon class="h-12 w-12 text-muted" />
<Icon class="h-9 w-9 {gradient ? 'text-white/70' : 'text-muted'}" strokeWidth={1.3} />
{/if}
</label>
@@ -52,7 +52,7 @@
{autocomplete}
{oninput}
bind:value
class="w-full rounded-control border border-line bg-surface py-3 text-base text-fg transition placeholder:text-muted focus:border-accent/60 focus:outline-none {Icon
class="w-full rounded-control bg-surface py-3 text-sm text-fg transition placeholder:text-muted focus:bg-surface-2 focus:outline-none {Icon
? 'pr-4 pl-11'
: 'px-4'} {klass}"
/>
@@ -25,8 +25,8 @@
{oncontextmenu}
>
{@render art(mediaCardArt)}
<div class="mt-2.5 truncate text-fg transition-colors group-hover:text-accent-soft">{title}</div>
<div class="mt-2.5 truncate text-sm font-medium text-fg">{title}</div>
{#if subtitle}
<div class="mt-0.5 truncate text-sm text-fg-3">{subtitle}</div>
<div class="mt-0.5 truncate text-[12.5px] text-fg-3">{subtitle}</div>
{/if}
</a>
@@ -5,6 +5,7 @@
icon: LucideIcon;
label: string;
href?: string;
target?: string;
type?: 'button' | 'submit';
reload?: boolean;
onclick?: (event: MouseEvent) => void;
@@ -15,6 +16,7 @@
icon: Icon,
label,
href,
target,
type = 'button',
reload = false,
onclick,
@@ -22,18 +24,25 @@
}: Props = $props();
const classes = $derived(
`flex w-full items-center gap-3 rounded-control px-2 py-2.5 text-left text-base 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-[13.5px] font-medium text-fg-2 transition hover:bg-hover ${klass}`
);
</script>
{#if href}
<a {href} data-sveltekit-reload={reload ? '' : undefined} {onclick} class={classes}>
<Icon class="h-6 w-6 shrink-0" strokeWidth={2} />
<a
{href}
{target}
rel={target === '_blank' ? 'noopener noreferrer' : undefined}
data-sveltekit-reload={reload ? '' : undefined}
{onclick}
class={classes}
>
<Icon class="h-[18px] w-[18px] shrink-0" strokeWidth={1.8} />
{label}
</a>
{:else}
<button {type} {onclick} class={classes}>
<Icon class="h-6 w-6 shrink-0" strokeWidth={2} />
<Icon class="h-[18px] w-[18px] shrink-0" strokeWidth={1.8} />
{label}
</button>
{/if}
@@ -1,66 +0,0 @@
<script lang="ts">
import Cover from './Cover.svelte';
import type { Mix } from '$lib/mixes';
interface Props {
mix: Mix;
featured?: boolean;
banner?: boolean;
class?: string;
index?: number;
}
let { mix, featured = false, banner = false, class: klass = '', index = 0 }: Props = $props();
const fillCount = $derived(banner ? 40 : 12);
const tiles = $derived.by(() => {
if (mix.items.length === 0) return [];
const out: Mix['items'] = [];
while (out.length < fillCount) out.push(...mix.items);
return out.slice(0, fillCount);
});
</script>
<a
href="/mixes/{mix.id}"
class="group/tile animate-enter relative block overflow-hidden rounded-art shadow-art ring-1 ring-line transition duration-300 ease-out [clip-path:inset(0_round_var(--radius-art))] ring-inset hover:shadow-art-lg focus-visible:ring-2 focus-visible:ring-accent focus-visible:outline-none {klass}"
style="animation-delay:{Math.min(index, 10) * 45}ms"
>
{#if tiles.length > 0}
<div
class="absolute inset-0 flex scale-105 transition duration-500 ease-out group-hover/tile:scale-110"
>
{#each tiles as item, i (i)}
<Cover
trackId={item.trackId ?? item.videoId ?? ''}
src={item.thumbnailUrl}
size="medium"
class="aspect-square h-full shrink-0"
/>
{/each}
</div>
{/if}
<div
class="pointer-events-none absolute inset-0 bg-linear-to-t from-scrim/85 via-scrim/65 to-scrim/40 {banner
? 'sm:bg-linear-to-r'
: ''}"
></div>
<div
class="pointer-events-none absolute inset-0 bg-scrim/25 opacity-0 transition-opacity duration-300"
></div>
<div
class="relative flex h-full flex-col justify-end p-3 sm:p-4 {banner
? 'sm:flex-row sm:items-center sm:justify-between sm:gap-4'
: ''}"
>
<div class="min-w-0">
<div class="truncate text-on-art {featured ? 'text-xl sm:text-2xl' : 'text-base'}">
{mix.title}
</div>
{#if mix.subtitle}
<div class="mt-0.5 truncate text-xs text-on-art/75 sm:text-sm">{mix.subtitle}</div>
{/if}
</div>
</div>
</a>
@@ -0,0 +1,51 @@
<script lang="ts">
import Play from '@lucide/svelte/icons/play';
import MixArt from './MixArt.svelte';
import { player } from '$lib/player/player.svelte';
import { queueItemForTarget } from '$lib/tracks';
import { mixItemTarget, type Mix } from '$lib/mixes';
interface Props {
mix: Mix;
index?: number;
class?: string;
}
let { mix, index = 0, class: klass = '' }: Props = $props();
function play(event: MouseEvent) {
event.preventDefault();
const items = mix.items.map((item) => queueItemForTarget(mixItemTarget(item)));
player.playQueue(items, 0);
}
</script>
<a
href="/mixes/{mix.id}"
class="group/tile animate-enter block cursor-pointer {klass}"
style="animation-delay:{Math.min(index, 10) * 45}ms"
>
<div class="relative aspect-square overflow-hidden rounded-art shadow-art">
<MixArt
items={mix.items}
class="h-full w-full scale-100 transition duration-300 group-hover/tile:scale-105"
/>
<div
class="pointer-events-none absolute inset-0 bg-linear-to-b from-transparent from-45% to-black/42"
></div>
<button
type="button"
onclick={play}
aria-label="Reproducir {mix.title}"
class="absolute right-2.5 bottom-2.5 grid h-9.5 w-9.5 place-items-center rounded-full border border-white/12 bg-ink/76 text-fg opacity-0 backdrop-blur-[8px] transition-opacity duration-150 group-hover/tile:opacity-100"
>
<Play class="h-3.5 w-3.5" fill="currentColor" />
</button>
</div>
<div class="mt-3.25 truncate text-[13px] font-semibold tracking-[-0.01em] text-fg">
{mix.title}
</div>
{#if mix.subtitle}
<div class="mt-1 truncate text-[11.5px] text-fg-3">{mix.subtitle}</div>
{/if}
</a>
+17 -5
View File
@@ -6,6 +6,7 @@
interface Props {
open: boolean;
onClose: () => void;
eyebrow?: string;
title?: string;
maxWidth?: string;
panelClass?: string;
@@ -15,9 +16,10 @@
let {
open,
onClose,
eyebrow,
title,
maxWidth = 'max-w-2xl',
panelClass = 'border border-line bg-elevated',
panelClass = '',
children
}: Props = $props();
@@ -74,16 +76,26 @@
>
<div
bind:this={panel}
class="animate-pop max-h-[85dvh] w-full overflow-y-auto {maxWidth} rounded-panel p-5 shadow-2xl outline-none sm:p-6 {panelClass}"
class="animate-pop max-h-[85dvh] w-full overflow-y-auto {maxWidth} rounded-panel p-5 shadow-2xl backdrop-blur-[22px] transition-[background] duration-[600ms] outline-none sm:p-6 {panelClass}"
style="background-color:var(--mf-panel-bg); background-image:var(--mf-modal-glow)"
role="dialog"
aria-modal="true"
aria-label={title}
tabindex="-1"
>
{#if title}
<div class="flex items-center justify-between">
<h2 class="text-lg font-semibold tracking-tight text-fg">{title}</h2>
<IconButton label="Cerrar" onclick={onClose}>
<div class="flex items-center justify-between gap-4">
<div class="min-w-0">
{#if eyebrow}
<p class="mb-2.5 text-xs font-semibold tracking-[0.1em] text-muted uppercase">
{eyebrow}
</p>
{/if}
<h2 class="font-display text-xl font-semibold tracking-[-0.02em] text-fg">
{title}
</h2>
</div>
<IconButton label="Cerrar" onclick={onClose} class="shrink-0">
<X class="h-4 w-4" />
</IconButton>
</div>
@@ -5,19 +5,14 @@
let { paused = false }: Props = $props();
const bars = [
{ delay: 0, dur: 0.9 },
{ delay: 0.25, dur: 0.7 },
{ delay: 0.1, dur: 1.05 },
{ delay: 0.4, dur: 0.8 }
];
const delays = [0, 0.3, 0.6];
</script>
<div class="absolute inset-0 flex items-end justify-center gap-[2.5px] bg-scrim/45 pb-2">
{#each bars as bar, i (i)}
<div class="absolute inset-0 flex items-end justify-center gap-[3px] bg-scrim/45 pb-2.5">
{#each delays as delay (delay)}
<span
class="w-[3px] origin-bottom rounded-full bg-on-art"
style="height:14px; animation:equalize {bar.dur}s ease-in-out {bar.delay}s infinite; animation-play-state:{paused
style="height:14px; animation:equalize .9s ease-in-out {delay}s infinite; animation-play-state:{paused
? 'paused'
: 'running'}"
></span>
+1 -1
View File
@@ -9,6 +9,6 @@
let { class: klass = '', children }: Props = $props();
</script>
<section class="page-x pt-7 pb-8 sm:pt-11 sm:pb-10 {klass}">
<section class="page-x pt-3 pb-8 sm:pt-4 sm:pb-10 {klass}">
{@render children()}
</section>
@@ -13,9 +13,11 @@
<div class="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between {klass}">
<div class="min-w-0">
<h1 class="font-display text-2xl font-semibold tracking-tight text-fg sm:text-4xl">{title}</h1>
<h1 class="font-display text-[28px] font-semibold tracking-[-0.03em] text-fg sm:text-[32px]">
{title}
</h1>
{#if subtitle}
<p class="mt-2 text-sm text-fg-2 sm:text-base">{subtitle}</p>
<p class="mt-2 text-[13px] text-fg-2">{subtitle}</p>
{/if}
</div>
{#if actions}
@@ -1,22 +0,0 @@
<script lang="ts">
import Play from '@lucide/svelte/icons/play';
import Pause from '@lucide/svelte/icons/pause';
interface Props {
active: boolean;
playing: boolean;
}
let { active, playing }: Props = $props();
</script>
<span
class="absolute right-2.5 bottom-2.5 grid h-11 w-11 place-items-center rounded-full bg-accent-soft text-on-accent opacity-0 shadow-lg transition-opacity group-hover:opacity-100"
class:!opacity-100={active}
>
{#if active && playing}
<Pause class="h-5 w-5" fill="currentColor" />
{:else}
<Play class="h-5 w-5" fill="currentColor" />
{/if}
</span>
@@ -5,16 +5,18 @@
interface Props {
id: string;
name: string;
description?: string | null;
trackCount: number;
trackIds: (string | number)[];
updatedAt: string;
index?: number;
}
let { id, name, description, trackIds, updatedAt, index = 0 }: Props = $props();
let { id, name, trackCount, trackIds, updatedAt, index = 0 }: Props = $props();
const subtitle = $derived(`${trackCount} ${trackCount === 1 ? 'canción' : 'canciones'}`);
</script>
<MediaCard href="/playlists/{id}" title={name} subtitle={description} {index}>
<MediaCard href="/playlists/{id}" title={name} {subtitle} {index}>
{#snippet art(artClass)}
<PlaylistArt playlistId={id} {trackIds} version={updatedAt} class={artClass} />
{/snippet}
@@ -1,6 +1,7 @@
<script lang="ts">
import { untrack } from 'svelte';
import { enhance } from '$app/forms';
import Music from '@lucide/svelte/icons/music';
import ImageDropzone from './ImageDropzone.svelte';
import Button from './Button.svelte';
import Field from './Field.svelte';
@@ -8,15 +9,20 @@
import Textarea from './Textarea.svelte';
import Alert from './Alert.svelte';
const NAME_MAX_LENGTH = 60;
const DESCRIPTION_MAX_LENGTH = 300;
interface Props {
action: string;
initialName?: string;
initialDescription?: string;
initialVisibility?: 'private' | 'public';
namePlaceholder?: string;
coverFallbackUrl?: string;
formMessage?: string;
submitLabel: string;
submittingLabel: string;
helperText?: string;
onCancel: () => void;
onSuccess?: () => void;
}
@@ -25,11 +31,13 @@
action,
initialName = '',
initialDescription = '',
namePlaceholder = '',
initialVisibility = 'private',
namePlaceholder = 'Playlist sin título',
coverFallbackUrl,
formMessage,
submitLabel,
submittingLabel,
helperText,
onCancel,
onSuccess
}: Props = $props();
@@ -37,6 +45,7 @@
let name = $state(untrack(() => initialName));
let description = $state(untrack(() => initialDescription));
let submitting = $state(false);
let visibility = $state(untrack(() => initialVisibility));
</script>
<form
@@ -48,49 +57,105 @@
return async ({ update, result }) => {
await update({ reset: false });
submitting = false;
if (result.type === 'success') onSuccess?.();
if (result.type === 'success' || result.type === 'redirect') onSuccess?.();
};
}}
class="mt-4 w-full max-w-4xl space-y-4"
class="mt-5 w-full max-w-4xl space-y-5"
>
<input type="hidden" name="visibility" value={visibility} />
<div class="flex flex-col items-stretch gap-5 sm:flex-row sm:gap-8">
<Field label="Portada" optional>
<ImageDropzone name="cover" fallbackUrl={coverFallbackUrl} />
<Field>
<ImageDropzone
name="cover"
fallbackUrl={coverFallbackUrl}
icon={Music}
gradient
class="h-36 w-36 rounded-[16px]"
/>
</Field>
<div class="flex flex-1 flex-col gap-4">
<Field label="Nombre" for="playlist-name">
{#snippet hint()}
<span class="ml-auto tabular-nums">{name.length}/{NAME_MAX_LENGTH}</span>
{/snippet}
<Input
id="playlist-name"
name="name"
maxlength={100}
maxlength={NAME_MAX_LENGTH}
required
bind:value={name}
placeholder={namePlaceholder}
/>
</Field>
<Field label="Descripción" for="playlist-description" optional class="min-h-0 flex-1">
<Field label="Descripción" for="playlist-description" class="min-h-0 flex-1">
<Textarea
id="playlist-description"
name="description"
maxlength={300}
maxlength={DESCRIPTION_MAX_LENGTH}
bind:value={description}
placeholder="¿De qué va esta lista?"
placeholder="Para qué sirve esta playlist, cuándo la escuchas…"
class="flex-1"
/>
</Field>
</div>
</div>
<div class="flex items-center justify-between gap-4 rounded-[14px] bg-surface px-4 py-3.5">
<div class="min-w-0">
<div class="text-sm font-medium text-fg-2">Visibilidad</div>
<div class="mt-0.5 text-xs text-muted">
{visibility === 'private'
? 'Solo tú la ves en tu biblioteca'
: 'Cualquiera con el enlace puede verla'}
</div>
</div>
<div class="relative flex shrink-0 rounded-full bg-surface-2 p-1">
<div
class="absolute inset-y-1 left-1 w-[calc(50%-4px)] rounded-full bg-fg transition-transform duration-300 ease-out {visibility ===
'public'
? 'translate-x-full'
: ''}"
></div>
<button
type="button"
onclick={() => (visibility = 'private')}
class="relative z-10 w-20 rounded-full py-1.5 text-center text-xs font-semibold transition-colors {visibility ===
'private'
? 'text-ink'
: 'text-muted hover:text-fg-2'}"
>
Privada
</button>
<button
type="button"
onclick={() => (visibility = 'public')}
class="relative z-10 w-20 rounded-full py-1.5 text-center text-xs font-semibold transition-colors {visibility ===
'public'
? 'text-ink'
: 'text-muted hover:text-fg-2'}"
>
Pública
</button>
</div>
</div>
{#if formMessage}
<Alert tone="danger">{formMessage}</Alert>
{/if}
<div class="flex items-center justify-end gap-3">
<Button type="button" variant="secondary" onclick={onCancel}>Cancelar</Button>
<Button type="submit" disabled={submitting || name.trim() === ''}>
{submitting ? submittingLabel : submitLabel}
</Button>
<div class="flex items-center justify-between gap-4">
{#if helperText}
<p class="text-sm text-muted">{helperText}</p>
{:else}
<span></span>
{/if}
<div class="flex shrink-0 items-center gap-3">
<Button type="button" variant="secondary" onclick={onCancel}>Cancelar</Button>
<Button type="submit" disabled={submitting || name.trim() === ''}>
{submitting ? submittingLabel : submitLabel}
</Button>
</div>
</div>
</form>
@@ -3,24 +3,30 @@
interface Props {
title: string;
subtitle?: string;
class?: string;
actions?: Snippet;
}
let { title, class: klass = '', actions }: Props = $props();
let { title, subtitle, class: klass = '', actions }: Props = $props();
</script>
{#snippet heading()}
<h2 class="font-display text-[17.5px] font-semibold tracking-[-0.025em] text-fg">
{title}
</h2>
{#if subtitle}
<p class="mt-1.25 text-xs text-fg-3">{subtitle}</p>
{/if}
{/snippet}
{#if actions}
<div class="mb-4 flex items-end justify-between gap-4 sm:mb-5 {klass}">
<h2 class="font-display text-xl font-medium tracking-tight text-fg sm:text-[1.75rem]">
{title}
</h2>
<div class="mb-4 flex items-end justify-between gap-4 sm:mb-4.5 {klass}">
<div>{@render heading()}</div>
{@render actions()}
</div>
{:else}
<h2
class="mb-4 font-display text-xl font-medium tracking-tight text-fg sm:mb-5 sm:text-[1.75rem] {klass}"
>
{title}
</h2>
<div class="mb-4 sm:mb-4.5 {klass}">
{@render heading()}
</div>
{/if}
@@ -12,6 +12,6 @@
const paddings = { sm: 'p-4', md: 'p-5 sm:p-6', lg: 'p-6 sm:p-12' };
</script>
<div class="rounded-panel border border-line bg-surface transition {paddings[padding]} {klass}">
<div class="rounded-panel bg-surface transition {paddings[padding]} {klass}">
{@render children()}
</div>
@@ -24,5 +24,5 @@
{placeholder}
{maxlength}
bind:value
class="min-h-0 w-full resize-none rounded-control border border-line bg-surface px-4 py-3 text-base text-fg transition placeholder:text-muted focus:border-accent/50 focus:outline-none {klass}"
class="min-h-0 w-full resize-none rounded-control bg-surface px-4 py-3 text-sm text-fg transition placeholder:text-muted focus:bg-surface-2 focus:outline-none {klass}"
></textarea>
@@ -1,6 +1,6 @@
<script lang="ts">
import Play from '@lucide/svelte/icons/play';
import NowPlaying from './NowPlaying.svelte';
import EqBars from './EqBars.svelte';
interface Props {
index: number;
@@ -16,10 +16,10 @@
type="button"
onclick={onToggle}
aria-label={active && playing ? 'Pausar' : 'Reproducir'}
class="relative grid h-8 w-8 place-items-center overflow-hidden rounded text-sm text-muted"
class="relative grid h-8 w-8 place-items-center rounded text-xs text-muted tabular-nums"
>
{#if active}
<NowPlaying paused={!playing} />
<EqBars paused={!playing} />
{:else}
<span class="group-hover:hidden">{index + 1}</span>
<Play class="hidden h-3.5 w-3.5 text-fg group-hover:block" fill="currentColor" />
@@ -9,6 +9,6 @@
let { class: klass = '', children }: Props = $props();
</script>
<span class="truncate text-center text-sm text-muted tabular-nums {klass}">
<span class="truncate text-center text-[12.5px] text-muted tabular-nums {klass}">
{@render children()}
</span>
@@ -4,19 +4,22 @@
import { trackTableKey, type TrackTableContext } from './TrackTable.svelte';
interface Props {
active?: boolean;
class?: string;
oncontextmenu?: (event: MouseEvent) => void;
children: Snippet;
}
let { class: klass = '', oncontextmenu, children }: Props = $props();
let { active = false, class: klass = '', oncontextmenu, children }: Props = $props();
const table = getContext<TrackTableContext>(trackTableKey);
</script>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="group grid track-grid items-center gap-3 rounded-control py-2.5 pr-4 pl-2 transition hover:bg-surface sm:gap-8 sm:pr-6 sm:pl-3 {klass}"
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'
: 'hover:bg-white/[3.2%]'} {klass}"
style="--mf-track-cols:{table.columns}; --mf-track-cols-mobile:{table.columnsMobile}"
{oncontextmenu}
>
@@ -29,7 +29,7 @@
const columns = $derived(
[
index ? '32px' : '',
'1fr',
'minmax(180px,1fr)',
...meta.map((column) => column.width),
'64px',
action ? '36px' : ''
@@ -39,7 +39,7 @@
);
const columnsMobile = $derived(
[index ? '28px' : '', '1fr', '52px', action ? '32px' : '']
[index ? '28px' : '', 'minmax(120px,1fr)', '52px', action ? '32px' : '']
.filter((width) => width !== '')
.join(' ')
);
@@ -54,9 +54,9 @@
});
</script>
<div class={klass}>
<div class="overflow-x-auto {klass}">
<div
class="grid track-grid items-center gap-3 border-b border-line pr-4 pb-2 pl-2 text-sm font-medium tracking-wide text-muted uppercase sm:gap-8 sm:pr-6 sm:pl-3"
class="grid track-grid items-center gap-3 border-b border-white/5 pr-4 pb-3 pl-2 text-[10.5px] font-semibold tracking-[0.12em] text-muted uppercase sm:gap-8 sm:pr-6 sm:pl-3"
style="--mf-track-cols:{columns}; --mf-track-cols-mobile:{columnsMobile}"
>
{#if index}
@@ -71,7 +71,7 @@
<span></span>
{/if}
</div>
<div class="mt-1 flex flex-col">
<div class="mt-1 flex flex-col gap-0.5">
{@render children()}
</div>
</div>
@@ -2,14 +2,17 @@
import type { Snippet } from 'svelte';
import Cover from './Cover.svelte';
import ExplicitBadge from './ExplicitBadge.svelte';
import ArtistLink from './ArtistLink.svelte';
interface Props {
trackId: string | number;
title: string;
artist?: string | null;
ownerUserId?: string | number | null;
coverSrc?: string | null;
coverSize?: string;
explicit?: boolean;
active?: boolean;
titleClass?: string;
onClick: () => void;
overlay?: Snippet;
@@ -20,17 +23,31 @@
trackId,
title,
artist,
ownerUserId,
coverSrc,
coverSize = 'h-[42px] w-[42px]',
explicit = false,
active = false,
titleClass = '',
onClick,
overlay,
badge
}: Props = $props();
function onKeydown(event: KeyboardEvent) {
if (event.key !== 'Enter' && event.key !== ' ') return;
event.preventDefault();
onClick();
}
</script>
<button type="button" onclick={onClick} class="flex min-w-0 items-center gap-3.5 text-left">
<div
role="button"
tabindex="0"
onclick={onClick}
onkeydown={onKeydown}
class="flex min-w-0 items-center gap-3.5 text-left"
>
<Cover
{trackId}
src={coverSrc}
@@ -45,11 +62,13 @@
{#if explicit}
<ExplicitBadge />
{/if}
<span class="min-w-0 truncate text-fg {titleClass}">{title}</span>
<span
class="min-w-0 truncate text-sm font-medium {active
? 'text-accent-soft'
: 'text-fg'} {titleClass}">{title}</span
>
{@render badge?.()}
</div>
{#if artist}
<div class="truncate text-left text-sm text-fg-3">{artist}</div>
{/if}
<ArtistLink name={artist} {ownerUserId} class="mt-0.5 text-[12.5px] text-fg-3" />
</div>
</button>
</div>
+6 -5
View File
@@ -9,12 +9,13 @@ export const LIBRARY_PICKER_PAGE_SIZE = 50;
export const EXPLORE_ALBUMS_PAGE_SIZE = 10;
export const EXPLORE_ALBUMS_LIMIT = 18;
export const HOME_LATEST_PAGE_SIZE = 10;
export const HOME_ALBUMS_LIMIT = 12;
export const HOME_MIXES_BENTO = 5;
export const HOME_MIXES_LIMIT = 6;
export const HOME_SHELF_LIMIT = 6;
export const HOME_CONTINUE_LIMIT = 6;
export const HOME_SPOTLIGHT_TRACKS_LIMIT = 5;
export const YOUTUBE_FILLER_LIMIT = 27;
export const HOME_TOP_MUSIC_FILLER_LIMIT = 10;
export const HOME_POPULAR_FILLER_LIMIT = 10;
export const HOME_POPULAR_MAX = 5;
export const HOME_ALBUM_FILLER_LIMIT = 8;
export const HOME_POPULAR_MAX = 8;
export const PLAYLIST_PICKER_PAGE_SIZE = 50;
export const LIKED_TRACKS_PAGE_SIZE = 500;
export const EXPLORE_USERS_LIMIT = 12;
+7
View File
@@ -5,6 +5,13 @@ export function fmtTime(seconds: number): string {
return `${minutes}:${rest < 10 ? '0' : ''}${rest}`;
}
export function fmtDurationLong(seconds: number): string {
const total = Math.max(0, Math.floor(seconds || 0));
const hours = Math.floor(total / 3600);
const minutes = Math.floor((total % 3600) / 60);
return hours > 0 ? `${hours} h ${minutes} min` : `${minutes} min`;
}
const dateFormatter = new Intl.DateTimeFormat('es', { dateStyle: 'medium' });
export function fmtDate(value: string | number | Date): string {
+95
View File
@@ -0,0 +1,95 @@
import { toQueueItems, type ApiTrackLike, type TrackSourceKind } from './player.svelte';
export interface LikedTrack {
id: string;
title: string;
artist?: string;
coverUrl?: string;
source?: TrackSourceKind;
explicit?: boolean;
ownerUserId?: string | number | null;
likedAt: number;
}
type LikeToggleInput = {
id: string | number;
title: string;
artist?: string;
coverUrl?: string;
source?: TrackSourceKind;
explicit?: boolean;
ownerUserId?: string | number | null;
};
function toggleRequestBody(track: LikeToggleInput) {
return track.source === 'youtube'
? { source: 'YouTube' as const, externalId: String(track.id) }
: { trackId: String(track.id) };
}
class LikedStore {
entries = $state<Record<string, LikedTrack>>({});
private hydrated = false;
count = $derived(Object.keys(this.entries).length);
list = $derived(Object.values(this.entries).sort((a, b) => b.likedAt - a.likedAt));
hydrate(tracks: (ApiTrackLike & { createdAt: string })[]) {
if (this.hydrated) return;
this.hydrated = true;
const items = toQueueItems(tracks);
const next: Record<string, LikedTrack> = {};
items.forEach((item, i) => {
next[String(item.id)] = {
id: String(item.id),
title: item.title,
artist: item.artist,
coverUrl: item.coverUrl,
source: item.source,
explicit: item.explicit,
ownerUserId: item.ownerUserId,
likedAt: new Date(tracks[i].createdAt).getTime()
};
});
this.entries = next;
}
isLiked(id: string | number): boolean {
return String(id) in this.entries;
}
async toggle(track: LikeToggleInput) {
const id = String(track.id);
const before = this.entries;
const wasLiked = id in before;
const next = { ...before };
if (wasLiked) delete next[id];
else {
next[id] = {
id,
title: track.title,
artist: track.artist,
coverUrl: track.coverUrl,
source: track.source,
explicit: track.explicit,
ownerUserId: track.ownerUserId,
likedAt: Date.now()
};
}
this.entries = next;
try {
const res = await fetch('/api/likes', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(toggleRequestBody(track))
});
if (!res.ok) throw new Error('Failed to toggle like');
} catch {
this.entries = before;
}
}
}
export const liked = new LikedStore();
+44 -3
View File
@@ -13,6 +13,7 @@ export interface PlayerTrack {
source: TrackSourceKind;
coverUrl?: string;
explicit?: boolean;
ownerUserId?: string | number | null;
}
export interface Playlist {
@@ -28,6 +29,7 @@ export interface QueueItem {
source?: TrackSourceKind;
coverUrl?: string;
explicit?: boolean;
ownerUserId?: string | number | null;
}
export interface ApiTrackLike {
@@ -38,6 +40,7 @@ export interface ApiTrackLike {
externalId?: string | null;
audioStatus?: 'Pending' | 'Processing' | 'Completed' | 'Failed';
isExplicit?: boolean;
ownerUserId?: string | number | null;
}
function isYouTubeTrack(track: ApiTrackLike): boolean {
@@ -69,7 +72,8 @@ export function toQueueItems(tracks: ApiTrackLike[]): QueueItem[] {
? youTubeThumbnailUrl(track.externalId)
: `/api/tracks/${track.id}/cover?size=small`
: undefined,
explicit: track.isExplicit
explicit: track.isExplicit,
ownerUserId: youTube ? undefined : track.ownerUserId
};
});
}
@@ -101,7 +105,8 @@ function toTrack(item: QueueItem): PlayerTrack {
duration: 0,
explicit: item.explicit,
source: item.source ?? 'local',
coverUrl: item.coverUrl
coverUrl: item.coverUrl,
ownerUserId: item.ownerUserId
};
}
@@ -115,6 +120,8 @@ class PlayerState {
error = $state('');
recentlyPlayed = $state<PlayerTrack[]>([]);
playlists = $state<Playlist[]>([]);
shuffle = $state(false);
repeat = $state(false);
accentColor = $state<string | null>(null);
@@ -154,6 +161,11 @@ class PlayerState {
});
audio.addEventListener('ended', () => {
this.#stopProgressLoop();
if (this.repeat) {
audio.currentTime = 0;
audio.play().catch(() => {});
return;
}
this.next();
});
audio.addEventListener('error', () => {
@@ -397,10 +409,39 @@ class PlayerState {
next() {
if (this.tracks.length === 0) return;
const idx = this.#index();
this.currentId = this.tracks[(idx + 1) % this.tracks.length].id;
let nextIdx = (idx + 1) % this.tracks.length;
if (this.shuffle && this.tracks.length > 1) {
do {
nextIdx = Math.floor(Math.random() * this.tracks.length);
} while (nextIdx === idx);
}
this.currentId = this.tracks[nextIdx].id;
this.#loadCurrent();
}
toggleShuffle() {
this.shuffle = !this.shuffle;
}
toggleRepeat() {
this.repeat = !this.repeat;
}
playQueueIndex(index: number) {
const track = this.tracks[index];
if (!track) return;
if (this.currentId === track.id) {
this.toggle();
return;
}
this.currentId = track.id;
this.#loadCurrent();
}
clearUpcoming() {
this.tracks = this.currentId === null ? [] : [this.current];
}
previous() {
if (this.tracks.length === 0) return;
const audio = this.#audioEl();
@@ -0,0 +1,16 @@
let open = $state(false);
export const queuePanel = {
get open() {
return open;
},
toggle() {
open = !open;
},
close() {
open = false;
},
show() {
open = true;
}
};
+13
View File
@@ -0,0 +1,13 @@
let open = $state(false);
export const createPlaylistModal = {
get open() {
return open;
},
show() {
open = true;
},
close() {
open = false;
}
};
+42
View File
@@ -0,0 +1,42 @@
import { player, toQueueItems, type ApiTrackLike, type QueueItem } from '$lib/player/player.svelte';
/**
* Merges the in-memory session history (tracks played since the page loaded)
* with the server-side listening history, de-duplicated by id and capped at `limit`.
*/
export function mergeRecentlyPlayed(history: ApiTrackLike[], limit: number): QueueItem[] {
const seen = new Set<string>();
const merged: QueueItem[] = [];
for (const t of player.recentlyPlayed) {
const id = String(t.id);
if (seen.has(id)) continue;
seen.add(id);
merged.push({
id,
title: t.title,
artist: t.artist,
source: t.source,
coverUrl: t.coverUrl,
explicit: t.explicit,
ownerUserId: t.ownerUserId
});
}
for (const item of toQueueItems(history)) {
const id = String(item.id);
if (seen.has(id)) continue;
seen.add(id);
merged.push({
id,
title: item.title,
artist: item.artist,
source: item.source ?? 'local',
coverUrl: item.coverUrl,
explicit: item.explicit,
ownerUserId: item.ownerUserId
});
}
return merged.slice(0, limit);
}
+104 -21
View File
@@ -1,24 +1,107 @@
const GREETINGS_BY_HOUR: { until: number; pool: string[] }[] = [
{
until: 6,
pool: ['¿Aún despierto?', 'La noche también tiene banda sonora', 'Un ratito más, con música']
},
{
until: 12,
pool: ['Buenos días', 'Que empiece bien la mañana', 'Un nuevo día, una nueva lista']
},
{ until: 14, pool: ['Buenos días', 'El mediodía sabe mejor con música'] },
{
until: 19,
pool: ['Buenas tardes', 'La tarde pide su banda sonora', 'Un respiro, con algo de música']
},
{ until: 23, pool: ['Buenas noches', 'Hora de bajar el ritmo', 'Que la noche te acompañe'] },
{ until: 24, pool: ['¿Aún despierto?', 'La noche también tiene banda sonora'] }
interface SpecialDate {
month: number;
day: number;
message: string;
}
const SPECIAL_DATES: SpecialDate[] = [
{ month: 1, day: 1, message: 'Nuevo año, nueva música.' },
{ month: 2, day: 14, message: 'Hoy también hay canciones para eso.' },
{ month: 10, day: 31, message: 'Esta noche suena diferente.' },
{ month: 12, day: 24, message: 'Nochebuena. Que no pare la música.' },
{ month: 12, day: 25, message: 'Feliz Navidad. Pon tu banda sonora.' },
{ month: 12, day: 31, message: 'Último día del año. Haz que suene bien.' }
];
export function pickGreeting(hour: number): string {
const pool =
GREETINGS_BY_HOUR.find((slot) => hour < slot.until)?.pool ?? GREETINGS_BY_HOUR[0].pool;
const greeting = pool[Math.floor(Math.random() * pool.length)];
return /[.!?]$/.test(greeting) ? greeting : `${greeting}.`;
const BIRTHDAY_MESSAGE = 'Hoy toca celebrar. Tú eliges la música.';
const HOUR_SLOTS = [6, 9, 12, 15, 18, 21, 24];
const WEEKLY_SCHEDULE: string[][] = [
[
'Domingo de madrugada.',
'Buenos días. Tómate el domingo con calma.',
'Domingo. Empieza despacio.',
'Una mañana tranquila merece buena música.',
'Domingo por la tarde. Hora de bajar el ritmo.',
'La semana empieza a despedirse.',
'Una última canción antes de volver a empezar.'
],
[
'La semana acaba de empezar.',
'Buenos días. Empezamos otra semana.',
'Lunes. Dale una banda sonora al día.',
'Una pausa musical para este lunes.',
'El lunes todavía no ha terminado.',
'El lunes ya empieza a quedar atrás.',
'Sobrevive al lunes con una última canción.'
],
[
'La noche sigue sonando.',
'Buenos días. Martes en marcha.',
'La semana ya está cogiendo ritmo.',
'Martes al mediodía. ¿Qué escuchamos?',
'Una tarde cualquiera necesita música.',
'El día empieza a bajar el ritmo.',
'Un martes más llega a su fin.'
],
[
'Mitad de semana, mitad de noche.',
'Buenos días. Ya es miércoles.',
'Ya estamos en el ecuador de la semana.',
'Miércoles al mediodía. Un poco de música.',
'La semana ya va cuesta abajo.',
'Unas canciones para terminar el miércoles.',
'Mitad de semana superada.'
],
[
'Una noche más antes del fin de semana.',
'Buenos días. Ya casi estamos.',
'Jueves. El fin de semana se acerca.',
'Una pausa musical antes de seguir.',
'Queda poco para desconectar.',
'Ya se empieza a notar el viernes.',
'Una noche más y llega el fin de semana.'
],
[
'La semana ha terminado. La noche continúa.',
'Buenos días. Es viernes.',
'Viernes. Hoy suena diferente.',
'Ya huele a fin de semana.',
'Últimas horas de la semana.',
'Se acabó la semana. Dale al play.',
'Viernes por la noche. Tú eliges la banda sonora.'
],
[
'La noche es joven. Que siga sonando.',
'Buenos días. Hoy no hay prisa.',
'Sábado. Empieza el día a tu ritmo.',
'Mediodía de sábado. ¿Qué ponemos?',
'Una tarde de sábado pide música.',
'El sábado todavía tiene mucho por delante.',
'Esta noche puede sonar cualquier cosa.'
]
];
export interface Birthday {
month: number;
day: number;
}
/**
* `birthday` is optional because the user's date of birth isn't part of the
* session/profile data yet. Wire it up here once that field exists upstream.
*/
export function pickGreeting(date: Date, birthday?: Birthday | null): string {
const month = date.getMonth() + 1;
const day = date.getDate();
if (birthday && birthday.month === month && birthday.day === day) return BIRTHDAY_MESSAGE;
const special = SPECIAL_DATES.find((d) => d.month === month && d.day === day);
if (special) return special.message;
const hour = date.getHours();
const slot = HOUR_SLOTS.findIndex((until) => hour < until);
return WEEKLY_SCHEDULE[date.getDay()][slot === -1 ? 0 : slot];
}
@@ -1,24 +0,0 @@
export interface DirectoryUser {
id: string;
name: string;
handle: string;
}
const DIRECTORY: DirectoryUser[] = [
{ id: 'u1', name: 'Alex Rivera', handle: 'alexrivera' },
{ id: 'u2', name: 'Marina Cordero', handle: 'marinac' },
{ id: 'u3', name: 'Diego Fuentes', handle: 'dfuentes' },
{ id: 'u4', name: 'Lucía Marés', handle: 'luciamares' },
{ id: 'u5', name: 'Noah Bennett', handle: 'noahb' },
{ id: 'u6', name: 'Sara Ibáñez', handle: 'saraib' },
{ id: 'u7', name: 'Kairo', handle: 'kairo' },
{ id: 'u8', name: 'Valentina Ortiz', handle: 'valen' }
];
export function searchUsers(query: string, limit = 12): DirectoryUser[] {
const term = query.trim().toLowerCase();
if (!term) return [];
return DIRECTORY.filter(
(user) => user.name.toLowerCase().includes(term) || user.handle.toLowerCase().includes(term)
).slice(0, limit);
}
+1 -1
View File
@@ -1,7 +1,7 @@
import type { createApiClient } from '$lib/server/api';
import type { YouTubeAlbumResult, YouTubeSong } from '$lib/types';
const YOUTUBE_FILLER_QUERIES = [
export const YOUTUBE_FILLER_QUERIES = [
'pop',
'rock',
'reggaeton',
+2 -2
View File
@@ -1,5 +1,5 @@
export const ACCENT_LIGHTNESS = 60;
export const ACCENT_CHROMA = 0.1;
export const ACCENT_LIGHTNESS = 74;
export const ACCENT_CHROMA = 0.145;
export const ACCENT_HUE = 145;
export const DEFAULT_ACCENT = `oklch(${ACCENT_LIGHTNESS}% ${ACCENT_CHROMA} ${ACCENT_HUE})`;
+40
View File
@@ -0,0 +1,40 @@
import { browser } from '$app/environment';
export type ThemeName = 'dark' | 'light';
const STORAGE_KEY = 'musify.theme';
function loadInitial(): ThemeName {
if (!browser) return 'dark';
try {
return localStorage.getItem(STORAGE_KEY) === 'light' ? 'light' : 'dark';
} catch {
return 'dark';
}
}
function applyTheme(theme: ThemeName) {
if (!browser) return;
document.documentElement.dataset.theme = theme;
}
class ThemeModeStore {
current = $state<ThemeName>(loadInitial());
constructor() {
applyTheme(this.current);
}
toggle() {
this.current = this.current === 'dark' ? 'light' : 'dark';
applyTheme(this.current);
if (!browser) return;
try {
localStorage.setItem(STORAGE_KEY, this.current);
} catch {
/* localStorage unavailable (private mode, quota) — preference just won't persist */
}
}
}
export const themeMode = new ThemeModeStore();
+134 -23
View File
@@ -1,39 +1,150 @@
:root {
--mf-bg: oklch(12.5% 0.004 95);
--mf-elevated: oklch(16% 0.005 95);
/* Dynamic tokens: overwritten at runtime from the playing track's hue.
These are the fallback values for the default accent (hue 145) so the
page never flashes unstyled before hydration. See theme/tokens.ts. */
--mf-bg: oklch(0.068 0.008 145);
--mf-elevated: oklch(0.11 0.012 145);
--mf-sidebar-bg: linear-gradient(180deg, oklch(0.095 0.012 145), oklch(0.068 0.007 145));
--mf-panel-bg: oklch(0.09 0.01 145 / 0.28);
--mf-bar-bg: oklch(0.105 0.012 145 / 0.9);
--mf-hairline: oklch(0.62 0.03 145 / 0.08);
--mf-ambient:
radial-gradient(52% 38% at 14% 0%, oklch(0.2611 0.0913 145 / 0.26), transparent 54%),
radial-gradient(46% 34% at 78% 0%, oklch(0.2347 0.085 145 / 0.2), transparent 56%);
--mf-hero-bg: linear-gradient(
105deg,
oklch(0.2295 0.0838 145 / 0.34) 0%,
oklch(0.1558 0.0663 145 / 0.11) 37%,
oklch(0.1084 0.055 145 / 0.03) 65%,
rgba(6, 6, 9, 0) 92%
);
--mf-spotlight-bg: linear-gradient(
100deg,
oklch(0.2189 0.0813 145 / 0.28) 0%,
oklch(0.1453 0.0638 145 / 0.1) 40%,
oklch(0.1084 0.055 145 / 0.03) 69%,
rgba(6, 6, 9, 0) 96%
);
--mf-modal-glow: linear-gradient(
135deg,
oklch(0.2295 0.0838 145 / 0.34) 0%,
oklch(0.1558 0.0663 145 / 0.11) 28%,
oklch(0.1084 0.055 145 / 0.03) 50%,
rgba(6, 6, 9, 0) 70%
);
--mf-logo-grad: linear-gradient(140deg, oklch(0.78 0.15 145), oklch(0.62 0.13 185) 92%);
--mf-logo-glow: 0 0 20px oklch(0.74 0.145 145 / 0.3);
--mf-cover-grad: linear-gradient(150deg, oklch(0.5 0.13 145), oklch(0.16 0.035 145));
--mf-text: oklch(93% 0.004 95);
--mf-text-2: oklch(78% 0.006 95);
--mf-text-3: oklch(62% 0.007 95);
--mf-text-4: oklch(46% 0.006 95);
--mf-surface: oklch(15.5% 0.005 95);
--mf-surface-2: rgba(255, 255, 255, 0.05);
--mf-surface-hover: rgba(255, 255, 255, 0.08);
--mf-border: rgba(255, 255, 255, 0.07);
--mf-border-strong: rgba(255, 255, 255, 0.13);
--mf-track: rgba(255, 255, 255, 0.12);
--mf-accent: oklch(60% 0.1 145);
--mf-accent-soft: color-mix(in oklch, var(--mf-accent) 80%, transparent);
--mf-accent: oklch(0.74 0.145 145);
--mf-accent-title: oklch(0.87 0.07 145);
--mf-accent-muted: oklch(0.79 0.035 145);
--mf-accent-soft-bg: oklch(0.74 0.145 145 / 0.08);
--mf-accent-line: oklch(0.74 0.145 145 / 0.2);
--mf-accent-hair: oklch(0.74 0.145 145 / 0.09);
--mf-accent-btn-bg: oklch(0.74 0.145 145 / 0.15);
--mf-accent-btn-bg-hover: oklch(0.74 0.145 145 / 0.24);
--mf-accent-glow: color-mix(in oklch, var(--mf-accent) 45%, transparent);
--mf-accent-dim: color-mix(in oklch, var(--mf-accent) 22%, transparent);
/* Fixed neutrals: deliberately hue-less, so only the accent carries color. */
--mf-text: #d9d8dc;
--mf-text-2: #8a8a8e;
--mf-text-3: #7d7d81;
--mf-text-4: #646468;
--mf-surface: rgba(255, 255, 255, 0.022);
--mf-surface-2: rgba(255, 255, 255, 0.045);
--mf-surface-hover: rgba(255, 255, 255, 0.06);
--mf-border: rgba(255, 255, 255, 0.04);
--mf-border-strong: rgba(255, 255, 255, 0.09);
--mf-track: rgba(255, 255, 255, 0.09);
--mf-scrim: #000000;
--mf-on-art: #fefdfb;
--mf-ink: #0a0a0d;
--mf-cta-strong-bg: #ffffff;
--mf-noise: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='180' height='180'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
--mf-sidebar-w: 288px;
--mf-nav-h: 60px;
--mf-player-h: 64px;
--mf-sidebar-w: 254px;
--mf-queue-w: 304px;
--mf-nav-h: 66px;
--mf-player-h: 78px;
--mf-safe-b: env(safe-area-inset-bottom, 0px);
}
@media (width >= 48rem) {
/* PlayerBar switches from its compact row to the full layout at `sm` (640px). */
@media (width >= 40rem) {
:root {
--mf-nav-h: 0px;
--mf-player-h: 92px;
--mf-player-h: 100px;
}
}
/* TabsBar (mobile/tablet nav) is replaced by the Sidebar at `lg` (1024px). */
@media (width >= 64rem) {
:root {
--mf-nav-h: 0px;
}
}
@media (width < 73.75rem) {
:root {
--mf-queue-w: 258px;
}
}
/* "Modo blanco" — flips the neutral canvas/surface/text tokens to a light
palette. Components that reference these tokens re-theme correctly;
components hardcoding literal dark-mode hex colors do not. */
:root[data-theme='light'] {
--mf-bg: oklch(0.97 0.006 90);
--mf-elevated: oklch(0.99 0.003 90);
--mf-sidebar-bg: linear-gradient(180deg, oklch(0.985 0.004 90), oklch(0.965 0.006 90));
--mf-panel-bg: oklch(0.97 0.006 90 / 0.7);
--mf-bar-bg: oklch(0.97 0.006 90 / 0.9);
--mf-hairline: rgba(0, 0, 0, 0.08);
--mf-text: #17171a;
--mf-text-2: #4a4a4e;
--mf-text-3: #6b6b6f;
--mf-text-4: #8c8c90;
--mf-surface: rgba(0, 0, 0, 0.035);
--mf-surface-2: rgba(0, 0, 0, 0.06);
--mf-surface-hover: rgba(0, 0, 0, 0.08);
--mf-border: rgba(0, 0, 0, 0.08);
--mf-border-strong: rgba(0, 0, 0, 0.14);
--mf-track: rgba(0, 0, 0, 0.12);
--mf-scrim: #ffffff;
--mf-ink: #ffffff;
--mf-cta-strong-bg: #17171a;
--mf-ambient:
radial-gradient(52% 38% at 14% 0%, oklch(0.9 0.06 145 / 0.26), transparent 54%),
radial-gradient(46% 34% at 78% 0%, oklch(0.9121 0.0497 145 / 0.2), transparent 56%);
--mf-hero-bg: linear-gradient(
105deg,
oklch(0.9145 0.0476 145 / 0.34) 0%,
oklch(0.9483 0.0186 145 / 0.11) 37%,
oklch(0.97 0 145 / 0.03) 65%,
rgba(255, 255, 255, 0) 92%
);
--mf-spotlight-bg: linear-gradient(
100deg,
oklch(0.9193 0.0434 145 / 0.28) 0%,
oklch(0.9531 0.0145 145 / 0.1) 40%,
oklch(0.97 0 145 / 0.03) 69%,
rgba(255, 255, 255, 0) 96%
);
--mf-modal-glow: linear-gradient(
135deg,
oklch(0.9145 0.0476 145 / 0.34) 0%,
oklch(0.9483 0.0186 145 / 0.11) 28%,
oklch(0.97 0 145 / 0.03) 50%,
rgba(255, 255, 255, 0) 70%
);
}
+86
View File
@@ -0,0 +1,86 @@
import { ACCENT_CHROMA, ACCENT_LIGHTNESS } from './color';
const L = ACCENT_LIGHTNESS / 100;
const C = ACCENT_CHROMA;
function r(value: number, digits = 4): string {
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})`;
}
// Light-mode counterpart of tc(): fades toward the light canvas (~0.97
// lightness, 0 chroma at l=88) instead of toward black, so the "glow"
// gradients read as a soft tint instead of a dark smudge on a light bg.
function tcLight(alpha: number, l: number, hue: number): string {
const depth = (88 - l) / 58;
const lightness = 0.97 - 0.07 * depth;
const chroma = Math.max(0, 0.06 * depth);
return `oklch(${r(lightness)} ${r(chroma)} ${r(hue, 1)} / ${alpha})`;
}
export interface ThemeTokens {
[cssVar: string]: string;
}
/**
* Every color in the app derives from the hue of the track in playback.
* See design_handoff_musify_player/README.md § "Sistema de color dinámico".
*
* `mode` only affects the neutral canvas tokens (bg/elevated/panel/hairline):
* these are re-applied as inline styles on every hue change, so a light-mode
* override in theme.css alone can never win against them — the light values
* have to come from here instead.
*/
export function buildThemeTokens(hue: number, mode: 'dark' | 'light' = 'dark'): ThemeTokens {
const h = ((hue % 360) + 360) % 360;
const h2 = (h + 40) % 360;
const H = r(h, 1);
const H2 = r(h2, 1);
const light = mode === 'light';
return {
'--mf-accent': `oklch(${r(L)} ${r(C)} ${H})`,
'--mf-accent-title': `oklch(0.87 0.07 ${H})`,
'--mf-accent-muted': `oklch(0.79 0.035 ${H})`,
'--mf-accent-soft-bg': `oklch(${r(L)} ${r(C)} ${H} / 0.08)`,
'--mf-accent-line': `oklch(${r(L)} ${r(C)} ${H} / 0.2)`,
'--mf-accent-hair': `oklch(${r(L)} ${r(C)} ${H} / 0.09)`,
'--mf-accent-btn-bg': `oklch(${r(L)} ${r(C)} ${H} / 0.15)`,
'--mf-accent-btn-bg-hover': `oklch(${r(L)} ${r(C)} ${H} / 0.24)`,
'--mf-bg': light ? `oklch(0.97 0.006 ${H})` : `oklch(0.068 0.008 ${H})`,
'--mf-elevated': light ? `oklch(0.99 0.003 ${H})` : `oklch(0.11 0.012 ${H})`,
'--mf-sidebar-bg': light
? `linear-gradient(180deg, oklch(0.985 0.004 ${H}), oklch(0.965 0.006 ${H}))`
: `linear-gradient(180deg, oklch(0.095 0.012 ${H}), oklch(0.068 0.007 ${H}))`,
'--mf-panel-bg': light ? `oklch(0.97 0.006 ${H} / 0.7)` : `oklch(0.09 0.01 ${H} / 0.28)`,
'--mf-bar-bg': light ? `oklch(0.97 0.006 ${H} / 0.9)` : `oklch(0.105 0.012 ${H} / 0.9)`,
'--mf-hairline': light ? `oklch(0.3 0.02 ${H} / 0.1)` : `oklch(0.62 0.03 ${H} / 0.08)`,
'--mf-ambient': light
? `radial-gradient(52% 38% at 14% 0%, ${tcLight(0.26, 30, h)}, transparent 54%), radial-gradient(46% 34% at 78% 0%, ${tcLight(0.2, 40, h)}, transparent 56%)`
: `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%)`,
'--mf-hero-bg': light
? `linear-gradient(105deg, ${tcLight(0.34, 42, h)} 0%, ${tcLight(0.11, 70, h)} 37%, ${tcLight(0.03, 88, h)} 65%, rgba(255,255,255,0) 92%)`
: `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%)`,
'--mf-modal-glow': light
? `linear-gradient(135deg, ${tcLight(0.34, 42, h)} 0%, ${tcLight(0.11, 70, h)} 28%, ${tcLight(0.03, 88, h)} 50%, rgba(255,255,255,0) 70%)`
: `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%)`,
'--mf-spotlight-bg': light
? `linear-gradient(100deg, ${tcLight(0.28, 46, h)} 0%, ${tcLight(0.1, 74, h)} 40%, ${tcLight(0.03, 88, h)} 69%, rgba(255,255,255,0) 96%)`
: `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%)`,
'--mf-logo-grad': `linear-gradient(140deg, oklch(0.78 0.15 ${H}), oklch(0.62 0.13 ${H2}) 92%)`,
'--mf-logo-glow': `0 0 20px oklch(${r(L)} ${r(C)} ${H} / 0.3)`,
'--mf-cover-grad': `linear-gradient(150deg, oklch(0.5 0.13 ${H}), oklch(0.16 0.035 ${H}))`
};
}
export function applyThemeTokens(
tokens: ThemeTokens,
target: HTMLElement = document.documentElement
) {
for (const [key, value] of Object.entries(tokens)) target.style.setProperty(key, value);
}
+6 -1
View File
@@ -22,6 +22,10 @@ export function targetArtist(target: TrackTarget): string | undefined {
return (target.kind === 'youtube' ? target.song.artist : target.track.artist) ?? undefined;
}
export function targetOwnerUserId(target: TrackTarget): string | number | undefined {
return target.kind === 'youtube' ? undefined : (target.track.ownerUserId ?? undefined);
}
export function targetExplicit(target: TrackTarget): boolean {
return target.kind === 'youtube' ? target.song.isExplicit : (target.track.isExplicit ?? false);
}
@@ -76,7 +80,8 @@ export function targetForQueueItem(item: QueueItem): TrackTarget {
title: item.title,
artist: item.artist,
source: 'Local',
isExplicit: item.explicit
isExplicit: item.explicit,
ownerUserId: item.ownerUserId
}
};
}
@@ -35,7 +35,7 @@
</PageHeader>
{#if items.length > 0}
<MediaGrid class="mt-9">
<MediaGrid class="mt-7 sm:mt-8">
{#each items as album, i (album.id)}
<AlbumCard
id={album.id}
@@ -55,10 +55,7 @@
description="Crea el primero y añádele las canciones que hayas subido."
>
{#snippet actions()}
<Button onclick={() => (creating = true)}>
<Plus class="h-4 w-4" strokeWidth={2} />
Crear álbum
</Button>
<Button onclick={() => (creating = true)}>Crear álbum</Button>
{/snippet}
</EmptyState>
{/if}
@@ -42,7 +42,7 @@ export const load: PageServerLoad = async ({ params, locals, url, fetch, parent
(track) => track.source === 'Local' && !inAlbum.has(track.id)
);
const isOwner = user !== null && Number(album.ownerUserId) === Number(user.sub);
const isOwner = user !== null && String(album.ownerUserId) === user.sub;
return { album, tracks, library, isOwner, section: isOwner ? '/albums' : null };
};
@@ -48,17 +48,17 @@
label={isOwner ? 'Volver a tus álbumes' : 'Volver al inicio'}
/>
<div class="mt-5 flex flex-col gap-5 sm:mt-6 sm:flex-row sm:items-end sm:gap-6">
<div class="mt-3 flex flex-col gap-5 sm:mt-4 sm:flex-row sm:items-end sm:gap-6">
<PlaylistArt
{trackIds}
{coverUrl}
size="large"
class="h-36 w-36 shrink-0 rounded-art-lg shadow-art-lg sm:h-44 sm:w-44"
class="h-36 w-36 shrink-0 rounded-[18px] shadow-[0_24px_56px_rgba(0,0,0,.55)] sm:h-41 sm:w-41"
/>
<div class="min-w-0 flex-1">
<p class="text-sm tracking-[0.14em] text-fg-3 uppercase">Álbum</p>
<p class="text-[10.5px] font-semibold tracking-[0.16em] text-fg-2 uppercase">Álbum</p>
<h1
class="mt-1.5 font-display text-3xl font-semibold tracking-tight break-words text-fg sm:text-5xl md:text-7xl"
class="mt-1.5 font-display text-[28px] font-semibold tracking-[-0.035em] break-words text-fg sm:text-[33px]"
>
{title}
</h1>
@@ -72,10 +72,10 @@
<div class="mt-6 flex flex-wrap items-center gap-2.5 sm:mt-7">
<Button size="sm" onclick={onPlayAll} disabled={trackIds.length === 0}>
{#if playing}
<Pause class="h-4 w-4" fill="currentColor" strokeWidth={1.5} />
<Pause class="h-4 w-4" strokeWidth={1.5} />
Pausar
{:else}
<Play class="h-4 w-4" fill="currentColor" strokeWidth={1.5} />
<Play class="h-4 w-4" strokeWidth={1.5} />
Reproducir
{/if}
</Button>
@@ -31,18 +31,16 @@
<TrackTable index action={isOwner} class="mt-6 sm:mt-8">
{#each tracks as track, i (track.id)}
<TrackRow>
<TrackIndexCell
index={i}
active={player.current.id === queueIdForTrack(track)}
playing={player.playing}
onToggle={() => playFrom(i)}
/>
{@const active = player.current.id === queueIdForTrack(track)}
<TrackRow {active}>
<TrackIndexCell index={i} {active} playing={player.playing} onToggle={() => playFrom(i)} />
<TrackTitleCell
trackId={track.id}
title={track.title}
artist={track.artist}
ownerUserId={track.ownerUserId}
explicit={track.isExplicit}
{active}
onClick={() => playFrom(i)}
/>
<TrackMeta>{fmtTime(Number(track.duration))}</TrackMeta>
@@ -28,12 +28,15 @@
<SectionHeading title="Tus canciones subidas" />
<TrackTable action>
{#each library as track, i (track.id)}
<TrackRow>
{@const active = player.current.id === track.id}
<TrackRow {active}>
<TrackTitleCell
trackId={track.id}
title={track.title}
artist={track.artist}
ownerUserId={track.ownerUserId}
explicit={track.isExplicit}
{active}
onClick={() => playFrom(i)}
>
{#snippet overlay()}
@@ -79,21 +79,21 @@
<Page>
<BackLink href="/explore" label="Volver a explorar" />
<div class="mt-5 flex flex-col gap-5 sm:mt-6 sm:flex-row sm:items-end sm:gap-6">
<div class="mt-3 flex flex-col gap-5 sm:mt-4 sm:flex-row sm:items-end sm:gap-6">
<Cover
trackId={album.albumId}
src={album.thumbnailUrl}
size="large"
alt={album.title}
class="h-36 w-36 shrink-0 rounded-art-lg shadow-art-lg sm:h-44 sm:w-44"
class="h-36 w-36 shrink-0 rounded-[18px] shadow-[0_24px_56px_rgba(0,0,0,.55)] sm:h-41 sm:w-41"
oncontextmenu={(e) => (albumMenu = albumContextMenuStateFor(e, 'youtube', album.albumId))}
/>
<div class="min-w-0 flex-1">
<p class="text-sm tracking-[0.14em] text-fg-3 uppercase">
<p class="text-[10.5px] font-semibold tracking-[0.16em] text-fg-2 uppercase">
{album.isSingle ? 'Single' : album.isEp ? 'EP' : 'Álbum'}
</p>
<h1
class="mt-1.5 font-display text-3xl font-semibold tracking-tight break-words text-fg sm:text-5xl md:text-7xl"
class="mt-1.5 font-display text-[28px] font-semibold tracking-[-0.035em] break-words text-fg sm:text-[33px]"
>
{album.title}
</h1>
@@ -113,10 +113,10 @@
<div class="mt-6 flex flex-wrap items-center gap-2.5 sm:mt-7">
<Button size="sm" onclick={playAll} disabled={queue.length === 0}>
{#if isCurrentQueue && player.playing}
<Pause class="h-4 w-4" fill="currentColor" strokeWidth={1.5} />
<Pause class="h-4 w-4" strokeWidth={1.5} />
Pausar
{:else}
<Play class="h-4 w-4" fill="currentColor" strokeWidth={1.5} />
<Play class="h-4 w-4" strokeWidth={1.5} />
Reproducir
{/if}
</Button>
@@ -132,19 +132,19 @@
<TrackTable index class="mt-6 sm:mt-8">
{#each tracks as track, i (track.videoId)}
<TrackRow oncontextmenu={(e) => (contextMenu = contextMenuStateFor(e, targetFor(i)))}>
<TrackIndexCell
index={i}
active={player.current.id === track.videoId}
playing={player.playing}
onToggle={() => playFrom(i)}
/>
{@const active = player.current.id === track.videoId}
<TrackRow
{active}
oncontextmenu={(e) => (contextMenu = contextMenuStateFor(e, targetFor(i)))}
>
<TrackIndexCell index={i} {active} playing={player.playing} onToggle={() => playFrom(i)} />
<TrackTitleCell
trackId={track.videoId}
title={track.title}
artist={album.artist}
coverSrc={album.thumbnailUrl}
explicit={track.isExplicit}
{active}
onClick={() => playFrom(i)}
/>
<TrackMeta>{fmtTime(Number(track.durationSeconds))}</TrackMeta>
@@ -1,6 +1,6 @@
import type { PageServerLoad, Actions } from './$types';
import { createApiClient, unwrapOrError } from '$lib/server/api';
import { fetchYoutubeFiller } from '$lib/server/youtube';
import { YOUTUBE_FILLER_QUERIES } from '$lib/server/youtube';
import {
addAlbumToPlaylistAction,
addTrackAction,
@@ -10,9 +10,8 @@ import {
import {
EXPLORE_ALBUMS_PAGE_SIZE,
EXPLORE_PAGE_SIZE as PAGE_SIZE,
YOUTUBE_FILLER_LIMIT
EXPLORE_USERS_LIMIT
} from '$lib/config';
import { searchUsers } from '$lib/server/userDirectory';
export const load: PageServerLoad = async ({ url, locals, fetch }) => {
const query = url.searchParams.get('q')?.trim() ?? '';
@@ -39,10 +38,17 @@ export const load: PageServerLoad = async ({ url, locals, fetch }) => {
})
: Promise.resolve(null);
const [tracksRes, playlistsRes, albumsRes] = await Promise.all([
const usersPromise = query
? api.GET('/users', {
params: { query: { usernameSearch: query, pageNumber: 1, pageSize: EXPLORE_USERS_LIMIT } }
})
: Promise.resolve(null);
const [tracksRes, playlistsRes, albumsRes, usersRes] = await Promise.all([
tracksPromise,
playlistsPromise,
albumsPromise
albumsPromise,
usersPromise
]);
const tracks = unwrapOrError(tracksRes, 'No se pudieron cargar las canciones.');
@@ -54,10 +60,8 @@ export const load: PageServerLoad = async ({ url, locals, fetch }) => {
tracks,
albums: albumItems.flatMap((item) => (item.album ? [item.album] : [])),
youtubeAlbums: albumItems.flatMap((item) => (item.youTubeAlbum ? [item.youTubeAlbum] : [])),
users: searchUsers(query),
youtubeFiller: query
? Promise.resolve(null)
: fetchYoutubeFiller(api, locals.accessToken, YOUTUBE_FILLER_LIMIT),
users: usersRes?.data?.items ?? [],
genres: YOUTUBE_FILLER_QUERIES,
playlists: playlistsRes?.data?.items ?? []
};
};
+163 -159
View File
@@ -1,8 +1,5 @@
<script lang="ts">
import Search from '@lucide/svelte/icons/search';
import Music from '@lucide/svelte/icons/music';
import { untrack } from 'svelte';
import { goto } from '$app/navigation';
import { player } from '$lib/player/player.svelte';
import { fetchAlbumQueueItems } from '$lib/albums';
import { fmtTime } from '$lib/format';
@@ -10,17 +7,16 @@
import PageHeader from '$lib/components/ui/PageHeader.svelte';
import MediaGrid from '$lib/components/ui/MediaGrid.svelte';
import Alert from '$lib/components/ui/Alert.svelte';
import Input from '$lib/components/ui/Input.svelte';
import EmptyState from '$lib/components/ui/EmptyState.svelte';
import InfiniteScroll from '$lib/components/ui/InfiniteScroll.svelte';
import Cover from '$lib/components/ui/Cover.svelte';
import PlaylistArt from '$lib/components/ui/PlaylistArt.svelte';
import AlbumCard from '$lib/components/ui/AlbumCard.svelte';
import ArtistAvatar from '$lib/components/ui/ArtistAvatar.svelte';
import Rail from '$lib/components/ui/Rail.svelte';
import NowPlaying from '$lib/components/ui/NowPlaying.svelte';
import ExplicitBadge from '$lib/components/ui/ExplicitBadge.svelte';
import SectionHeading from '$lib/components/ui/SectionHeading.svelte';
import { EXPLORE_ALBUMS_LIMIT, EXPLORE_PAGE_SIZE, SEARCH_DEBOUNCE_MS } from '$lib/config';
import { EXPLORE_ALBUMS_LIMIT, EXPLORE_PAGE_SIZE } from '$lib/config';
import type { YouTubeSong } from '$lib/types';
import TrackContextMenu, {
contextMenuStateFor,
@@ -42,10 +38,24 @@
type TrackTarget
} from '$lib/tracks';
let { data, form } = $props();
const GENRE_INFO: Record<string, { label: string; tagline: string }> = {
pop: { label: 'Pop', tagline: 'Éxitos que suenan en todas partes' },
rock: { label: 'Rock', tagline: 'Guitarras, actitud y ruido' },
reggaeton: { label: 'Reggaetón', tagline: 'El ritmo que no para' },
'lo-fi': { label: 'Lo-fi', tagline: 'Para concentrarte o relajarte' },
indie: { label: 'Indie', tagline: 'Voces fuera del radar' },
salsa: { label: 'Salsa', tagline: 'Para mover el cuerpo' },
'k-pop': { label: 'K-pop', tagline: 'Coreografías pegajosas' },
electrónica: { label: 'Electrónica', tagline: 'Beats para perderte' },
jazz: { label: 'Jazz', tagline: 'Improvisación y elegancia' },
baladas: { label: 'Baladas', tagline: 'Para sentir con calma' },
trap: { label: 'Trap', tagline: 'Autotune y bajos pesados' },
cumbia: { label: 'Cumbia', tagline: 'El sabor de siempre' },
'hip hop': { label: 'Hip hop', tagline: 'Rimas con actitud' },
'música clásica': { label: 'Clásica', tagline: 'Siglos de composición' }
};
let searchValue = $state(untrack(() => data.query));
let searchTimeout: ReturnType<typeof setTimeout> | undefined;
let { data, form } = $props();
let ytItems = $state<YouTubeSong[]>([]);
let ytUnavailable = $state(false);
@@ -71,26 +81,26 @@
if (data.query) {
ytItems = appendUnique(
[],
data.tracks.items.flatMap((item) => (item.youTubeSong ? [item.youTubeSong as YouTubeSong] : [])),
data.tracks.items.flatMap((item) =>
item.youTubeSong ? [item.youTubeSong as YouTubeSong] : []
),
(i) => i.videoId
);
ytUnavailable = data.tracks.youtubeUnavailable;
return;
} else {
ytItems = [];
ytUnavailable = false;
}
ytItems = [];
ytUnavailable = false;
let cancelled = false;
data.youtubeFiller?.then((filler) => {
if (cancelled || !filler) return;
ytItems = appendUnique([], filler.items, (i) => i.videoId);
});
return () => {
cancelled = true;
};
});
const genreTiles = $derived(
data.genres.map((query, i) => ({
query,
hue: Math.round((i * 360) / data.genres.length),
...(GENRE_INFO[query] ?? { label: query, tagline: 'Explora este estilo' })
}))
);
const songRows = $derived([
...localItems.map((track) => ({
target: { kind: 'local' as const, track },
@@ -126,35 +136,10 @@
!!data.query && !ytUnavailable && items.length === 0 && !hasAlbums && !hasUsers
);
function localAlbumCaption(album: LocalAlbum) {
return [
album.releaseYear === null ? undefined : String(album.releaseYear),
`${Number(album.trackCount)} ${Number(album.trackCount) === 1 ? 'canción' : 'canciones'}`
]
.filter(Boolean)
.join(' · ');
}
function youtubeAlbumCaption(album: YouTubeAlbum) {
return [album.artist, album.releaseYear === null ? undefined : String(album.releaseYear)]
.filter(Boolean)
.join(' · ');
}
function buildHref(query: string) {
return query ? `/explore?q=${encodeURIComponent(query)}` : '/explore';
}
function onSearchInput(event: Event) {
const value = (event.currentTarget as HTMLInputElement).value;
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => {
const term = value.trim();
if (term === data.query) return;
goto(buildHref(term), { keepFocus: true, replaceState: true, noScroll: true });
}, SEARCH_DEBOUNCE_MS);
}
function togglePlay(index: number) {
player.playOrToggle(items.map(queueItemForTarget), index);
}
@@ -224,87 +209,99 @@
</script>
<svelte:head>
<title>Buscar</title>
<meta name="description" content="Busca canciones, álbumes y usuarios en Musify." />
<title>{data.query ? `Buscar «${data.query}»` : 'Descubrir'}</title>
<meta
name="description"
content="Busca canciones, álbumes y personas, o descubre por género en Musify."
/>
</svelte:head>
<Page>
<PageHeader title="Buscar" subtitle="Encuentra canciones, álbumes y personas." />
<div class="mt-6">
<Input
type="search"
icon={Search}
bind:value={searchValue}
oninput={onSearchInput}
placeholder="¿Qué quieres reproducir?"
{#if !data.query}
<PageHeader
title="Descubrir"
subtitle="Encuentra tu próxima canción favorita explorando por género."
/>
</div>
{:else if !nothingFound}
<PageHeader title="Resultados para «{data.query}»" />
{/if}
{#if form?.message}
<Alert class="mt-4">{form.message}</Alert>
{/if}
{#if nothingFound}
<EmptyState
icon={Music}
title="Sin resultados"
description="No hay resultados para «{data.query}»."
/>
<div class="mx-auto max-w-115 py-[8vh] text-center">
<h1 class="font-display text-xl font-semibold tracking-[-0.025em] text-fg">
Sin resultados para «{data.query}»
</h1>
<p class="mt-2.5 text-[13px] leading-[1.65] text-fg-2">
Revisa la ortografía o prueba con un término más corto. También puedes buscar por álbum o
por playlist.
</p>
<div class="mt-5.5 flex flex-wrap justify-center gap-2">
{#each genreTiles.slice(0, 4) as genre (genre.query)}
<a
href={buildHref(genre.query)}
class="rounded-full border border-white/8 px-3.5 py-2 text-xs text-fg-2 transition-colors hover:bg-white/5 hover:text-fg"
>
{genre.label}
</a>
{/each}
</div>
</div>
{/if}
{#if !data.query}
<!-- GÉNEROS -->
<div class="mt-7 grid grid-cols-[repeat(auto-fill,minmax(268px,1fr))] gap-3.5">
{#each genreTiles as genre, i (genre.query)}
<a
href={buildHref(genre.query)}
class="animate-enter group relative block h-28 overflow-hidden rounded-2xl p-4.25 transition-[filter] hover:brightness-125"
style="animation-delay:{Math.min(i, 10) *
40}ms; background:linear-gradient(140deg, oklch(0.24 0.07 {genre.hue}), rgba(9,9,12,.95))"
>
<div class="font-display text-[15.5px] font-semibold tracking-[-0.02em] text-fg">
{genre.label}
</div>
<div class="mt-1.5 text-[11.5px] text-fg-3">{genre.tagline}</div>
</a>
{/each}
</div>
{/if}
{#if hasAlbums}
<div class="mt-8">
<div class="mt-9">
<SectionHeading title="Álbumes" />
<MediaGrid as="ul" min="168px" minMobile="150px">
{#each albumEntries as entry, i (entry.kind === 'local' ? entry.album.id : entry.album.albumId)}
<li>
{#if entry.kind === 'local'}
<a
href="/albums/{entry.album.id}"
class="group/card animate-enter block min-w-0 rounded-art focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-bg focus-visible:outline-none"
style="animation-delay:{Math.min(i, 10) * 45}ms"
oncontextmenu={(e) => openAlbumMenu(e, 'local', entry.album.id)}
>
<div class="relative">
<PlaylistArt
trackIds={entry.album.coverTrackIds}
class="aspect-square w-full rounded-art shadow-art ring-1 ring-line transition duration-300 ease-out ring-inset group-hover/card:shadow-art-lg"
/>
</div>
<div
class="mt-2.5 truncate text-fg transition-colors group-hover/card:text-accent-soft"
>
{entry.album.title}
</div>
<div class="mt-0.5 truncate text-sm text-fg-3">
{localAlbumCaption(entry.album)}
</div>
</a>
<AlbumCard
id={entry.album.id}
title={entry.album.title}
releaseYear={entry.album.releaseYear === null
? undefined
: Number(entry.album.releaseYear)}
trackCount={Number(entry.album.trackCount)}
trackIds={entry.album.coverTrackIds}
index={i}
onContextMenu={(e) => openAlbumMenu(e, 'local', entry.album.id)}
/>
{:else}
<a
<AlbumCard
id={entry.album.albumId}
href="/albums/external/youtube/{entry.album.albumId}"
class="group/card animate-enter block min-w-0 rounded-art focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-bg focus-visible:outline-none"
style="animation-delay:{Math.min(i, 10) * 45}ms"
oncontextmenu={(e) => openAlbumMenu(e, 'youtube', entry.album.albumId)}
>
<div class="relative">
<Cover
trackId={entry.album.albumId}
src={entry.album.thumbnailUrl}
size="large"
alt={entry.album.title}
class="aspect-square w-full rounded-art shadow-art ring-1 ring-line transition duration-300 ease-out ring-inset group-hover/card:shadow-art-lg"
/>
</div>
<div
class="mt-2.5 truncate text-fg transition-colors group-hover/card:text-accent-soft"
>
{entry.album.title}
</div>
<div class="mt-0.5 truncate text-sm text-fg-3">
{youtubeAlbumCaption(entry.album)}
</div>
</a>
title={entry.album.title}
subtitle={entry.album.artist}
releaseYear={entry.album.releaseYear === null
? undefined
: Number(entry.album.releaseYear)}
coverSrc={entry.album.thumbnailUrl}
index={i}
onContextMenu={(e) => openAlbumMenu(e, 'youtube', entry.album.albumId)}
/>
{/if}
</li>
{/each}
@@ -313,71 +310,78 @@
{/if}
{#if hasUsers}
<div class="mt-8">
<div class="mt-9">
<SectionHeading title="Usuarios" />
<Rail>
{#each users as u, i (u.id)}
<div
<a
href="/u/{u.id}"
class="group/artist animate-enter shrink-0"
style="animation-delay:{Math.min(i, 10) * 45}ms"
>
<ArtistAvatar name={u.name} size={96} />
<div class="mt-1 w-24 truncate text-center text-xs text-fg-3">@{u.handle}</div>
</div>
<ArtistAvatar name={u.name} imageUrl={u.profilePictureUrl} size={96} />
</a>
{/each}
</Rail>
</div>
{/if}
<div class="mt-6">
{#if songRows.length > 0}
<ul class="flex flex-col gap-1">
{#each songRows as { target: item, seconds }, i (targetId(item))}
<li>
<button
type="button"
onclick={() => togglePlay(i)}
oncontextmenu={(e) => openContextMenu(e, item)}
aria-label="Reproducir {targetTitle(item)}"
class="group/row flex w-full min-w-0 items-center gap-3.5 rounded-control p-2 pr-4 text-left transition hover:bg-surface focus-visible:ring-2 focus-visible:ring-accent focus-visible:outline-none"
>
<Cover
trackId={targetId(item)}
src={targetCoverSrc(item)}
size="small"
alt={targetTitle(item)}
class="h-11 w-11 shrink-0 rounded-control shadow-art ring-1 ring-line ring-inset"
{#if data.query}
<div class="mt-9">
{#if songRows.length > 0}
<SectionHeading title="Canciones" />
<ul class="flex flex-col gap-1">
{#each songRows as { target: item, seconds }, i (targetId(item))}
<li>
<button
type="button"
onclick={() => togglePlay(i)}
oncontextmenu={(e) => openContextMenu(e, item)}
aria-label="Reproducir {targetTitle(item)}"
class="group/row flex w-full min-w-0 items-center gap-3.5 rounded-control p-2 pr-4 text-left transition hover:bg-surface focus-visible:ring-2 focus-visible:ring-accent focus-visible:outline-none"
>
{#if isTargetCurrent(item)}
<NowPlaying paused={!player.playing} />
{/if}
</Cover>
<div class="min-w-0 flex-1">
<div class="flex min-w-0 items-center gap-1.5">
{#if targetExplicit(item)}
<ExplicitBadge />
<Cover
trackId={targetId(item)}
src={targetCoverSrc(item)}
size="small"
alt={targetTitle(item)}
class="h-11 w-11 shrink-0 rounded-control shadow-art ring-1 ring-line ring-inset"
>
{#if isTargetCurrent(item)}
<NowPlaying paused={!player.playing} />
{/if}
</Cover>
<div class="min-w-0 flex-1">
<div class="flex min-w-0 items-center gap-1.5">
{#if targetExplicit(item)}
<ExplicitBadge />
{/if}
<span class="truncate text-[13px] font-medium text-fg">
{targetTitle(item)}
</span>
</div>
{#if targetArtist(item)}
<div class="truncate text-[11.5px] text-fg-3">
Canción · {targetArtist(item)}
</div>
{/if}
<span class="truncate text-fg transition-colors group-hover/row:text-accent-soft">
{targetTitle(item)}
</span>
</div>
{#if targetArtist(item)}
<div class="truncate text-sm text-fg-3">Canción · {targetArtist(item)}</div>
{/if}
</div>
<span class="shrink-0 text-sm text-muted tabular-nums">{fmtTime(seconds)}</span>
</button>
</li>
{/each}
</ul>
<span class="shrink-0 text-[11.5px] text-muted tabular-nums"
>{fmtTime(seconds)}</span
>
</button>
</li>
{/each}
</ul>
{#if hasMore}
<InfiniteScroll onLoadMore={loadMore} {hasMore} loading={loadingMore} />
{#if hasMore}
<InfiniteScroll onLoadMore={loadMore} {hasMore} loading={loadingMore} />
{/if}
{:else if !ytUnavailable && !nothingFound}
<EmptyState icon={Music} description="No hay canciones que coincidan con «{data.query}»." />
{/if}
{:else if data.query && !ytUnavailable}
<EmptyState icon={Music} description="No hay canciones que coincidan con «{data.query}»." />
{/if}
</div>
</div>
{/if}
{#if ytUnavailable}
<EmptyState
@@ -51,20 +51,28 @@
index
action
meta={[
{ label: 'Álbum', width: '160px' },
{ label: 'Subida', width: '100px' },
{ label: 'Escuchas', width: '100px' }
]}
class="mt-6 sm:mt-8"
class="mt-7 sm:mt-8"
>
{#each items as track, i (track.id)}
<TrackRow>
{@const active = player.current.id === track.id}
<TrackRow {active}>
<TrackIndexCell
index={i}
active={player.current.id === track.id}
{active}
playing={player.playing}
onToggle={() => togglePlay(i)}
/>
<TrackTitleCell trackId={track.id} title={track.title} onClick={() => togglePlay(i)} />
<TrackTitleCell
trackId={track.id}
title={track.title}
{active}
onClick={() => togglePlay(i)}
/>
<TrackMeta class="hidden sm:block">—</TrackMeta>
<TrackMeta class="hidden sm:block">{fmtDate(track.createdAt)}</TrackMeta>
<TrackMeta class="hidden sm:block">{Number(track.listensCount)}</TrackMeta>
<TrackMeta>{fmtTime(Number(track.duration))}</TrackMeta>
@@ -0,0 +1,7 @@
import { redirect } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = ({ locals }) => {
if (!locals.user) redirect(302, '/explore');
return {};
};
@@ -0,0 +1,136 @@
<script lang="ts">
import Heart from '@lucide/svelte/icons/heart';
import Play from '@lucide/svelte/icons/play';
import Pause from '@lucide/svelte/icons/pause';
import X from '@lucide/svelte/icons/x';
import { player, type QueueItem } from '$lib/player/player.svelte';
import { liked } from '$lib/player/liked.svelte';
import { fmtDate } from '$lib/format';
import Page from '$lib/components/ui/Page.svelte';
import Button from '$lib/components/ui/Button.svelte';
import IconButton from '$lib/components/ui/IconButton.svelte';
import EmptyState from '$lib/components/ui/EmptyState.svelte';
import TrackTable from '$lib/components/ui/TrackTable.svelte';
import TrackRow from '$lib/components/ui/TrackRow.svelte';
import TrackMeta from '$lib/components/ui/TrackMeta.svelte';
import TrackIndexCell from '$lib/components/ui/TrackIndexCell.svelte';
import TrackTitleCell from '$lib/components/ui/TrackTitleCell.svelte';
const tracks = $derived(liked.list);
const queueItems = $derived<QueueItem[]>(
tracks.map((t) => ({
id: t.id,
title: t.title,
artist: t.artist,
source: t.source,
coverUrl: t.coverUrl,
explicit: t.explicit
}))
);
const isCurrentQueue = $derived(tracks.some((t) => t.id === player.current.id));
function playFrom(index: number) {
player.playOrToggle(queueItems, index);
}
function playAll() {
if (queueItems.length === 0) return;
if (isCurrentQueue) player.toggle();
else player.playQueue(queueItems, 0);
}
</script>
<svelte:head>
<title>Me gusta</title>
<meta name="description" content="Las canciones que te gustan en Musify." />
</svelte:head>
<Page>
<div class="flex flex-col gap-5 sm:flex-row sm:items-center sm:gap-6">
<div
class="grid h-36 w-36 shrink-0 place-items-center rounded-[18px] shadow-[0_24px_56px_rgba(0,0,0,.55)] sm:h-41 sm:w-41"
style="background:linear-gradient(150deg, oklch(0.4 0.09 330), oklch(0.18 0.04 10))"
>
<Heart class="h-10 w-10 text-white/90" fill="currentColor" strokeWidth={0} />
</div>
<div class="min-w-0 flex-1">
<p class="text-[10.5px] font-semibold tracking-[0.16em] text-fg-2 uppercase">Colección</p>
<h1
class="mt-3 font-display text-[28px] font-semibold tracking-[-0.035em] text-fg sm:text-[33px]"
>
Me gusta
</h1>
<p class="mt-3.25 text-[12.5px] text-fg-2">
{tracks.length}
{tracks.length === 1 ? 'canción' : 'canciones'}
</p>
</div>
</div>
<div class="mt-6 flex flex-wrap items-center gap-2.5 sm:mt-7">
<Button size="sm" onclick={playAll} disabled={tracks.length === 0}>
{#if isCurrentQueue && player.playing}
<Pause class="h-4 w-4" strokeWidth={1.5} />
Pausar
{:else}
<Play class="h-4 w-4" strokeWidth={1.5} />
Reproducir
{/if}
</Button>
</div>
{#if tracks.length > 0}
<TrackTable
index
action
meta={[
{ label: 'Álbum', width: '160px' },
{ label: 'Añadida', width: '140px' }
]}
class="mt-6 sm:mt-8"
>
{#each tracks as track, i (track.id)}
{@const active = player.current.id === track.id}
<TrackRow {active}>
<TrackIndexCell
index={i}
{active}
playing={player.playing}
onToggle={() => playFrom(i)}
/>
<TrackTitleCell
trackId={track.id}
title={track.title}
artist={track.artist}
ownerUserId={track.ownerUserId}
coverSrc={track.coverUrl}
explicit={track.explicit}
{active}
onClick={() => playFrom(i)}
/>
<TrackMeta class="hidden sm:block">—</TrackMeta>
<TrackMeta class="hidden sm:block">{fmtDate(track.likedAt)}</TrackMeta>
<TrackMeta>—</TrackMeta>
<IconButton
label="Quitar de Me gusta"
size="sm"
revealOnHover
onclick={() => liked.toggle(track)}
>
<X class="h-3.5 w-3.5" />
</IconButton>
</TrackRow>
{/each}
</TrackTable>
{:else}
<EmptyState
icon={Heart}
title="Todavía no tienes canciones con me gusta"
description="Toca el corazón en cualquier canción del reproductor para guardarla aquí."
>
{#snippet actions()}
<Button href="/explore" variant="secondary">Explorar música</Button>
{/snippet}
</EmptyState>
{/if}
</Page>
@@ -67,12 +67,15 @@
<Page>
<BackLink href="/" label="Volver al inicio" />
<div class="mt-5 flex flex-col gap-5 sm:mt-6 sm:flex-row sm:items-end sm:gap-6">
<MixArt {items} class="h-36 w-36 shrink-0 rounded-art-lg shadow-art-lg sm:h-44 sm:w-44" />
<div class="mt-3 flex flex-col gap-5 sm:mt-4 sm:flex-row sm:items-end sm:gap-6">
<MixArt
{items}
class="h-36 w-36 shrink-0 rounded-[18px] shadow-[0_24px_56px_rgba(0,0,0,.55)] sm:h-41 sm:w-41"
/>
<div class="min-w-0 flex-1">
<p class="text-sm tracking-[0.14em] text-fg-3 uppercase">Mezcla</p>
<p class="text-[10.5px] font-semibold tracking-[0.16em] text-fg-2 uppercase">Mezcla</p>
<h1
class="mt-1.5 font-display text-3xl font-semibold tracking-tight break-words text-fg sm:text-5xl md:text-7xl"
class="mt-1.5 font-display text-[28px] font-semibold tracking-[-0.035em] break-words text-fg sm:text-[33px]"
>
{mix.title}
</h1>
@@ -89,10 +92,10 @@
<div class="mt-6 flex flex-wrap items-center gap-2.5 sm:mt-7">
<Button size="sm" onclick={playAll} disabled={queue.length === 0}>
{#if isCurrentQueue && player.playing}
<Pause class="h-4 w-4" fill="currentColor" strokeWidth={1.5} />
<Pause class="h-4 w-4" strokeWidth={1.5} />
Pausar
{:else}
<Play class="h-4 w-4" fill="currentColor" strokeWidth={1.5} />
<Play class="h-4 w-4" strokeWidth={1.5} />
Reproducir
{/if}
</Button>
@@ -108,19 +111,16 @@
<TrackTable index class="mt-6 sm:mt-8">
{#each items as item, i (mixItemKey(item))}
<TrackRow oncontextmenu={(e) => (contextMenu = contextMenuStateFor(e, targets[i]))}>
<TrackIndexCell
index={i}
active={isTargetCurrent(targets[i])}
playing={player.playing}
onToggle={() => playFrom(i)}
/>
{@const active = isTargetCurrent(targets[i])}
<TrackRow {active} oncontextmenu={(e) => (contextMenu = contextMenuStateFor(e, targets[i]))}>
<TrackIndexCell index={i} {active} playing={player.playing} onToggle={() => playFrom(i)} />
<TrackTitleCell
trackId={targetId(targets[i])}
title={targetTitle(targets[i])}
artist={targetArtist(targets[i])}
coverSrc={targetCoverSrc(targets[i])}
explicit={item.isExplicit}
{active}
onClick={() => playFrom(i)}
/>
<TrackMeta>{fmtTime(Number(item.durationSeconds))}</TrackMeta>
@@ -19,7 +19,30 @@ export const load: PageServerLoad = async ({ locals, url, fetch }) => {
const data = unwrapOrError(result, 'No se pudieron cargar tus playlists.');
return { playlists: data };
const tracksByPlaylist = await Promise.all(
data.items.map((playlist) =>
api
.GET('/playlists/{playlistId}/tracks', {
params: { path: { playlistId: playlist.id }, query: { pageNumber: 1, pageSize: 500 } }
})
.then((res) => res.data?.items ?? [])
)
);
const items = data.items.map((playlist, i) => {
const tracks = tracksByPlaylist[i];
const trackCount = tracks.length;
const durationSeconds = tracks.reduce((sum, t) => sum + Number(t.duration || 0), 0);
return { ...playlist, trackCount, durationSeconds };
});
const totals = {
playlistCount: items.length,
trackCount: items.reduce((sum, p) => sum + p.trackCount, 0),
durationSeconds: items.reduce((sum, p) => sum + p.durationSeconds, 0)
};
return { playlists: { ...data, items }, totals };
};
export const actions: Actions = {
@@ -30,6 +53,7 @@ export const actions: Actions = {
const form = await request.formData();
const name = String(form.get('name') ?? '').trim();
const description = String(form.get('description') ?? '').trim();
const visibility = form.get('visibility') === 'public' ? 'Public' : 'Private';
const cover = form.get('cover');
if (name === '' || name.length > MAX_NAME) {
@@ -55,7 +79,7 @@ export const actions: Actions = {
}
const { data, error: err } = await api.POST('/playlists', {
body: { name, description, pictureIntentId }
body: { name, description, pictureIntentId, visibility }
});
if (err || !data)
@@ -1,43 +1,21 @@
<script lang="ts">
import Plus from '@lucide/svelte/icons/plus';
import ListMusic from '@lucide/svelte/icons/list-music';
import Page from '$lib/components/ui/Page.svelte';
import PageHeader from '$lib/components/ui/PageHeader.svelte';
import MediaGrid from '$lib/components/ui/MediaGrid.svelte';
import PlaylistCard from '$lib/components/ui/PlaylistCard.svelte';
import Modal from '$lib/components/ui/Modal.svelte';
import EmptyState from '$lib/components/ui/EmptyState.svelte';
import PlaylistForm from '$lib/components/ui/PlaylistForm.svelte';
import Button from '$lib/components/ui/Button.svelte';
import { createPlaylistModal } from '$lib/playlists.svelte';
import { fmtDurationLong } from '$lib/format';
const playlistNames = [
'Mi Playlist',
'Tus favoritos',
'Lo mejor de hoy',
'Para relajarse',
'Buena vibra',
'Modo estudio',
'Road Trip',
'Domingos',
'Late Night',
'Descubriendo música',
'Todo un poco',
'En bucle',
'Mi colección',
'Canciones para repetir',
'Hits del momento'
];
const namePlaceholder = playlistNames[Math.floor(Math.random() * playlistNames.length)];
let { data, form } = $props();
let { data } = $props();
const items = $derived(data.playlists.items);
let creating = $state(false);
function openCreate() {
creating = true;
}
const totals = $derived(data.totals);
const summary = $derived(
`${totals.playlistCount} ${totals.playlistCount === 1 ? 'playlist' : 'playlists'} · ${totals.trackCount} ${totals.trackCount === 1 ? 'canción' : 'canciones'} · ${fmtDurationLong(totals.durationSeconds)}`
);
</script>
<svelte:head>
@@ -46,24 +24,24 @@
</svelte:head>
<Page>
<PageHeader title="Mis listas" subtitle="Crea y organiza tus colecciones.">
<PageHeader
title="Mis listas"
subtitle={items.length > 0 ? summary : 'Crea y organiza tus colecciones.'}
>
{#snippet actions()}
{#if items.length > 0}
<Button onclick={openCreate}>
<Plus class="h-4 w-4" strokeWidth={2.5} />
Crear lista
</Button>
<Button onclick={() => createPlaylistModal.show()}>Crear lista</Button>
{/if}
{/snippet}
</PageHeader>
{#if items.length > 0}
<MediaGrid class="mt-9">
<MediaGrid min="210px" minMobile="180px" class="mt-7 sm:mt-8">
{#each items as playlist, i (playlist.id)}
<PlaylistCard
id={playlist.id}
name={playlist.name}
description={playlist.description}
trackCount={playlist.trackCount}
trackIds={playlist.coverTrackIds}
updatedAt={playlist.updatedAt}
index={i}
@@ -77,22 +55,8 @@
description="Crea la primera y añádele canciones de tu biblioteca."
>
{#snippet actions()}
<Button onclick={openCreate}>
<Plus class="h-4 w-4" strokeWidth={2} />
Crear lista
</Button>
<Button onclick={() => createPlaylistModal.show()}>Crear lista</Button>
{/snippet}
</EmptyState>
{/if}
</Page>
<Modal open={creating} onClose={() => (creating = false)} title="Nueva lista">
<PlaylistForm
action="?/create"
{namePlaceholder}
formMessage={form?.message}
submitLabel="Crear"
submittingLabel="Creando…"
onCancel={() => (creating = false)}
/>
</Modal>
@@ -8,75 +8,28 @@ import {
unwrapOrFail
} from '$lib/server/api';
import { uploadPresignedImage } from '$lib/server/upload';
import { fetchYoutubeFiller } from '$lib/server/youtube';
import { shuffle } from '$lib/collections';
import { addTrackAction, addYouTubeToPlaylistAction } from '$lib/server/playlistActions';
import type { YouTubeSong } from '$lib/types';
const MAX_NAME = 100;
const SUGGESTIONS_LIMIT = 20;
export const load: PageServerLoad = async ({ params, locals, url, fetch, parent }) => {
const { allowAnonymousListening } = await parent();
const user = optionalUser(locals, url, allowAnonymousListening);
optionalUser(locals, url, allowAnonymousListening);
const api = createApiClient({ fetch, accessToken: locals.accessToken ?? undefined });
const [playlistRes, tracksRes, libraryRes, playlistsRes] = await Promise.all([
const [playlistRes, tracksRes] = await Promise.all([
api.GET('/playlists/{id}', { params: { path: { id: params.id } } }),
api.GET('/playlists/{playlistId}/tracks', {
params: { path: { playlistId: params.id }, query: { pageNumber: 1, pageSize: 200 } }
}),
user
? api.GET('/tracks/users/{userId}', {
params: { path: { userId: user.sub }, query: { pageNumber: 1, pageSize: 50 } }
})
: Promise.resolve(null),
user
? api.GET('/playlists/users/{userId}', {
params: { path: { userId: user.sub }, query: { pageNumber: 1, pageSize: 50 } }
})
: Promise.resolve(null)
})
]);
const playlist = unwrapOrError(playlistRes, 'Playlist no encontrada.', 404);
const tracks = tracksRes.data?.items ?? [];
const inPlaylist = new Set(tracks.map((t) => t.id));
const library = (libraryRes?.data?.items ?? []).filter((t) => !inPlaylist.has(t.id));
const alreadyLinked = new Set(
tracks.flatMap((t) => (t.source === 'YouTube' && t.externalId ? [t.externalId] : []))
);
const seeds = [...new Set(tracks.map((t) => t.artist).filter((a): a is string => !!a))];
const seed = seeds.length > 0 ? shuffle(seeds)[0] : undefined;
const youtube = fetchYoutubeFiller(
api,
locals.accessToken,
SUGGESTIONS_LIMIT + alreadyLinked.size,
seed
)
.then((filler) => ({
query: filler.query,
items: (filler.items as YouTubeSong[])
.filter((song) => !alreadyLinked.has(song.videoId))
.slice(0, SUGGESTIONS_LIMIT)
}))
.catch(() => ({ query: '', items: [] as YouTubeSong[] }));
return {
playlist,
tracks,
library,
youtube,
playlists: playlistsRes?.data?.items ?? []
};
return { playlist, tracks };
};
export const actions: Actions = {
addTrack: addTrackAction,
addYouTubeToPlaylist: addYouTubeToPlaylistAction,
removeTrack: async ({ request, params, locals, fetch }) => {
const accessToken = requireAccessTokenAction(locals);
if (typeof accessToken !== 'string') return accessToken;
@@ -98,6 +51,7 @@ export const actions: Actions = {
const form = await request.formData();
const name = String(form.get('name') ?? '').trim();
const description = String(form.get('description') ?? '').trim();
const newVisibility = form.get('visibility') === 'public' ? 'Public' : 'Private';
const cover = form.get('cover');
if (name === '' || name.length > MAX_NAME) {
return fail(400, { message: 'El nombre es obligatorio (máx. 100 caracteres).' });
@@ -119,7 +73,7 @@ export const actions: Actions = {
const result = await api.PUT('/playlists/{playlistId}', {
params: { path: { playlistId: params.id } },
body: { newName: name, newDescription: description, newPictureIntentId }
body: { newName: name, newDescription: description, newPictureIntentId, newVisibility }
});
const failure = unwrapOrFail(result, 'No se pudo actualizar la playlist.');
if (failure) return failure;
@@ -1,30 +1,21 @@
<script lang="ts">
import ListMusic from '@lucide/svelte/icons/list-music';
import { player, queueIdForTrack, toQueueItems } from '$lib/player/player.svelte';
import Page from '$lib/components/ui/Page.svelte';
import Modal from '$lib/components/ui/Modal.svelte';
import PlaylistForm from '$lib/components/ui/PlaylistForm.svelte';
import Button from '$lib/components/ui/Button.svelte';
import EmptyState from '$lib/components/ui/EmptyState.svelte';
import PlaylistHeader from './components/PlaylistHeader.svelte';
import PlaylistTrackTable from './components/PlaylistTrackTable.svelte';
import SuggestionsTable from './components/SuggestionsTable.svelte';
import type { YouTubeSong } from '$lib/types';
let { data, form } = $props();
const playlist = $derived(data.playlist);
const tracks = $derived(data.tracks);
const library = $derived(data.library);
let editing = $state(false);
let confirmingDelete = $state(false);
let youtubeSuggestions = $state<YouTubeSong[]>([]);
$effect(() => {
youtubeSuggestions = [];
data.youtube.then((result) => {
youtubeSuggestions = result.items;
});
});
const isCurrentQueue = $derived(tracks.some((t) => queueIdForTrack(t) === player.current.id));
@@ -47,6 +38,7 @@
description={playlist.description}
trackIds={tracks.map((t) => t.id)}
updatedAt={playlist.updatedAt}
visibility={playlist.visibility}
playing={isCurrentQueue && player.playing}
onPlayAll={playAll}
onEdit={() => (editing = true)}
@@ -55,10 +47,17 @@
{#if tracks.length > 0}
<PlaylistTrackTable {tracks} />
{/if}
{#if library.length > 0 || youtubeSuggestions.length > 0}
<SuggestionsTable {library} youtube={youtubeSuggestions} playlists={data.playlists} />
{:else}
<EmptyState
icon={ListMusic}
title="Esta playlist todavía está vacía"
description="Añade canciones desde tu biblioteca o explora música nueva para empezar."
class="mt-6 sm:mt-8"
>
{#snippet actions()}
<Button href="/explore" variant="secondary">Explorar música</Button>
{/snippet}
</EmptyState>
{/if}
</Page>
@@ -67,6 +66,7 @@
action="?/rename"
initialName={playlist.name}
initialDescription={playlist.description ?? ''}
initialVisibility={playlist.visibility === 'Public' ? 'public' : 'private'}
coverFallbackUrl="/api/playlists/{playlist.id}/cover?size=medium&v={encodeURIComponent(
playlist.updatedAt
)}"
@@ -79,10 +79,10 @@
</Modal>
<Modal open={confirmingDelete} onClose={() => (confirmingDelete = false)} maxWidth="max-w-sm">
<h2 class="text-lg font-semibold tracking-tight text-fg">
<h2 class="font-display text-xl font-semibold tracking-[-0.02em] text-fg">
¿Eliminar «{playlist.name}»?
</h2>
<p class="mt-2 text-base text-fg-3">
<p class="mt-2.5 text-[13px] leading-[1.65] text-fg-2">
Esta acción no se puede deshacer, la playlist se eliminará de tu biblioteca y de todos los
dispositivos donde la tengas guardada.
</p>
@@ -4,7 +4,6 @@
import SquarePencil from '@lucide/svelte/icons/square-pen';
import Trash from '@lucide/svelte/icons/trash';
import PlaylistArt from '$lib/components/ui/PlaylistArt.svelte';
import BackLink from '$lib/components/ui/BackLink.svelte';
import Button from '$lib/components/ui/Button.svelte';
interface Props {
@@ -13,39 +12,50 @@
description?: string | null;
trackIds: (string | number)[];
updatedAt: string;
visibility: 'Public' | 'Private';
playing: boolean;
onPlayAll: () => void;
onEdit: () => void;
onDelete: () => void;
}
let { id, name, description, trackIds, updatedAt, playing, onPlayAll, onEdit, onDelete }: Props =
$props();
let {
id,
name,
description,
trackIds,
updatedAt,
visibility,
playing,
onPlayAll,
onEdit,
onDelete
}: Props = $props();
</script>
<BackLink href="/playlists" label="Volver a tus listas" />
<div class="mt-5 flex flex-col gap-5 sm:mt-6 sm:flex-row sm:items-end sm:gap-6">
<div class="flex flex-col gap-5 sm:flex-row sm:items-center sm:gap-6">
<PlaylistArt
playlistId={id}
{trackIds}
size="large"
version={updatedAt}
class="h-36 w-36 shrink-0 rounded-art-lg shadow-art-lg sm:h-44 sm:w-44"
class="h-36 w-36 shrink-0 rounded-[18px] shadow-[0_24px_56px_rgba(0,0,0,.55)] sm:h-41 sm:w-41"
/>
<div class="min-w-0 flex-1">
<p class="text-sm tracking-[0.14em] text-fg-3 uppercase">Lista</p>
<p class="text-[10.5px] font-semibold tracking-[0.16em] text-fg-2 uppercase">Lista</p>
<h1
class="mt-1.5 font-display text-3xl font-semibold tracking-tight break-words text-fg sm:text-5xl md:text-7xl"
class="mt-3 font-display text-[28px] font-semibold tracking-[-0.035em] break-words text-fg sm:text-[33px]"
>
{name}
</h1>
{#if description}
<p class="mt-3 max-w-2xl text-sm text-fg-2 sm:text-base">{description}</p>
{/if}
<p class="mt-3 text-sm text-muted">
<p class="mt-3.25 text-[12.5px] text-fg-2">
{trackIds.length}
{trackIds.length === 1 ? 'canción' : 'canciones'}
{trackIds.length === 1 ? 'canción' : 'canciones'} · {visibility === 'Public'
? 'Pública'
: 'Privada'}
</p>
</div>
</div>
@@ -53,19 +63,15 @@
<div class="mt-6 flex flex-wrap items-center gap-2.5 sm:mt-7">
<Button size="sm" onclick={onPlayAll} disabled={trackIds.length === 0}>
{#if playing}
<Pause class="h-4 w-4" fill="currentColor" strokeWidth={1.5} />
Pausar
{:else}
<Play class="h-4 w-4" fill="currentColor" strokeWidth={1.5} />
Reproducir
{/if}
</Button>
<Button size="sm" variant="secondary" onclick={onEdit}>
<SquarePencil class="h-4 w-4" strokeWidth={1.5} />
Editar
</Button>
<Button size="sm" variant="secondary" onclick={onDelete}>
<Trash class="h-4 w-4" strokeWidth={1.5} />
Eliminar
</Button>
</div>
@@ -31,23 +31,29 @@
}
</script>
<TrackTable index action meta={[{ label: 'Añadida', width: '140px' }]} class="mt-6 sm:mt-8">
<TrackTable
index
action
meta={[
{ label: 'Álbum', width: '160px' },
{ label: 'Añadida', width: '140px' }
]}
class="mt-6 sm:mt-8"
>
{#each tracks as track, i (track.id)}
<TrackRow>
<TrackIndexCell
index={i}
active={player.current.id === queueIdForTrack(track)}
playing={player.playing}
onToggle={() => playFrom(i)}
/>
{@const active = player.current.id === queueIdForTrack(track)}
<TrackRow {active}>
<TrackIndexCell index={i} playing={player.playing} onToggle={() => playFrom(i)} {active} />
<TrackTitleCell
trackId={track.id}
title={track.title}
artist={track.artist}
ownerUserId={track.ownerUserId}
coverSrc={isPendingYouTubeTrack(track) && track.externalId
? youTubeThumbnailUrl(track.externalId)
: undefined}
explicit={track.isExplicit}
{active}
onClick={() => playFrom(i)}
>
{#snippet badge()}
@@ -58,6 +64,7 @@
{/if}
{/snippet}
</TrackTitleCell>
<TrackMeta class="hidden sm:block">—</TrackMeta>
<TrackMeta class="hidden sm:block">{fmtDate(track.createdAt)}</TrackMeta>
<TrackMeta>{fmtTime(Number(track.duration))}</TrackMeta>
<form
@@ -1,151 +0,0 @@
<script lang="ts">
import { enhance } from '$app/forms';
import Plus from '@lucide/svelte/icons/plus';
import {
player,
queueIdForTrack,
type ApiTrackLike,
type QueueItem
} from '$lib/player/player.svelte';
import { fmtTime } from '$lib/format';
import type { YouTubeSong } from '$lib/types';
import SectionHeading from '$lib/components/ui/SectionHeading.svelte';
import IconButton from '$lib/components/ui/IconButton.svelte';
import NowPlaying from '$lib/components/ui/NowPlaying.svelte';
import TrackTable from '$lib/components/ui/TrackTable.svelte';
import TrackRow from '$lib/components/ui/TrackRow.svelte';
import TrackMeta from '$lib/components/ui/TrackMeta.svelte';
import TrackTitleCell from '$lib/components/ui/TrackTitleCell.svelte';
import TrackContextMenu, {
contextMenuStateFor,
type ContextMenuState
} from '$lib/components/TrackContextMenu.svelte';
type LibraryTrack = ApiTrackLike & { duration: number | string };
type Suggestion = { kind: 'local'; track: LibraryTrack } | { kind: 'youtube'; song: YouTubeSong };
interface Props {
library: LibraryTrack[];
youtube: YouTubeSong[];
playlists: { id: string; name: string }[];
}
let { library, youtube, playlists }: Props = $props();
let contextMenu = $state<ContextMenuState | null>(null);
function openContextMenu(event: MouseEvent, item: Suggestion) {
contextMenu = contextMenuStateFor(event, item);
}
function closeContextMenu() {
contextMenu = null;
}
function addToQueue() {
if (!contextMenu) return;
player.addToQueue(queueItemFor(contextMenu as Suggestion));
closeContextMenu();
}
const suggestions = $derived<Suggestion[]>([
...library.map((track) => ({ kind: 'local' as const, track })),
...youtube.map((song) => ({ kind: 'youtube' as const, song }))
]);
function idFor(item: Suggestion) {
return item.kind === 'youtube' ? item.song.videoId : queueIdForTrack(item.track);
}
function queueItemFor(item: Suggestion): QueueItem {
return item.kind === 'youtube'
? {
id: item.song.videoId,
title: item.song.title,
artist: item.song.artist,
source: 'youtube',
coverUrl: item.song.thumbnailUrl,
explicit: item.song.isExplicit
}
: {
id: queueIdForTrack(item.track),
title: item.track.title,
artist: item.track.artist ?? undefined,
source: 'local',
coverUrl: `/api/tracks/${item.track.id}/cover?size=small`
};
}
function playFrom(index: number) {
player.playOrToggle(suggestions.map(queueItemFor), index);
}
</script>
<div class="mt-10 sm:mt-12">
<SectionHeading title="Sugerencias" />
<TrackTable action>
{#each suggestions as item, i (idFor(item))}
<TrackRow oncontextmenu={(e) => openContextMenu(e, item)}>
<TrackTitleCell
trackId={item.kind === 'youtube' ? item.song.videoId : item.track.id}
title={item.kind === 'youtube' ? item.song.title : item.track.title}
artist={item.kind === 'youtube' ? item.song.artist : item.track.artist}
coverSrc={item.kind === 'youtube' ? item.song.thumbnailUrl : undefined}
explicit={item.kind === 'youtube' && item.song.isExplicit}
onClick={() => playFrom(i)}
>
{#snippet overlay()}
{#if player.current.id === idFor(item)}
<NowPlaying paused={!player.playing} />
{/if}
{/snippet}
</TrackTitleCell>
<TrackMeta>
{fmtTime(
item.kind === 'youtube' ? item.song.durationSeconds : Number(item.track.duration)
)}
</TrackMeta>
{#if item.kind === 'youtube'}
<form
method="POST"
action="?/addYouTubeToPlaylist"
use:enhance={() =>
async ({ update }) =>
update()}
>
<input type="hidden" name="videoId" value={item.song.videoId} />
<input type="hidden" name="title" value={item.song.title} />
<input type="hidden" name="artist" value={item.song.artist} />
<input type="hidden" name="durationSeconds" value={item.song.durationSeconds} />
<input type="hidden" name="thumbnailUrl" value={item.song.thumbnailUrl} />
<IconButton type="submit" label="Añadir a la playlist" size="sm">
<Plus class="h-4 w-4" />
</IconButton>
</form>
{:else}
<form
method="POST"
action="?/addTrack"
use:enhance={() =>
async ({ update }) =>
update()}
>
<input type="hidden" name="trackId" value={item.track.id} />
<IconButton type="submit" label="Añadir a la playlist" size="sm">
<Plus class="h-4 w-4" />
</IconButton>
</form>
{/if}
</TrackRow>
{/each}
</TrackTable>
</div>
{#if contextMenu}
<TrackContextMenu
menu={contextMenu}
{playlists}
onClose={closeContextMenu}
onAddToQueue={addToQueue}
/>
{/if}
@@ -0,0 +1,75 @@
import type { PageServerLoad, Actions } from './$types';
import {
createApiClient,
optionalUser,
requireAccessTokenAction,
unwrapOrError
} from '$lib/server/api';
const PROFILE_PLAYLISTS_PAGE_SIZE = 50;
export const load: PageServerLoad = async ({ params, locals, url, fetch, parent }) => {
const { allowAnonymousListening } = await parent();
const viewer = optionalUser(locals, url, allowAnonymousListening);
const api = createApiClient({ fetch, accessToken: locals.accessToken ?? undefined });
const [profileRes, playlistsRes] = await Promise.all([
api.GET('/users/{id}/profile', { params: { path: { id: params.id } } }),
api.GET('/playlists/users/{userId}', {
params: {
path: { userId: params.id },
query: { pageNumber: 1, pageSize: PROFILE_PLAYLISTS_PAGE_SIZE, onlyPublic: true }
}
})
]);
const profile = unwrapOrError(profileRes, 'Usuario no encontrado.', 404);
const playlistItems = playlistsRes.data?.items ?? [];
const tracksByPlaylist = await Promise.all(
playlistItems.map((playlist) =>
api
.GET('/playlists/{playlistId}/tracks', {
params: { path: { playlistId: playlist.id }, query: { pageNumber: 1, pageSize: 1 } }
})
.then((res) => Number(res.data?.totalItemCount ?? 0))
)
);
const playlists = playlistItems.map((playlist, i) => ({
...playlist,
trackCount: tracksByPlaylist[i]
}));
const isOwnProfile = viewer !== null && viewer.sub === params.id;
const isFollowing =
viewer && !isOwnProfile
? await api
.GET('/users/{id}/is-following', { params: { path: { id: params.id } } })
.then((res) => res.data ?? false)
.catch(() => false)
: false;
return { profile, playlists, isOwnProfile, isFollowing, isAnonymous: viewer === null };
};
export const actions: Actions = {
follow: async ({ params, locals, fetch }) => {
const accessToken = requireAccessTokenAction(locals);
if (typeof accessToken !== 'string') return accessToken;
const api = createApiClient({ fetch, accessToken });
await api.POST('/users/{id}/follow', { params: { path: { id: params.id } } });
return { following: true };
},
unfollow: async ({ params, locals, fetch }) => {
const accessToken = requireAccessTokenAction(locals);
if (typeof accessToken !== 'string') return accessToken;
const api = createApiClient({ fetch, accessToken });
await api.DELETE('/users/{id}/follow', { params: { path: { id: params.id } } });
return { following: false };
}
};
@@ -0,0 +1,100 @@
<script lang="ts">
import { untrack } from 'svelte';
import { enhance } from '$app/forms';
import ListMusic from '@lucide/svelte/icons/list-music';
import Page from '$lib/components/ui/Page.svelte';
import MediaGrid from '$lib/components/ui/MediaGrid.svelte';
import PlaylistCard from '$lib/components/ui/PlaylistCard.svelte';
import EmptyState from '$lib/components/ui/EmptyState.svelte';
import Button from '$lib/components/ui/Button.svelte';
import ArtistAvatar from '$lib/components/ui/ArtistAvatar.svelte';
let { data, form } = $props();
const profile = $derived(data.profile);
const playlists = $derived(data.playlists);
let following = $state(untrack(() => data.isFollowing));
let submitting = $state(false);
$effect(() => {
if (form && 'following' in form && typeof form.following === 'boolean')
following = form.following;
});
</script>
<svelte:head>
<title>{profile.name}</title>
<meta name="description" content="Perfil de {profile.name} en Musify." />
</svelte:head>
<Page>
<div class="flex flex-col gap-5 sm:flex-row sm:items-center sm:gap-6">
<ArtistAvatar name={profile.name} imageUrl={profile.profilePictureUrl} size={144} />
<div class="min-w-0 flex-1">
<p class="text-[10.5px] font-semibold tracking-[0.16em] text-fg-2 uppercase">Perfil</p>
<h1
class="mt-3 font-display text-[28px] font-semibold tracking-[-0.035em] break-words text-fg sm:text-[33px]"
>
{profile.name}
</h1>
<p class="mt-3.25 text-[12.5px] text-fg-2">
{profile.followersCount}
{profile.followersCount === 1 ? 'seguidor' : 'seguidores'} · {profile.followingCount} siguiendo
</p>
</div>
</div>
{#if !data.isOwnProfile && !data.isAnonymous}
<div class="mt-6 flex items-center gap-2.5 sm:mt-7">
<form
method="POST"
action={following ? '?/unfollow' : '?/follow'}
use:enhance={() => {
submitting = true;
return async ({ update }) => {
await update();
submitting = false;
};
}}
>
<Button
type="submit"
size="sm"
variant={following ? 'secondary' : 'primary'}
disabled={submitting}
>
{following ? 'Dejar de seguir' : 'Seguir'}
</Button>
</form>
</div>
{/if}
<div class="mt-9">
<h2 class="font-display text-lg font-semibold tracking-[-0.02em] text-fg">
Playlists públicas
</h2>
{#if playlists.length > 0}
<MediaGrid min="210px" minMobile="180px" class="mt-6">
{#each playlists as playlist, i (playlist.id)}
<PlaylistCard
id={playlist.id}
name={playlist.name}
trackCount={playlist.trackCount}
trackIds={playlist.coverTrackIds}
updatedAt={playlist.updatedAt}
index={i}
/>
{/each}
</MediaGrid>
{:else}
<EmptyState
icon={ListMusic}
title="Sin playlists públicas"
description="Este usuario todavía no ha compartido ninguna playlist pública."
class="mt-6"
/>
{/if}
</div>
</Page>
+41 -44
View File
@@ -30,9 +30,9 @@
let audioInput = $state<HTMLInputElement>();
const steps = [
{ key: 'upload', label: 'Subir', hint: 'Portada y audio al almacenamiento' },
{ key: 'process', label: 'Procesar', hint: 'Transcodificado para streaming' },
{ key: 'publish', label: 'Publicar', hint: 'Disponible en el catálogo' }
{ key: 'upload', label: 'Subir', hint: 'Portada y audio.' },
{ key: 'process', label: 'Procesar', hint: 'Transcodificado para streaming.' },
{ key: 'publish', label: 'Publicar', hint: 'Disponible en el catálogo.' }
];
const busy = $derived(status === 'uploading');
@@ -47,14 +47,8 @@
return ['idle', 'idle', 'idle'];
});
const dropClass = $derived(
dragging || audioName !== '' ? 'border-accent/60' : 'border-line hover:border-accent/60'
);
function stepClass(s: StepState) {
if (s === 'active') return 'border-accent/40 bg-accent/5';
if (s === 'done') return 'border-accent/40';
return '';
return s === 'idle' ? '' : 'bg-accent-tint';
}
function formatSize(bytes: number) {
@@ -112,37 +106,39 @@
subtitle="Añade una canción con su portada y título. Nosotros la procesamos para streaming."
/>
<ol class="mt-8 grid gap-3 sm:mt-10 sm:grid-cols-3 sm:gap-4">
<ol class="mt-7 grid gap-3 sm:mt-8 sm:grid-cols-3 sm:gap-4">
{#each steps as step, i (step.key)}
<li>
<Surface class={stepClass(stepStates[i])}>
<Surface padding="sm" class={stepClass(stepStates[i])}>
<div class="flex items-center gap-3">
<span
class="grid h-8 w-8 place-items-center rounded-full bg-accent-soft text-sm font-bold text-on-accent"
class="grid h-7 w-7 shrink-0 place-items-center rounded-full text-xs font-bold {stepStates[
i
] === 'idle'
? 'bg-white/[3.5%] text-fg-2'
: 'bg-accent-btn text-accent-soft'}"
>
{#if stepStates[i] === 'done'}
<Check class="h-4 w-4" strokeWidth={2.5} />
<Check class="h-3.5 w-3.5" strokeWidth={2.5} />
{:else}
{i + 1}
{/if}
</span>
<span class="text-fg">{step.label}</span>
<span class="text-sm font-medium text-fg">{step.label}</span>
</div>
<p class="mt-3 text-base text-muted">{step.hint}</p>
<p class="mt-2.5 text-xs text-muted">{step.hint}</p>
</Surface>
</li>
{/each}
</ol>
{#if status === 'done'}
<Surface padding="lg" class="mt-10 border-accent/30 bg-accent/10 text-center">
<span
class="mx-auto grid h-16 w-16 place-items-center rounded-full bg-accent-soft text-on-accent"
>
<Check class="h-8 w-8" strokeWidth={2.5} />
<Surface padding="lg" class="mt-10 bg-accent-tint text-center">
<span class="mx-auto grid h-14 w-14 place-items-center rounded-full bg-accent-soft text-ink">
<Check class="h-7 w-7" strokeWidth={2.5} />
</span>
<h2 class="mt-6 text-2xl font-semibold tracking-tight text-fg">¡Subida!</h2>
<p class="mt-3 text-fg-2">
<h2 class="mt-6 font-display text-2xl font-semibold tracking-[-0.02em] text-fg">¡Subida!</h2>
<p class="mt-3 text-sm text-fg-2">
«{publishedTitle}» se está procesando. Aparecerá lista para reproducir en tu biblioteca en
unos momentos.
</p>
@@ -184,7 +180,9 @@
}}
ondragleave={() => (dragging = false)}
ondrop={onDrop}
class="relative flex min-h-56 flex-col items-center justify-center rounded-panel border border-dashed bg-surface p-6 text-center transition sm:min-h-72 sm:p-10 {dropClass}"
class="relative flex min-h-48 flex-col items-center justify-center rounded-panel p-6 text-center transition sm:min-h-64 sm:p-10 {dragging
? 'bg-surface-2 ring-2 ring-accent/50'
: 'bg-surface'}"
>
<input
bind:this={audioInput}
@@ -192,33 +190,32 @@
name="audio"
accept="audio/*"
onchange={onAudioInput}
class="absolute inset-0 cursor-pointer opacity-0"
class="absolute inset-0 cursor-pointer opacity-0 focus:outline-none"
aria-label="Seleccionar archivo de audio"
/>
<span
class="grid h-16 w-16 place-items-center rounded-full bg-accent-soft text-on-accent sm:h-20 sm:w-20"
>
{#if audioName === ''}
<Headphones class="h-8 w-8 sm:h-9 sm:w-9" />
{:else}
<Music class="h-8 w-8 sm:h-9 sm:w-9" />
{/if}
</span>
{#if audioName === ''}
<p class="mt-5 text-fg sm:text-lg">Arrastra tu audio aquí o haz clic para elegir</p>
<p class="mt-2 text-sm text-muted">MP3, FLAC, WAV…</p>
<Headphones class="h-9 w-9 text-muted sm:h-10 sm:w-10" strokeWidth={1.4} />
{:else}
<p class="mt-5 max-w-full truncate text-fg sm:text-lg">
<Music class="h-9 w-9 text-accent-soft sm:h-10 sm:w-10" strokeWidth={1.4} />
{/if}
{#if audioName === ''}
<p class="mt-4 text-sm font-medium text-fg">
Arrastra tu audio aquí o haz clic para elegir
</p>
<p class="mt-1.5 text-xs text-muted">MP3, FLAC, WAV…</p>
{:else}
<p class="mt-4 max-w-full truncate text-sm font-medium text-fg">
{audioName}
</p>
<p class="mt-2 text-sm text-muted">{formatSize(audioSize)} · Listo para subir</p>
<p class="mt-1.5 text-xs text-muted">{formatSize(audioSize)} · Listo para subir</p>
{/if}
</div>
<div class="grid items-start gap-6 sm:grid-cols-[auto_1fr] sm:gap-8">
<Field label="Portada">
<label
class="relative grid h-36 w-36 cursor-pointer place-items-center overflow-hidden rounded-control border border-line bg-surface transition hover:border-accent/50 sm:h-44 sm:w-44"
class="relative grid h-36 w-36 cursor-pointer place-items-center overflow-hidden rounded-control transition sm:h-44 sm:w-44"
style={coverPreview === '' ? 'background:var(--mf-cover-grad)' : undefined}
>
<input
type="file"
@@ -231,7 +228,7 @@
{#if coverPreview !== ''}
<img src={coverPreview} alt="Portada" class="h-full w-full object-cover" />
{:else}
<ImageIcon class="h-12 w-12 text-muted" />
<ImageIcon class="h-11 w-11 text-white/70" strokeWidth={1.3} />
{/if}
</label>
</Field>
@@ -263,12 +260,12 @@
</Checkbox>
{#if status === 'uploading'}
<Surface>
<div class="flex items-center justify-between text-base">
<Surface padding="sm">
<div class="flex items-center justify-between text-sm">
<span class="font-medium text-fg">Subiendo y creando la pista…</span>
<span class="text-muted">un momento</span>
<span class="text-xs text-muted">un momento</span>
</div>
<div class="mt-4 h-2.5 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>
</Surface>
+16
View File
@@ -0,0 +1,16 @@
<script lang="ts">
import { page } from '$app/state';
import Button from '$lib/components/ui/Button.svelte';
</script>
<div class="animate-enter mx-auto my-[14vh] max-w-105 text-center">
<h1 class="mb-2.5 font-display text-xl font-semibold tracking-[-0.02em] text-fg">
{page.status === 404 ? 'Página no encontrada' : 'No se pudo cargar tu música'}
</h1>
<p class="mb-6.5 text-[13px] leading-[1.65] text-fg-2">
{page.status === 404
? 'Comprueba el enlace o vuelve al inicio.'
: 'La conexión se interrumpió. Lo que tengas descargado seguirá disponible sin conexión.'}
</p>
<Button href="/" variant="secondary">Volver</Button>
</div>
+63 -1
View File
@@ -1,8 +1,44 @@
import { redirect } from '@sveltejs/kit';
import type { LayoutServerLoad } from './$types';
import { getAllowAnonymousListening } from '$lib/server/playbackConfig';
import { createApiClient } from '$lib/server/api';
import { authConfig } from '$lib/server/config';
import { LIKED_TRACKS_PAGE_SIZE } from '$lib/config';
const PUBLIC_PATHS = new Set(['/login']);
const SIDEBAR_PLAYLISTS_LIMIT = 8;
async function fetchSidebarPlaylists(
fetchFn: typeof fetch,
accessToken: string | null,
userId: string
) {
try {
const api = createApiClient({ fetch: fetchFn, accessToken: accessToken ?? undefined });
const { data } = await api.GET('/playlists/users/{userId}', {
params: {
path: { userId },
query: { pageNumber: 1, pageSize: SIDEBAR_PLAYLISTS_LIMIT }
}
});
return data?.items ?? [];
} catch {
// The sidebar shell must render even when the API is unreachable.
return [];
}
}
async function fetchLikedTracks(fetchFn: typeof fetch, accessToken: string | null) {
try {
const api = createApiClient({ fetch: fetchFn, accessToken: accessToken ?? undefined });
const { data } = await api.GET('/likes', {
params: { query: { pageNumber: 1, pageSize: LIKED_TRACKS_PAGE_SIZE } }
});
return data?.items ?? [];
} catch {
return [];
}
}
export const load: LayoutServerLoad = async ({ locals, url, fetch }) => {
const allowAnonymousListening = await getAllowAnonymousListening(fetch);
@@ -10,5 +46,31 @@ export const load: LayoutServerLoad = async ({ locals, url, fetch }) => {
if (!locals.user && !allowAnonymousListening && !PUBLIC_PATHS.has(url.pathname)) {
redirect(302, `/login?returnTo=${encodeURIComponent(url.pathname + url.search)}`);
}
return { user: locals.user, allowAnonymousListening };
const accountUrl = authConfig.issuer
? `${authConfig.issuer.replace(/\/+$/, '')}/ui/console`
: null;
if (!locals.user) {
return {
user: locals.user,
allowAnonymousListening,
sidebarPlaylists: [],
likedTracks: [],
accountUrl
};
}
const [sidebarPlaylists, likedTracks] = await Promise.all([
fetchSidebarPlaylists(fetch, locals.accessToken, locals.user.sub),
fetchLikedTracks(fetch, locals.accessToken)
]);
return {
user: locals.user,
allowAnonymousListening,
sidebarPlaylists,
likedTracks,
accountUrl
};
};
+82 -44
View File
@@ -1,16 +1,23 @@
<script lang="ts">
import '@fontsource-variable/manrope/index.css';
import '@fontsource-variable/bricolage-grotesque/opsz.css';
import '@fontsource-variable/sora/index.css';
import '@fontsource-variable/plus-jakarta-sans/index.css';
import '$lib/theme/theme.css';
import './layout.css';
import favicon from '$lib/assets/favicon.svg';
import PlayerBar from '$lib/components/player/PlayerBar.svelte';
import PlayerDock from '$lib/components/player/PlayerDock.svelte';
import Queue from '$lib/components/player/Queue.svelte';
import Sidebar from '$lib/components/Sidebar.svelte';
import TopBar from '$lib/components/TopBar.svelte';
import MobileHeader from '$lib/components/MobileHeader.svelte';
import MobileNav from '$lib/components/MobileNav.svelte';
import Modal from '$lib/components/ui/Modal.svelte';
import PlaylistForm from '$lib/components/ui/PlaylistForm.svelte';
import { player } from '$lib/player/player.svelte';
import { ACCENT_LIGHTNESS, ACCENT_CHROMA, ACCENT_HUE } from '$lib/theme/color';
import { queuePanel } from '$lib/player/queuePanel.svelte';
import { liked } from '$lib/player/liked.svelte';
import { createPlaylistModal } from '$lib/playlists.svelte';
import { ACCENT_HUE } from '$lib/theme/color';
import { buildThemeTokens, applyThemeTokens } from '$lib/theme/tokens';
import { themeMode } from '$lib/theme/mode.svelte';
import { trackNavigation } from '$lib/navigation.svelte';
import { page } from '$app/state';
@@ -18,9 +25,19 @@
trackNavigation();
$effect(() => {
if (data.likedTracks?.length) liked.hydrate(data.likedTracks);
});
const isAuthPage = $derived(page.url.pathname === '/login');
const hasTrack = $derived(player.currentId !== null);
let hadTrack = false;
$effect(() => {
if (hasTrack && !hadTrack) queuePanel.show();
hadTrack = hasTrack;
});
function isTypingTarget(target: EventTarget | null): boolean {
if (!(target instanceof HTMLElement)) return false;
if (target.isContentEditable) return true;
@@ -33,37 +50,30 @@
player.toggle();
}
function parseAccent(value: string): { c: number; h: number } | null {
const m = /oklch\(\s*[\d.]+%?\s+([\d.]+)\s+([\d.]+)/.exec(value);
return m ? { c: parseFloat(m[1]), h: parseFloat(m[2]) } : null;
function parseHue(value: string): number | null {
const m = /oklch\(\s*[\d.]+%?\s+[\d.]+\s+([\d.]+)/.exec(value);
return m ? parseFloat(m[1]) : null;
}
let displayed = { c: ACCENT_CHROMA, h: ACCENT_HUE };
let displayedHue = ACCENT_HUE;
let rafId = 0;
$effect(() => {
const target = parseAccent(player.accent);
if (!target) {
document.documentElement.style.setProperty('--mf-accent', player.accent);
return;
}
const targetHue = parseHue(player.accent) ?? ACCENT_HUE;
const mode = themeMode.current;
cancelAnimationFrame(rafId);
const from = { ...displayed };
let dh = target.h - from.h;
const fromHue = displayedHue;
let dh = targetHue - fromHue;
if (dh > 180) dh -= 360;
else if (dh < -180) dh += 360;
const start = performance.now();
const duration = 350;
const duration = 600;
const tick = (now: number) => {
const t = Math.min(1, (now - start) / duration);
const e = 1 - Math.pow(1 - t, 3);
const c = from.c + (target.c - from.c) * e;
const h = (((from.h + dh * e) % 360) + 360) % 360;
displayed = { c, h };
document.documentElement.style.setProperty(
'--mf-accent',
`oklch(${ACCENT_LIGHTNESS}% ${c.toFixed(3)} ${h.toFixed(1)})`
);
const eased = 1 - Math.pow(1 - t, 3);
const hue = (((fromHue + dh * eased) % 360) + 360) % 360;
displayedHue = hue;
applyThemeTokens(buildThemeTokens(hue, mode));
if (t < 1) rafId = requestAnimationFrame(tick);
};
rafId = requestAnimationFrame(tick);
@@ -80,25 +90,53 @@
{@render children()}
</div>
{:else}
<div class="flex min-h-screen bg-bg text-fg antialiased">
<Sidebar user={data.user} />
<div class="flex min-w-0 flex-1 flex-col">
<MobileHeader user={data.user} />
<TopBar user={data.user} />
<main
class="flex-1 transition-[padding]"
style="padding-bottom:calc(var(--mf-nav-h) + var(--mf-safe-b) + {hasTrack
? 'var(--mf-player-h)'
: '0px'})"
>
{#key page.url.pathname}
<div class="animate-fade">{@render children()}</div>
{/key}
</main>
{#if hasTrack}
<PlayerBar />
{/if}
<MobileNav user={data.user} />
<div class="relative flex min-h-screen text-fg antialiased">
<div
class="pointer-events-none fixed inset-0 -z-10 bg-bg"
style="background-image:var(--mf-ambient)"
></div>
<div
class="pointer-events-none fixed inset-0 -z-10"
style="background:radial-gradient(130% 85% at 50% 118%, color-mix(in oklch, var(--mf-scrim) 80%, transparent), transparent 62%)"
></div>
<Sidebar user={data.user} playlists={data.sidebarPlaylists ?? []} />
<div class="flex min-w-0 flex-1">
<div class="flex min-w-0 flex-1 flex-col">
<MobileHeader user={data.user} accountUrl={data.accountUrl} />
<TopBar user={data.user} accountUrl={data.accountUrl} />
<main
class="flex-1 transition-[padding]"
style="padding-bottom:calc(var(--mf-nav-h) + var(--mf-safe-b) + {hasTrack
? 'var(--mf-player-h)'
: '0px'})"
>
{#key page.url.pathname}
<div class="animate-fade">{@render children()}</div>
{/key}
</main>
</div>
<Queue />
</div>
<PlayerDock user={data.user} />
<Modal
open={createPlaylistModal.open}
onClose={() => createPlaylistModal.close()}
title="Crear playlist"
>
<PlaylistForm
action="/playlists?/create"
formMessage={page.form?.message}
submitLabel="Crear"
submittingLabel="Creando…"
helperText="Podrás añadir canciones más tarde."
onCancel={() => createPlaylistModal.close()}
onSuccess={() => createPlaylistModal.close()}
/>
</Modal>
</div>
{/if}
+31 -45
View File
@@ -1,21 +1,14 @@
import { redirect } from '@sveltejs/kit';
import type { PageServerLoad, Actions } from './$types';
import { createApiClient, requireUser } from '$lib/server/api';
import { fetchYoutubeAlbumFiller, fetchYoutubeFiller } from '$lib/server/youtube';
import { fetchYoutubeFiller } from '$lib/server/youtube';
import { addTrackAction, addYouTubeToPlaylistAction } from '$lib/server/playlistActions';
import {
addAlbumToPlaylistAction,
addTrackAction,
addYouTubeToPlaylistAction
} from '$lib/server/playlistActions';
import {
HOME_ALBUM_FILLER_LIMIT,
HOME_ALBUMS_LIMIT,
HOME_LATEST_PAGE_SIZE,
HOME_MIXES_BENTO,
HOME_MIXES_LIMIT,
HOME_POPULAR_FILLER_LIMIT,
HOME_SHELF_LIMIT,
HOME_TOP_MUSIC_FILLER_LIMIT,
YOUTUBE_FILLER_LIMIT
HOME_SPOTLIGHT_TRACKS_LIMIT
} from '$lib/config';
import { pickGreeting } from '$lib/server/greeting';
@@ -34,18 +27,18 @@ export const load: PageServerLoad = async ({ locals, url, fetch }) => {
query: { pageNumber: 1, pageSize: HOME_SHELF_LIMIT + 1 }
}
});
const albumsPromise = api.GET('/albums/recent', {
params: { query: { limit: HOME_ALBUMS_LIMIT } }
});
const mixesPromise = api.GET('/mixes');
const recentlyPlayedPromise = api.GET('/users/{id}/listening-history', {
params: { path: { id: user.sub } }
});
const listeningStatsPromise = api
.GET('/users/{id}/listening-stats', { params: { path: { id: user.sub } } })
.then((res) => res.data ?? { tracksThisWeek: 0, secondsThisWeek: 0, streakDays: 0 })
.catch(() => ({ tracksThisWeek: 0, secondsThisWeek: 0, streakDays: 0 }));
const [latest, playlists, albums, mixes, recentlyPlayed] = await Promise.all([
const [latest, playlists, mixes, recentlyPlayed] = await Promise.all([
latestPromise,
playlistsPromise,
albumsPromise,
mixesPromise,
recentlyPlayedPromise
]);
@@ -54,30 +47,32 @@ export const load: PageServerLoad = async ({ locals, url, fetch }) => {
item.track ? [item.track] : []
);
const spotlightPlaylist = playlistItems[0] ?? null;
const spotlightTracksPromise = spotlightPlaylist
? api
.GET('/playlists/{playlistId}/tracks', {
params: {
path: { playlistId: spotlightPlaylist.id },
query: { pageNumber: 1, pageSize: HOME_SPOTLIGHT_TRACKS_LIMIT }
}
})
.then((res) => ({
items: res.data?.items ?? [],
totalCount: Number(res.data?.totalItemCount ?? 0)
}))
.catch(() => ({ items: [], totalCount: 0 }))
: Promise.resolve({ items: [], totalCount: 0 });
return {
greeting: pickGreeting(new Date().getHours()),
greeting: pickGreeting(new Date()),
recentlyPlayed: recentlyPlayed.data ?? [],
albums: albums.data ?? [],
mixes: (mixes.data ?? []).slice(0, HOME_MIXES_BENTO),
mixes: (mixes.data ?? []).slice(0, HOME_MIXES_LIMIT),
playlists: playlistItems.slice(0, HOME_SHELF_LIMIT),
playlistsHasMore: playlistItems.length > HOME_SHELF_LIMIT,
newReleases: latestItems,
topMusicFiller: fetchYoutubeFiller(
api,
locals.accessToken,
HOME_TOP_MUSIC_FILLER_LIMIT,
undefined,
user.sub,
3
),
youtubeFiller: fetchYoutubeFiller(
api,
locals.accessToken,
YOUTUBE_FILLER_LIMIT,
undefined,
user.sub,
0
),
spotlightPlaylist,
spotlightTracks: spotlightTracksPromise,
listeningStats: listeningStatsPromise,
popularFiller: fetchYoutubeFiller(
api,
locals.accessToken,
@@ -85,20 +80,11 @@ export const load: PageServerLoad = async ({ locals, url, fetch }) => {
undefined,
user.sub,
1
),
albumFiller: fetchYoutubeAlbumFiller(
api,
locals.accessToken,
HOME_ALBUM_FILLER_LIMIT,
undefined,
user.sub,
2
)
};
};
export const actions: Actions = {
addTrack: addTrackAction,
addYouTubeToPlaylist: addYouTubeToPlaylistAction,
addAlbumToPlaylist: addAlbumToPlaylistAction
addYouTubeToPlaylist: addYouTubeToPlaylistAction
};
+366 -506
View File
@@ -1,11 +1,11 @@
<script lang="ts">
import ArrowRight from '@lucide/svelte/icons/arrow-right';
import ChevronLeft from '@lucide/svelte/icons/chevron-left';
import ChevronRight from '@lucide/svelte/icons/chevron-right';
import MoreHorizontal from '@lucide/svelte/icons/more-horizontal';
import Play from '@lucide/svelte/icons/play';
import Pause from '@lucide/svelte/icons/pause';
import { player, toQueueItems, type QueueItem } from '$lib/player/player.svelte';
import History from '@lucide/svelte/icons/history';
import Shuffle from '@lucide/svelte/icons/shuffle';
import ListMusic from '@lucide/svelte/icons/list-music';
import { player } from '$lib/player/player.svelte';
import { mergeRecentlyPlayed } from '$lib/recentlyPlayed';
import { shuffle as shuffleList } from '$lib/collections';
import { fmtTime, fmtDurationLong } from '$lib/format';
import {
isTargetCurrent,
queueItemForTarget,
@@ -15,64 +15,33 @@
targetExplicit,
targetForQueueItem,
targetId,
targetOwnerUserId,
targetTitle,
type TrackTarget
} from '$lib/tracks';
import { fmtTime } from '$lib/format';
import Cover from '$lib/components/ui/Cover.svelte';
import ArtistLink from '$lib/components/ui/ArtistLink.svelte';
import PlaylistArt from '$lib/components/ui/PlaylistArt.svelte';
import MixBentoTile from '$lib/components/ui/MixBentoTile.svelte';
import Rail from '$lib/components/ui/Rail.svelte';
import NowPlaying from '$lib/components/ui/NowPlaying.svelte';
import MixTile from '$lib/components/ui/MixTile.svelte';
import EqBars from '$lib/components/ui/EqBars.svelte';
import ExplicitBadge from '$lib/components/ui/ExplicitBadge.svelte';
import SectionHeading from '$lib/components/ui/SectionHeading.svelte';
import EmptyState from '$lib/components/ui/EmptyState.svelte';
import Button from '$lib/components/ui/Button.svelte';
import TrackContextMenu, {
contextMenuStateFor,
type ContextMenuState
} from '$lib/components/TrackContextMenu.svelte';
import AlbumContextMenu, {
albumContextMenuStateFor,
type AlbumMenuState
} from '$lib/components/AlbumContextMenu.svelte';
import { fetchAlbumQueueItems } from '$lib/albums';
import type { YouTubeAlbumResult, YouTubeSong } from '$lib/types';
import type { YouTubeSong } from '$lib/types';
import { appendUnique } from '$lib/collections';
import { HOME_LATEST_PAGE_SIZE, HOME_POPULAR_MAX } from '$lib/config';
import { HOME_CONTINUE_LIMIT, HOME_POPULAR_MAX } from '$lib/config';
let { data } = $props();
let contextMenu = $state<ContextMenuState | null>(null);
let albumMenu = $state<AlbumMenuState | null>(null);
let topMusicFillerItems = $state<YouTubeSong[]>([]);
let youtubeFillerItems = $state<YouTubeSong[]>([]);
let popularFillerItems = $state<YouTubeSong[]>([]);
let albumFillerItems = $state<YouTubeAlbumResult[]>([]);
let topMusicRail: ReturnType<typeof Rail> | undefined = $state();
let recommendedRail: ReturnType<typeof Rail> | undefined = $state();
$effect(() => {
topMusicFillerItems = [];
let cancelled = false;
data.topMusicFiller.then((filler) => {
if (cancelled) return;
topMusicFillerItems = appendUnique([], filler.items, (song) => song.videoId);
});
return () => {
cancelled = true;
};
});
$effect(() => {
youtubeFillerItems = [];
let cancelled = false;
data.youtubeFiller.then((filler) => {
if (cancelled) return;
youtubeFillerItems = appendUnique([], filler.items, (song) => song.videoId);
});
return () => {
cancelled = true;
};
});
let spotlightItems = $state<TrackTarget[]>([]);
let spotlightTotal = $state(0);
$effect(() => {
popularFillerItems = [];
@@ -87,96 +56,76 @@
});
$effect(() => {
albumFillerItems = [];
spotlightItems = [];
spotlightTotal = 0;
let cancelled = false;
data.albumFiller.then((filler) => {
data.spotlightTracks.then(({ items, totalCount }) => {
if (cancelled) return;
albumFillerItems = appendUnique([], filler.items, (album) => album.albumId);
spotlightItems = items.map((track) => ({ kind: 'local' as const, track }));
spotlightTotal = totalCount;
});
return () => {
cancelled = true;
};
});
const albums = $derived(data.albums);
const mixes = $derived(data.mixes);
const playlists = $derived(data.playlists);
const newTargets = $derived<TrackTarget[]>(
data.newReleases.map((track) => ({ kind: 'local' as const, track }))
const spotlight = $derived(data.spotlightPlaylist);
const spotlightDurationSeconds = $derived(
spotlightItems.some((item) => targetDurationSeconds(item) !== undefined)
? spotlightItems.reduce((sum, item) => sum + (targetDurationSeconds(item) ?? 0), 0)
: undefined
);
const topMusicTargets = $derived<TrackTarget[]>([
...newTargets,
...topMusicFillerItems.map((song) => ({ kind: 'youtube' as const, song }))
]);
const recTargets = $derived<TrackTarget[]>(
youtubeFillerItems.map((song) => ({ kind: 'youtube' as const, song }))
const spotlightTrackCount = $derived(spotlightTotal || spotlightItems.length);
const spotlightVisibilityLabel = $derived(
spotlight?.visibility === 'Public' ? 'Pública' : 'Privada'
);
const spotlightMeta = $derived(
spotlightTrackCount === 0
? spotlightVisibilityLabel
: [
`${spotlightTrackCount} ${spotlightTrackCount === 1 ? 'canción' : 'canciones'}`,
spotlightDurationSeconds !== undefined
? fmtDurationLong(spotlightDurationSeconds)
: '0 min',
spotlightVisibilityLabel
].join(' · ')
);
type RecommendedAlbum =
| { kind: 'recent'; album: (typeof albums)[number] }
| { kind: 'discover'; album: YouTubeAlbumResult };
const recentlyPlayed = $derived.by(() => {
const seen = new Set<string>();
const merged: QueueItem[] = [];
for (const t of player.recentlyPlayed) {
const id = String(t.id);
if (seen.has(id)) continue;
seen.add(id);
merged.push({
id,
title: t.title,
artist: t.artist,
source: t.source,
coverUrl: t.coverUrl,
explicit: t.explicit
});
}
for (const item of toQueueItems(data.recentlyPlayed)) {
const id = String(item.id);
if (seen.has(id)) continue;
seen.add(id);
merged.push({
id,
title: item.title,
artist: item.artist,
source: item.source ?? 'local',
coverUrl: item.coverUrl,
explicit: item.explicit
});
}
return merged.slice(0, HOME_LATEST_PAGE_SIZE);
});
const continueItems = $derived(mergeRecentlyPlayed(data.recentlyPlayed, HOME_CONTINUE_LIMIT));
const recentPool = $derived(mergeRecentlyPlayed(data.recentlyPlayed, 24));
const popularTargets = $derived<TrackTarget[]>(
[
...recentlyPlayed.map(targetForQueueItem),
...recentPool.map(targetForQueueItem),
...popularFillerItems.map((song) => ({ kind: 'youtube' as const, song }))
].slice(0, HOME_POPULAR_MAX)
);
const recommendedAlbums = $derived<RecommendedAlbum[]>([
...albums.map((album) => ({ kind: 'recent' as const, album })),
...albumFillerItems.map((album) => ({ kind: 'discover' as const, album }))
]);
const heroTrack = $derived(recentlyPlayed[0] ?? null);
const heroPlaying = $derived(
heroTrack !== null && player.current.id === heroTrack.id && player.playing
const spotlightQueue = $derived(spotlightItems.map(queueItemForTarget));
const spotlightIsCurrent = $derived(
spotlightQueue.length > 0 && spotlightQueue.some((item) => item.id === player.current.id)
);
const heroPlaying = $derived(spotlightIsCurrent && player.playing);
function playRecent(index: number) {
player.playOrToggle(recentlyPlayed, index);
}
function playTopMusic(index: number) {
player.playOrToggle(topMusicTargets.map(queueItemForTarget), index);
function playContinue(index: number) {
player.playOrToggle(continueItems, index);
}
function playPopular(index: number) {
player.playOrToggle(popularTargets.map(queueItemForTarget), index);
}
function playRecommended(index: number) {
player.playOrToggle(recTargets.map(queueItemForTarget), index);
function playSpotlightTrack(index: number) {
player.playOrToggle(spotlightQueue, index);
}
function playSpotlight() {
if (spotlightQueue.length === 0) return;
if (spotlightIsCurrent) player.toggle();
else player.playQueue(spotlightQueue, 0);
}
function shuffleSpotlight() {
if (spotlightQueue.length === 0) return;
player.playQueue(shuffleList(spotlightQueue), 0);
}
function openContextMenu(event: MouseEvent, target: TrackTarget) {
@@ -187,39 +136,6 @@
player.addToQueue(queueItemForTarget(contextMenu));
contextMenu = null;
}
function openAlbumMenu(event: MouseEvent, kind: 'local' | 'youtube', albumId: string) {
albumMenu = albumContextMenuStateFor(event, kind, albumId);
}
async function albumPlayNext() {
if (!albumMenu) return;
const { kind, albumId } = albumMenu;
albumMenu = null;
player.playNext(await fetchAlbumQueueItems(kind, albumId));
}
async function albumAddToQueue() {
if (!albumMenu) return;
const { kind, albumId } = albumMenu;
albumMenu = null;
player.appendToQueue(await fetchAlbumQueueItems(kind, albumId));
}
function albumCaption(album: (typeof albums)[number]) {
return [
album.releaseYear ? String(album.releaseYear) : undefined,
`${Number(album.trackCount)} ${Number(album.trackCount) === 1 ? 'canción' : 'canciones'}`
]
.filter(Boolean)
.join(' · ');
}
function youtubeAlbumCaption(album: YouTubeAlbumResult) {
return [album.artist, album.releaseYear ? String(album.releaseYear) : undefined]
.filter(Boolean)
.join(' · ');
}
</script>
<svelte:head>
@@ -227,399 +143,353 @@
<meta name="description" content="Tu música, sin límites." />
</svelte:head>
{#snippet railArrows(rail: ReturnType<typeof Rail> | undefined)}
<div class="flex shrink-0 gap-2">
<button
type="button"
onclick={() => rail?.scrollByPage(-1)}
aria-label="Anterior"
class="grid h-8 w-8 place-items-center rounded-full border border-line text-fg-2 transition hover:border-accent-soft/50 hover:text-accent-soft"
>
<ChevronLeft class="h-4 w-4" />
</button>
<button
type="button"
onclick={() => rail?.scrollByPage(1)}
aria-label="Siguiente"
class="grid h-8 w-8 place-items-center rounded-full border border-line text-fg-2 transition hover:border-accent-soft/50 hover:text-accent-soft"
>
<ChevronRight class="h-4 w-4" />
</button>
</div>
{/snippet}
<section class="relative overflow-hidden page-x pt-8 pb-8 sm:pt-10 sm:pb-10">
<div class="pointer-events-none absolute inset-0 -z-10 overflow-hidden">
<section class="page-x pt-3 sm:pt-4">
<div
class="relative overflow-hidden rounded-[24px] bg-[image:var(--mf-hero-bg)] px-6 py-8 transition-[background] duration-[600ms] sm:px-9 sm:py-8.5"
>
<div
class="absolute inset-x-0 top-[-19rem] h-[34rem] sm:h-[42rem]"
style="background:radial-gradient(58% 100% at 50% 0%, color-mix(in oklch, var(--color-accent), transparent 5%), color-mix(in oklch, var(--color-accent), transparent 48%) 42%, transparent 78%); animation:breathe 14s ease-in-out infinite"
></div>
<div class="absolute inset-x-0 bottom-0 h-16 bg-linear-to-b from-transparent to-bg"></div>
</div>
<div class="relative flex flex-col gap-10 lg:flex-row lg:items-end lg:justify-between lg:gap-8">
<div class="animate-enter flex max-w-2xl flex-col gap-4">
<p class="text-xs font-semibold tracking-[0.28em] text-fg-2 uppercase sm:text-sm">
{data.greeting}
</p>
<h1
class="font-display text-[clamp(3rem,7.5vw,5.5rem)] leading-[0.98] font-medium tracking-tight text-fg"
>
Tu música,<br /><span class="text-accent-soft">sin límites.</span>
</h1>
<p class="max-w-md text-base text-fg-2 sm:text-lg">
Sube lo tuyo, descubre lo nuevo y escúchalo todo en un solo sitio.
</p>
</div>
{#if heroTrack}
<button
type="button"
onclick={() => playRecent(0)}
aria-label="{heroPlaying ? 'Pausar' : 'Reproducir'} {heroTrack.title}"
class="group/hero animate-enter relative flex w-full max-w-sm shrink-0 items-center gap-4 overflow-hidden rounded-panel border border-line-strong bg-surface/70 p-4 text-left backdrop-blur-xl transition duration-300 hover:border-accent-soft/50 hover:bg-surface-hover hover:shadow-glow sm:p-5"
style="animation-delay:140ms"
>
<Cover
trackId={heroTrack.id}
src={heroTrack.coverUrl}
size="medium"
alt={heroTrack.title}
class="h-16 w-16 shrink-0 rounded-art shadow-art sm:h-20 sm:w-20"
class="relative flex flex-col items-start gap-7 lg:flex-row lg:flex-wrap lg:items-end lg:justify-between"
>
<div class="animate-enter max-w-135 min-w-65">
<h1
class="font-display text-[28px] leading-[1.1] font-semibold tracking-[-0.035em] text-pretty text-fg sm:text-[36px]"
>
{#if player.current.id === heroTrack.id}
<NowPlaying paused={!player.playing} />
{/if}
</Cover>
<div class="min-w-0 flex-1">
<p class="text-[0.65rem] font-semibold tracking-[0.2em] text-fg-2 uppercase">
Seguir escuchando
</p>
<p class="mt-1 truncate font-display text-lg text-fg">{heroTrack.title}</p>
{#if heroTrack.artist}
<p class="truncate text-sm text-fg-2">{heroTrack.artist}</p>
{data.greeting}
</h1>
<p class="mt-3.5 max-w-108 text-[13.5px] leading-[1.65] text-fg-2">
Sube lo tuyo, descubre lo nuevo y escúchalo todo en un solo sitio.
</p>
<div class="mt-6.5 flex flex-wrap items-center gap-2.5">
{#if spotlight}
<Button
variant="accent"
class="!bg-cta-strong !font-bold !text-ink hover:!brightness-95"
onclick={playSpotlight}
>
{heroPlaying ? 'Pausar' : 'Reanudar'}
{spotlight.name}
</Button>
{/if}
<Button href="/explore" variant="secondary" class="!font-normal">Explorar música</Button>
</div>
<span
class="grid h-11 w-11 shrink-0 place-items-center rounded-full bg-accent-soft text-on-accent shadow-art transition duration-300 group-hover/hero:scale-105"
>
{#if heroPlaying}
<Pause class="h-4.5 w-4.5" fill="currentColor" />
{:else}
<Play class="h-4.5 w-4.5 translate-x-0.5" fill="currentColor" />
{/if}
</span>
</button>
{/if}
</div>
{#await data.listeningStats then stats}
<div class="flex gap-6.5">
<div>
<div class="font-display text-[25px] font-semibold tracking-[-0.03em] text-fg">
{stats.tracksThisWeek}
</div>
<div class="mt-1 text-[11.5px] text-fg-3">Canciones nuevas escuchadas esta semana</div>
</div>
<div>
<div class="font-display text-[25px] font-semibold tracking-[-0.03em] text-fg">
{fmtDurationLong(Number(stats.secondsThisWeek))}
</div>
<div class="mt-1 text-[11.5px] text-fg-3">Tiempo de escucha esta semana</div>
</div>
<div>
<div class="font-display text-[25px] font-semibold tracking-[-0.03em] text-fg">
{stats.streakDays}
</div>
<div class="mt-1 text-[11.5px] text-fg-3">Días consecutivos escuchando</div>
</div>
</div>
{/await}
</div>
</div>
</section>
<div class="flex flex-col gap-12 page-x pb-8 sm:pb-10">
{#if topMusicTargets.length > 0}
<section>
<SectionHeading title="Top música">
{#snippet actions()}
{@render railArrows(topMusicRail)}
{/snippet}
</SectionHeading>
<Rail bind:this={topMusicRail}>
{#each topMusicTargets as target, i (targetId(target))}
<button
type="button"
onclick={() => playTopMusic(i)}
oncontextmenu={(e) => openContextMenu(e, target)}
aria-label="Reproducir {targetTitle(target)}"
class="group/card animate-enter block w-36 shrink-0 rounded-art text-left focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-bg focus-visible:outline-none"
style="animation-delay:{Math.min(i, 10) * 45}ms"
<div class="flex flex-col gap-11.5 page-x pt-10 pb-8 sm:pb-10">
<!-- CONTINUAR ESCUCHANDO -->
<section>
<SectionHeading title="Continuar escuchando" />
{#if continueItems.length > 0}
<div class="grid grid-cols-2 gap-2.5 sm:grid-cols-3 lg:grid-cols-5">
{#each continueItems as item, i (item.id)}
{@const isCurrent = player.current.id === item.id}
<div
role="button"
tabindex="0"
onclick={() => playContinue(i)}
onkeydown={(e) => {
if (e.key !== 'Enter' && e.key !== ' ') return;
e.preventDefault();
playContinue(i);
}}
oncontextmenu={(e) => openContextMenu(e, targetForQueueItem(item))}
class="group/cont animate-enter flex items-center gap-3.25 rounded-[13px] p-2.25 pr-3.5 text-left transition-colors {isCurrent
? 'bg-accent-tint'
: 'hover:bg-white/[3%]'}"
style="animation-delay:{Math.min(i, 10) * 40}ms"
>
<div class="relative h-36 w-36">
<Cover
trackId={targetId(target)}
src={targetCoverSrc(target)}
size="large"
alt={targetTitle(target)}
class="h-full w-full rounded-art shadow-art ring-1 ring-line transition duration-300 ease-out ring-inset group-hover/card:shadow-art-lg"
<Cover
trackId={item.id}
src={item.coverUrl}
size="small"
alt={item.title}
class="h-13 w-13 shrink-0 rounded-[10px]"
/>
<div class="min-w-0 flex-1">
<div
class="truncate text-sm leading-[1.3] font-medium {isCurrent
? 'text-accent-soft'
: 'text-fg'}"
>
{#if isTargetCurrent(target)}
<NowPlaying paused={!player.playing} />
{/if}
</Cover>
{item.title}
</div>
<ArtistLink
name={item.artist || '—'}
ownerUserId={item.ownerUserId}
class="mt-0.75 text-[12.5px] text-fg-3"
/>
</div>
<div class="mt-3 flex min-w-0 items-center gap-1.5">
{#if targetExplicit(target)}
<ExplicitBadge />
{/if}
<span class="truncate text-fg transition-colors group-hover/card:text-accent-soft">
{targetTitle(target)}
</div>
{/each}
</div>
{:else}
<EmptyState
icon={History}
title="Aún no has escuchado nada"
description="Reproduce alguna canción y tu actividad reciente aparecerá aquí."
>
{#snippet actions()}
<Button href="/explore" variant="secondary">Explorar música</Button>
{/snippet}
</EmptyState>
{/if}
</section>
<!-- PLAYLIST DESTACADA -->
{#if spotlight}
<section>
<div
class="grid grid-cols-1 overflow-hidden rounded-panel-lg bg-[image:var(--mf-spotlight-bg)] transition-[background] duration-[600ms] lg:grid-cols-2"
>
<div class="flex flex-col justify-center gap-5 p-8 sm:p-9">
<div class="flex items-center gap-5">
<PlaylistArt
playlistId={spotlight.id}
trackIds={spotlight.coverTrackIds}
version={spotlight.updatedAt}
size="large"
class="h-28 w-28 shrink-0 rounded-[16px] shadow-[0_16px_40px_rgba(0,0,0,.5)]"
/>
<div class="min-w-0">
<p class="text-[11px] font-semibold tracking-[0.16em] text-fg-2 uppercase">
Playlist destacada
</p>
<h3
class="mt-2.5 truncate font-display text-[28px] font-semibold tracking-[-0.03em] text-fg"
>
{spotlight.name}
</h3>
<p class="mt-2 text-[13px] text-fg-2">{spotlightMeta}</p>
</div>
</div>
<p class="max-w-110 text-sm leading-[1.7] text-fg-2">
{spotlight.description || 'Tu colección, siempre a mano.'}
</p>
<div class="mt-1 flex gap-2.5">
<Button variant="accent" onclick={playSpotlight}>Reproducir</Button>
<Button variant="secondary" class="!font-normal" onclick={shuffleSpotlight}>
Aleatorio
</Button>
</div>
</div>
<div
class="flex flex-col gap-0.5 p-5.5 sm:p-6"
style="background-image:linear-gradient(to right, color-mix(in oklch, var(--mf-scrim) 26%, transparent), transparent)"
>
{#each spotlightItems as target, i (targetId(target))}
{@const durationSeconds = targetDurationSeconds(target)}
<div
role="button"
tabindex="0"
onclick={() => playSpotlightTrack(i)}
onkeydown={(e) => {
if (e.key !== 'Enter' && e.key !== ' ') return;
e.preventDefault();
playSpotlightTrack(i);
}}
class="flex items-center gap-3 rounded-[10px] p-2 text-left transition-colors hover:bg-white/[3%] {isTargetCurrent(
target
)
? 'bg-accent-tint'
: ''}"
>
<span class="w-5 shrink-0 text-center text-[11.5px] text-muted tabular-nums"
>{i + 1}</span
>
<div class="min-w-0 flex-1">
<div
class="truncate text-[12.5px] font-medium {isTargetCurrent(target)
? 'text-accent-soft'
: 'text-fg'}"
>
{targetTitle(target)}
</div>
<ArtistLink
name={targetArtist(target)}
ownerUserId={targetOwnerUserId(target)}
class="mt-0.5 text-[11px] text-fg-3"
/>
</div>
<span class="shrink-0 text-[11.5px] text-muted tabular-nums">
{durationSeconds !== undefined ? fmtTime(durationSeconds) : '—'}
</span>
</div>
{#if targetArtist(target)}
<div class="mt-0.5 truncate text-sm text-fg-2">{targetArtist(target)}</div>
{/if}
</button>
{/each}
</Rail>
{:else}
<p class="p-2 text-sm text-fg-3">Esta playlist todavía no tiene canciones.</p>
{/each}
</div>
</div>
</section>
{/if}
<div class="grid grid-cols-1 gap-10 lg:grid-cols-2 lg:gap-8">
<section class="min-w-0">
<SectionHeading title="Popular" />
<div class="flex flex-col gap-1.5">
<!-- MIXES -->
<section>
<SectionHeading title="Mixes" subtitle="Generados a partir de lo que más repites" />
{#if mixes.length > 0}
<div class="grid grid-cols-[repeat(auto-fill,minmax(164px,1fr))] gap-5">
{#each mixes as mix, i (mix.id)}
<MixTile {mix} index={i} />
{/each}
</div>
{:else}
<EmptyState
icon={Shuffle}
title="Aún no tienes mezclas"
description="Se generan automáticamente a partir de lo que escuchas."
/>
{/if}
</section>
<!-- POPULARES ESTA SEMANA -->
<section>
<SectionHeading
title="Populares esta semana"
subtitle="Lo más escuchado entre quienes comparten tu gusto"
/>
{#if popularTargets.length > 0}
<div class="grid grid-cols-1 gap-x-8.5 gap-y-1.5 lg:grid-cols-2">
{#each popularTargets as target, i (targetId(target))}
{@const isCurrent = isTargetCurrent(target)}
<div
class="group/row flex min-w-0 items-center gap-3 rounded-control p-2 transition duration-200 {isCurrent
? 'bg-surface-hover'
: 'hover:bg-hover'}"
role="button"
tabindex="0"
onclick={() => playPopular(i)}
onkeydown={(e) => {
if (e.key !== 'Enter' && e.key !== ' ') return;
e.preventDefault();
playPopular(i);
}}
oncontextmenu={(e) => openContextMenu(e, target)}
class="flex items-center gap-3.5 rounded-[11px] p-2 text-left transition-colors {isCurrent
? 'bg-accent-tint'
: 'hover:bg-white/[3%]'}"
>
<button
type="button"
onclick={() => playPopular(i)}
aria-label="{isCurrent && player.playing ? 'Pausar' : 'Reproducir'} {targetTitle(
target
)}"
class="relative shrink-0"
>
<Cover
trackId={targetId(target)}
src={targetCoverSrc(target)}
size="small"
alt={targetTitle(target)}
class="h-11 w-11 rounded-control shadow-art transition duration-200"
>
{#if isCurrent}
<NowPlaying paused={!player.playing} />
{/if}
</Cover>
{#if !isCurrent}
<span
class="absolute inset-0 grid place-items-center rounded-control bg-scrim/0 opacity-0 transition duration-200 group-hover/row:bg-scrim/50 group-hover/row:opacity-100"
>
<Play class="h-3.5 w-3.5 translate-x-0.5 text-on-art" fill="currentColor" />
</span>
<span class="flex h-3.5 w-4.5 shrink-0 items-end justify-center">
{#if isCurrent}
<EqBars size={13} paused={!player.playing} />
{:else}
<span class="text-xs text-muted tabular-nums">{i + 1}</span>
{/if}
</button>
<button type="button" onclick={() => playPopular(i)} class="min-w-0 flex-1 text-left">
</span>
<Cover
trackId={targetId(target)}
src={targetCoverSrc(target)}
size="small"
alt={targetTitle(target)}
class="h-9.5 w-9.5 shrink-0 rounded-[8px]"
/>
<div class="min-w-0 flex-1">
<div class="flex min-w-0 items-center gap-1.5">
{#if targetExplicit(target)}
<ExplicitBadge />
{/if}
<span
class="truncate text-sm text-fg transition-colors group-hover/row:text-accent-soft"
class="truncate text-[12.5px] font-medium {isCurrent
? 'text-accent-soft'
: 'text-fg'}"
>
{targetTitle(target)}
</span>
</div>
{#if targetArtist(target)}
<div class="truncate text-xs text-fg-2">{targetArtist(target)}</div>
{/if}
</button>
<button
type="button"
onclick={(e) => openContextMenu(e, target)}
aria-label="Más opciones"
class="shrink-0 rounded-full p-1.5 text-fg-2 opacity-0 transition group-hover/row:opacity-100 hover:text-fg"
>
<MoreHorizontal class="h-4 w-4" />
</button>
<ArtistLink
name={targetArtist(target)}
ownerUserId={targetOwnerUserId(target)}
class="mt-0.5 text-[11.5px] text-fg-3"
/>
</div>
</div>
{/each}
</div>
</section>
<section class="min-w-0">
<SectionHeading title="Álbumes recomendados">
{:else}
<EmptyState
icon={History}
title="Aún no has escuchado nada"
description="Reproduce alguna canción y tu actividad reciente aparecerá aquí."
>
{#snippet actions()}
{@render railArrows(recommendedRail)}
<Button href="/explore" variant="secondary">Explorar música</Button>
{/snippet}
</SectionHeading>
<Rail bind:this={recommendedRail}>
{#each recommendedAlbums as entry, i (entry.kind === 'recent' ? entry.album.id : entry.album.albumId)}
{#if entry.kind === 'recent'}
{@const album = entry.album}
<a
href={album.source === 'YouTube'
? `/albums/external/youtube/${album.externalId}`
: `/albums/${album.id}`}
class="group/card animate-enter block w-36 shrink-0 text-center"
style="animation-delay:{Math.min(i, 10) * 45}ms"
oncontextmenu={(e) =>
openAlbumMenu(e, album.source === 'YouTube' ? 'youtube' : 'local', album.id)}
>
{#if album.source === 'YouTube' && album.thumbnailUrl}
<Cover
trackId={album.id}
src={album.thumbnailUrl}
size="large"
alt={album.title}
class="mx-auto h-36 w-36 rounded-art shadow-art ring-1 ring-line transition duration-300 ease-out ring-inset group-hover/card:shadow-art-lg"
/>
{:else}
<PlaylistArt
trackIds={album.coverTrackIds}
class="mx-auto h-36 w-36 rounded-art shadow-art ring-1 ring-line transition duration-300 ease-out ring-inset group-hover/card:shadow-art-lg"
/>
{/if}
<div
class="mt-3.5 truncate text-fg transition-colors group-hover/card:text-accent-soft"
>
{album.title}
</div>
<div class="mt-0.5 truncate text-sm text-fg-2">{albumCaption(album)}</div>
</a>
{:else}
{@const album = entry.album}
<a
href="/albums/external/youtube/{album.albumId}"
class="group/card animate-enter block w-36 shrink-0 text-center"
style="animation-delay:{Math.min(i, 10) * 45}ms"
oncontextmenu={(e) => openAlbumMenu(e, 'youtube', album.albumId)}
>
<Cover
trackId={album.albumId}
src={album.thumbnailUrl}
size="large"
alt={album.title}
class="mx-auto h-36 w-36 rounded-art shadow-art ring-1 ring-line transition duration-300 ease-out ring-inset group-hover/card:shadow-art-lg"
/>
<div
class="mt-3.5 truncate text-fg transition-colors group-hover/card:text-accent-soft"
>
{album.title}
</div>
<div class="mt-0.5 truncate text-sm text-fg-2">{youtubeAlbumCaption(album)}</div>
</a>
{/if}
{/each}
</Rail>
</section>
</div>
</EmptyState>
{/if}
</section>
{#if mixes.length > 0}
<section>
<SectionHeading title="Mezclas" />
<div class="grid grid-cols-2 gap-3 sm:auto-rows-25 sm:grid-cols-4 sm:gap-4">
{#each mixes as mix, i (mix.id)}
<MixBentoTile
{mix}
index={i}
featured={i === 0}
banner={i === 4}
class={i === 0
? 'col-span-2 min-h-42 sm:row-span-2 sm:min-h-0'
: i === 1
? 'col-span-2 min-h-30 sm:min-h-0'
: i === 4
? 'col-span-2 min-h-24 sm:col-span-4 sm:min-h-0'
: 'min-h-30 sm:min-h-0'}
/>
{/each}
</div>
</section>
{/if}
{#if playlists.length > 0}
<section>
<SectionHeading title="Listas de reproducción" />
<Rail>
<!-- TUS PLAYLISTS -->
<section>
<SectionHeading title="Tus playlists" subtitle="Creadas y guardadas por ti">
{#snippet actions()}
{#if data.playlistsHasMore}
<a
href="/playlists"
class="shrink-0 text-[11.5px] font-semibold tracking-[0.07em] text-fg-3 uppercase transition-colors hover:text-fg"
>
Ver todas
</a>
{/if}
{/snippet}
</SectionHeading>
{#if playlists.length > 0}
<div class="grid grid-cols-[repeat(auto-fill,minmax(258px,1fr))] gap-3.5">
{#each playlists as playlist, i (playlist.id)}
<a
href="/playlists/{playlist.id}"
class="group/card animate-enter block w-40 shrink-0 pr-2 pb-2"
style="animation-delay:{Math.min(i, 10) * 45}ms"
class="group/card animate-enter flex items-center gap-3.5 rounded-[15px] p-3 transition-colors hover:bg-white/[3%]"
style="animation-delay:{Math.min(i, 10) * 40}ms"
>
<div class="relative h-40 w-40">
<div
class="absolute inset-0 translate-x-2 translate-y-2 rounded-art bg-surface-2 ring-1 ring-line"
></div>
<div
class="absolute inset-0 translate-x-1 translate-y-1 rounded-art bg-surface-hover ring-1 ring-line"
></div>
<PlaylistArt
playlistId={playlist.id}
trackIds={playlist.coverTrackIds}
version={playlist.updatedAt}
class="absolute inset-0 h-full w-full rounded-art shadow-art-lg ring-1 ring-line-strong"
/>
<PlaylistArt
playlistId={playlist.id}
trackIds={playlist.coverTrackIds}
version={playlist.updatedAt}
size="medium"
class="h-15.5 w-15.5 shrink-0 rounded-[11px] shadow-[0_10px_24px_rgba(0,0,0,.42)]"
/>
<div class="min-w-0 flex-1">
<div class="truncate text-[13.5px] font-semibold tracking-[-0.01em] text-fg">
{playlist.name}
</div>
{#if playlist.description}
<div class="mt-1 truncate text-[11.5px] text-fg-3">{playlist.description}</div>
{/if}
</div>
<div
class="mt-3.5 truncate text-fg transition-colors group-hover/card:text-accent-soft"
>
{playlist.name}
</div>
{#if playlist.description}
<div class="mt-0.5 truncate text-sm text-fg-2">{playlist.description}</div>
{/if}
</a>
{/each}
<a
href="/playlists"
class="group/more grid h-40 w-40 shrink-0 place-items-center rounded-art border border-dashed border-line text-fg-2 transition duration-300 hover:border-accent-soft/50 hover:text-accent-soft"
>
<div class="flex flex-col items-center gap-2">
<span
class="grid h-9 w-9 place-items-center rounded-full bg-surface-2 transition-transform duration-300 group-hover/more:translate-x-0.5"
>
<ArrowRight class="h-4 w-4" />
</span>
<span class="text-sm">Ver todas</span>
</div>
</a>
</Rail>
</section>
{/if}
{#if recTargets.length > 0}
<section>
<SectionHeading title="Quizá te guste" />
<ul class="grid grid-cols-1 gap-1 lg:grid-cols-2 lg:gap-x-8">
{#each recTargets as target, i (targetId(target))}
<li>
<button
type="button"
onclick={() => playRecommended(i)}
oncontextmenu={(e) => openContextMenu(e, target)}
aria-label="Reproducir {targetTitle(target)}"
class="group/row flex w-full min-w-0 items-center gap-3.5 rounded-control p-2 pr-4 text-left transition hover:bg-hover focus-visible:ring-2 focus-visible:ring-accent focus-visible:outline-none"
>
<Cover
trackId={targetId(target)}
src={targetCoverSrc(target)}
size="small"
alt={targetTitle(target)}
class="h-11 w-11 shrink-0 rounded-control shadow-art ring-1 ring-line ring-inset"
>
{#if isTargetCurrent(target)}
<NowPlaying paused={!player.playing} />
{/if}
</Cover>
<div class="min-w-0 flex-1">
<div class="flex min-w-0 items-center gap-1.5">
{#if targetExplicit(target)}
<ExplicitBadge />
{/if}
<span
class="truncate text-sm text-fg transition-colors group-hover/row:text-accent-soft"
>
{targetTitle(target)}
</span>
</div>
{#if targetArtist(target)}
<div class="truncate text-xs text-fg-2">{targetArtist(target)}</div>
{/if}
</div>
{#if targetDurationSeconds(target)}
<span class="shrink-0 text-sm text-fg-2 tabular-nums">
{fmtTime(targetDurationSeconds(target) ?? 0)}
</span>
{/if}
</button>
</li>
{/each}
</ul>
</section>
{/if}
</div>
{:else}
<EmptyState
icon={ListMusic}
title="Todavía no tienes listas"
description="Crea tu primera lista y añádele canciones de tu biblioteca."
>
{#snippet actions()}
<Button href="/playlists">Crear lista</Button>
{/snippet}
</EmptyState>
{/if}
</section>
</div>
{#if contextMenu}
@@ -630,13 +500,3 @@
onAddToQueue={addToQueue}
/>
{/if}
{#if albumMenu}
<AlbumContextMenu
menu={albumMenu}
{playlists}
onClose={() => (albumMenu = null)}
onPlayNext={albumPlayNext}
onAddToQueue={albumAddToQueue}
/>
{/if}
@@ -0,0 +1,16 @@
import type { RequestHandler } from './$types';
import { json } from '@sveltejs/kit';
import { createApiClient } from '$lib/server/api';
export const POST: RequestHandler = async ({ request, locals, fetch }) => {
if (!locals.accessToken) return json({ message: 'Inicia sesión.' }, { status: 401 });
const body = await request.json();
const api = createApiClient({ fetch, accessToken: locals.accessToken });
const result = await api.POST('/likes/toggle', { body });
if (result.error || result.data === undefined)
return json({ message: 'No se pudo actualizar Me gusta.' }, { status: 502 });
return json({ liked: result.data });
};
+25 -6
View File
@@ -1,27 +1,36 @@
@import 'tailwindcss';
@theme {
--font-sans: 'Manrope Variable', ui-sans-serif, system-ui, sans-serif;
--font-display: 'Bricolage Grotesque Variable', ui-sans-serif, system-ui, sans-serif;
--font-sans: 'Plus Jakarta Sans Variable', ui-sans-serif, system-ui, sans-serif;
--font-display: 'Sora Variable', ui-sans-serif, system-ui, sans-serif;
--color-bg: var(--mf-bg);
--color-elevated: var(--mf-elevated);
--color-surface: var(--mf-surface);
--color-surface-2: var(--mf-surface-2);
--color-surface-hover: var(--mf-surface-hover);
--color-hover: rgba(255, 255, 255, 0.05);
--color-hover: rgba(255, 255, 255, 0.045);
--color-line: var(--mf-border);
--color-line-strong: var(--mf-border-strong);
--color-hairline: var(--mf-hairline);
--color-track: var(--mf-track);
--color-fg: var(--mf-text);
--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-soft: var(--mf-accent-soft);
--color-accent-soft: var(--mf-accent-title);
--color-accent-muted: var(--mf-accent-muted);
--color-accent-tint: var(--mf-accent-soft-bg);
--color-accent-line: var(--mf-accent-line);
--color-accent-hair: var(--mf-accent-hair);
--color-accent-btn: var(--mf-accent-btn-bg);
--color-accent-btn-hover: var(--mf-accent-btn-bg-hover);
--color-accent-glow: var(--mf-accent-glow);
--color-accent-dim: var(--mf-accent-dim);
--color-on-accent: var(--mf-on-art);
--color-ink: var(--mf-ink);
--color-cta-strong: var(--mf-cta-strong-bg);
--color-scrim: var(--mf-scrim);
--color-on-art: var(--mf-on-art);
--color-danger: oklch(63.7% 0.15 25.331);
@@ -29,13 +38,15 @@
--radius-control: 0.625rem;
--radius-panel: 1.125rem;
--radius-art: 0.85rem;
--radius-panel-lg: 1.375rem;
--radius-art: 0.8125rem;
--radius-art-lg: 1.125rem;
--radius-art-round: 1.75rem;
--shadow-art: 0 12px 28px -10px rgba(0, 0, 0, 0.6);
--shadow-art: 0 14px 32px rgba(0, 0, 0, 0.45);
--shadow-art-lg: 0 24px 50px -16px rgba(0, 0, 0, 0.75);
--shadow-menu: 0 12px 28px -10px rgba(0, 0, 0, 0.6);
--shadow-player: 0 20px 56px rgba(0, 0, 0, 0.6);
--shadow-glow: 0 0 0 1px var(--mf-border-strong), 0 20px 45px -20px var(--mf-accent-glow);
}
@@ -165,6 +176,14 @@ button:disabled {
animation: fade-in 0.4s ease both;
}
.glass-panel {
background-color: var(--mf-panel-bg);
background-image: var(--mf-modal-glow);
backdrop-filter: blur(22px);
-webkit-backdrop-filter: blur(22px);
transition: background 0.6s;
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
+8 -7
View File
@@ -12,14 +12,15 @@
</svelte:head>
<div class="relative grid min-h-screen place-items-center overflow-hidden bg-bg px-6 text-fg">
<div
class="pointer-events-none absolute inset-0"
style="background:radial-gradient(ellipse 46rem 28rem at 50% 36%, color-mix(in oklch, var(--color-accent), transparent 82%), transparent 70%)"
></div>
<div class="pointer-events-none absolute inset-0" style="background:var(--mf-ambient)"></div>
<div class="relative flex w-full max-w-4xl flex-col items-center gap-12 text-center">
<div class="font-display text-[clamp(1.5rem,3vw,2.5rem)] font-semibold tracking-tight">
Musify
<div class="flex items-center gap-3">
<span class="h-8 w-8 rounded-xl bg-[image:var(--mf-logo-grad)] shadow-[var(--mf-logo-glow)]"
></span>
<span class="font-display text-[clamp(1.5rem,3vw,2.5rem)] font-semibold tracking-tight">
Musify
</span>
</div>
<div class="animate-enter flex flex-col items-center gap-5">
@@ -39,7 +40,7 @@
<Button
href={`/auth/login?mode=register&${returnParam}`}
reload
variant="subtle"
variant="secondary"
size="lg"
class="w-full"
>