This commit is contained in:
2026-07-24 18:54:31 +02:00
parent 9132e67371
commit 6412a657e7
23 changed files with 187 additions and 175 deletions
+1 -1
View File
@@ -591,7 +591,7 @@ export interface components {
};
CreatePlayListRequest: {
name: string;
description: string;
description: null | string;
/** Format: uuid */
pictureIntentId: null | string;
};
@@ -21,7 +21,7 @@
<div class="flex flex-col items-center gap-2.5 text-center" style="width:{size}px">
<div
class="relative grid shrink-0 place-items-center overflow-hidden rounded-full shadow-art ring-1 ring-line transition duration-300 ease-out ring-inset group-hover/artist:-translate-y-1 group-hover/artist:shadow-art-lg"
class="relative grid shrink-0 place-items-center overflow-hidden rounded-full 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;background:{gradientForHue(hueFor(id))}"
>
<div
@@ -1,6 +1,6 @@
<script lang="ts" module>
export const mediaCardArt =
'aspect-square w-full rounded-art shadow-art transition duration-300 ease-out group-hover:-translate-y-1 group-hover:shadow-art-lg';
'aspect-square w-full rounded-art shadow-art transition duration-300 ease-out group-hover:shadow-art-lg';
</script>
<script lang="ts">
@@ -1,7 +1,7 @@
<script lang="ts">
import MixArt from './MixArt.svelte';
import { hueFor } from '$lib/theme/color';
import type { Mix } from '$lib/mixes';
import Cover from './Cover.svelte';
import { gradientForHue, hueFor } from '$lib/theme/color';
import { mixItemKey, type Mix } from '$lib/mixes';
interface Props {
mix: Mix;
@@ -12,35 +12,53 @@
}
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 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"
style="animation-delay:{Math.min(index, 10) * 45}ms;background:{gradientForHue(hueFor(mix.id))}"
>
<MixArt
items={mix.items}
hue={hueFor(mix.id)}
class="absolute inset-0 h-full w-full transition duration-500 ease-out group-hover/tile:scale-[1.04]"
/>
{#if tiles.length > 0}
<div
class="pointer-events-none absolute inset-0"
style="background:linear-gradient(to top, rgba(0,0,0,0.78) 0%, rgba(0,0,0,0.32) 42%, rgba(0,0,0,0.05) 100%)"
></div>
class="absolute inset-0 flex transition duration-300 ease-out group-hover/tile:scale-[1.01]"
>
{#each tiles as item, i (i)}
<Cover
trackId={item.trackId ?? item.videoId ?? ''}
src={item.thumbnailUrl}
hue={hueFor(mixItemKey(item))}
size="medium"
class="aspect-square h-full shrink-0"
/>
{/each}
</div>
{/if}
<div
class="pointer-events-none absolute inset-0 bg-black/10 transition-colors duration-300 group-hover/tile:bg-black/20"
></div>
<div
class="relative flex h-full flex-col justify-end p-3 sm:p-4 {banner
class="group 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 font-semibold text-white {featured ? 'text-xl sm:text-2xl' : 'text-base'}"
class="truncate text-white {featured ? 'text-xl sm:text-2xl' : 'text-base'} group-hover:text-accent-soft transition-colors"
>
{mix.title}
</div>
{#if mix.subtitle}
<div class="mt-0.5 truncate text-xs text-white/70 sm:text-sm">{mix.subtitle}</div>
<div class="mt-0.5 truncate text-xs text-muted sm:text-sm">{mix.subtitle}</div>
{/if}
</div>
</div>
@@ -11,9 +11,8 @@
</script>
<span
class="absolute right-2.5 bottom-2.5 grid h-11 w-11 translate-y-2 place-items-center rounded-full bg-accent-soft text-on-accent opacity-0 shadow-lg transition-all group-hover:translate-y-0 group-hover:opacity-100"
class="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}
class:!translate-y-0={active}
>
{#if active && playing}
<Pause class="h-5 w-5" fill="currentColor" />
@@ -11,12 +11,16 @@
</script>
{#if actions}
<div class="mb-3 flex items-baseline justify-between gap-4 sm:mb-4 {klass}">
<h2 class="text-lg tracking-tight text-fg sm:text-[28px]">{title}</h2>
<div class="mb-3 flex items-baseline justify-between gap-4 sm:mb-4 {klass} tracking-widest">
<h2 class="font-display font-medium text-fg sm:text-xl">
{title}
</h2>
{@render actions()}
</div>
{:else}
<h2 class="mb-3 text-lg tracking-tight text-fg sm:mb-4 sm:text-[28px] {klass}">
<h2
class="mb-3 font-display font-medium text-fg sm:mb-4 sm:text-xl {klass}"
>
{title}
</h2>
{/if}
-1
View File
@@ -10,7 +10,6 @@ export const EXPLORE_ALBUMS_PAGE_SIZE = 10;
export const HOME_LATEST_PAGE_SIZE = 10;
export const HOME_ALBUMS_LIMIT = 12;
export const HOME_MIXES_BENTO = 5;
export const HOME_ARTISTS_LIMIT = 10;
export const HOME_SHELF_LIMIT = 6;
export const YOUTUBE_FILLER_LIMIT = 27;
export const PLAYLIST_PICKER_PAGE_SIZE = 50;
-42
View File
@@ -1,42 +0,0 @@
export interface HomeArtist {
id: string;
name: string;
}
const FILLER_ARTISTS = [
'Nova Reyes',
'The Paper Lanterns',
'Kairo',
'Lucía Marés',
'Midnight Transit',
'Sundown Kids',
'Aria Volkov',
'El Bosque Rojo',
'Halcyon',
'Marea Baja'
];
export function pickTopArtists(tracks: { artist?: string | null }[], limit: number): HomeArtist[] {
const seen = new Set<string>();
const artists: HomeArtist[] = [];
for (const track of tracks) {
for (const name of (track.artist ?? '').split(',').map((part) => part.trim())) {
const key = name.toLowerCase();
if (!name || seen.has(key)) continue;
seen.add(key);
artists.push({ id: name, name });
if (artists.length >= limit) return artists;
}
}
for (const name of FILLER_ARTISTS) {
const key = name.toLowerCase();
if (seen.has(key)) continue;
seen.add(key);
artists.push({ id: name, name });
if (artists.length >= limit) break;
}
return artists;
}
+2 -2
View File
@@ -5,9 +5,9 @@
--mf-text: #c7c6c5;
--mf-text-2: #a5a4a3;
--mf-text-3: #8c8b8a;
--mf-text-4: #7f7e7d;
--mf-text-4: rgb(120, 120, 120);
--mf-surface: rgb(18, 18, 18);
--mf-surface: rgb(15, 15, 15);
--mf-surface-2: rgba(255, 255, 255, 0.06);
--mf-surface-hover: rgba(255, 255, 255, 0.09);
@@ -14,7 +14,6 @@
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 CoverBadge from '$lib/components/ui/CoverBadge.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';
@@ -223,7 +222,7 @@
<Page>
<PageHeader title="Buscar" subtitle="Encuentra canciones, álbumes y personas." />
<div class="mt-8">
<div class="mt-6">
<Input
type="search"
icon={Search}
@@ -246,8 +245,7 @@
{/if}
{#if hasAlbums}
<div class="mt-10">
<SectionHeading title="Álbumes" />
<div class="mt-8">
<MediaGrid as="ul" min="168px" minMobile="150px">
{#each albumEntries as entry, i (entry.kind === 'local' ? entry.album.id : entry.album.albumId)}
<li>
@@ -264,9 +262,8 @@
<PlaylistArt
trackIds={entry.album.coverTrackIds}
hue={hueFor(entry.album.id)}
class="aspect-square w-full rounded-art shadow-art ring-1 ring-line transition duration-300 ease-out ring-inset group-hover/card:-translate-y-1 group-hover/card:shadow-art-lg"
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"
/>
<CoverBadge label="Álbum" />
</div>
<div
class="mt-2.5 truncate text-fg transition-colors group-hover/card:text-accent-soft"
@@ -291,9 +288,8 @@
hue={hueFor(entry.album.albumId)}
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:-translate-y-1 group-hover/card:shadow-art-lg"
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"
/>
<CoverBadge label="Álbum" />
</div>
<div
class="mt-2.5 truncate text-fg transition-colors group-hover/card:text-accent-soft"
@@ -312,7 +308,7 @@
{/if}
{#if hasUsers}
<div class="mt-10">
<div class="mt-8">
<SectionHeading title="Usuarios" />
<Rail>
{#each users as u, i (u.id)}
@@ -328,8 +324,7 @@
</div>
{/if}
<div class="mt-10">
<SectionHeading title="Canciones" />
<div class="mt-6">
{#if songRows.length > 0}
<ul class="flex flex-col gap-1">
{#each songRows as { target: item, seconds }, i (targetId(item))}
@@ -363,7 +358,7 @@
</span>
</div>
{#if targetArtist(item)}
<div class="truncate text-sm text-fg-3">{targetArtist(item)}</div>
<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>
+1
View File
@@ -1,5 +1,6 @@
<script lang="ts">
import '@fontsource-variable/inter/index.css';
import '@fontsource-variable/sora/index.css';
import '$lib/theme/theme.css';
import './layout.css';
import favicon from '$lib/assets/favicon.svg';
-3
View File
@@ -4,14 +4,12 @@ import { fetchYoutubeFiller } from '$lib/server/youtube';
import { addTrackAction, addYouTubeToPlaylistAction } from '$lib/server/playlistActions';
import {
HOME_ALBUMS_LIMIT,
HOME_ARTISTS_LIMIT,
HOME_LATEST_PAGE_SIZE,
HOME_MIXES_BENTO,
HOME_SHELF_LIMIT,
YOUTUBE_FILLER_LIMIT
} from '$lib/config';
import { pickGreeting } from '$lib/server/greeting';
import { pickTopArtists } from '$lib/server/topArtists';
export const load: PageServerLoad = async ({ locals, url, fetch }) => {
const user = requireUser(locals, url);
@@ -52,7 +50,6 @@ export const load: PageServerLoad = async ({ locals, url, fetch }) => {
playlists: playlistItems.slice(0, HOME_SHELF_LIMIT),
playlistsHasMore: playlistItems.length > HOME_SHELF_LIMIT,
newReleases: latestItems,
topArtists: pickTopArtists(latestItems, HOME_ARTISTS_LIMIT),
youtubeFiller: fetchYoutubeFiller(
api,
locals.accessToken,
+32 -57
View File
@@ -1,6 +1,6 @@
<script lang="ts">
import Music from '@lucide/svelte/icons/music';
import ListMusic from '@lucide/svelte/icons/list-music';
import ArrowRight from '@lucide/svelte/icons/arrow-right';
import { player, toQueueItems, type QueueItem } from '$lib/player/player.svelte';
import {
isTargetCurrent,
@@ -19,11 +19,9 @@
import MixBentoTile from '$lib/components/ui/MixBentoTile.svelte';
import Rail from '$lib/components/ui/Rail.svelte';
import CoverBadge from '$lib/components/ui/CoverBadge.svelte';
import ArtistAvatar from '$lib/components/ui/ArtistAvatar.svelte';
import NowPlaying from '$lib/components/ui/NowPlaying.svelte';
import EmptyState from '$lib/components/ui/EmptyState.svelte';
import ExplicitBadge from '$lib/components/ui/ExplicitBadge.svelte';
import MediaGrid from '$lib/components/ui/MediaGrid.svelte';
import SectionHeading from '$lib/components/ui/SectionHeading.svelte';
import TrackContextMenu, {
contextMenuStateFor,
@@ -31,6 +29,7 @@
} from '$lib/components/TrackContextMenu.svelte';
import type { YouTubeSong } from '$lib/types';
import { appendUnique } from '$lib/collections';
import { HOME_LATEST_PAGE_SIZE } from '$lib/config';
let { data } = $props();
@@ -52,7 +51,6 @@
const albums = $derived(data.albums);
const mixes = $derived(data.mixes);
const playlists = $derived(data.playlists);
const artists = $derived(data.topArtists);
const newTargets = $derived<TrackTarget[]>(
data.newReleases.map((track) => ({ kind: 'local' as const, track }))
@@ -90,7 +88,7 @@
explicit: item.explicit
});
}
return merged.slice(0, 12);
return merged.slice(0, HOME_LATEST_PAGE_SIZE);
});
function playRecent(index: number) {
@@ -142,8 +140,9 @@
{#if recentlyPlayed.length > 0}
<section class="page-x pt-6 sm:pt-8">
<SectionHeading title="Escuchar otra vez" />
<MediaGrid min="260px" minMobile="220px">
<div
class="grid grid-cols-1 gap-3 sm:grid-cols-2 sm:gap-4 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5"
>
{#each recentlyPlayed as track, i (track.id)}
<button
type="button"
@@ -152,7 +151,7 @@
aria-label="{player.current.id === track.id && player.playing
? 'Pausar'
: 'Reproducir'} {track.title}"
class="group/row flex min-w-0 items-center gap-3 rounded-control bg-surface p-2 text-left transition duration-200"
class="group/row flex min-w-0 items-center gap-3 rounded-control bg-surface p-2.5 text-left transition duration-200"
>
<Cover
trackId={track.id}
@@ -160,7 +159,7 @@
hue={hueFor(track.id)}
size="small"
alt={track.title}
class="h-14 w-14 shrink-0 rounded-control shadow-art ring-line transition duration-200"
class="h-14 w-14 shrink-0 rounded-control shadow-art transition duration-200"
>
{#if player.current.id === track.id}
<NowPlaying paused={!player.playing} />
@@ -181,12 +180,12 @@
</div>
</button>
{/each}
</MediaGrid>
</div>
</section>
{/if}
{#if albums.length > 0}
<section class="page-x pt-10 sm:pt-14">
<section class="page-x pt-10">
<SectionHeading title="Álbumes recién escuchados">
{#snippet actions()}
<a href="/albums" class="text-sm text-fg-3 transition hover:text-accent-soft">Ver todos</a>
@@ -203,7 +202,7 @@
<PlaylistArt
trackIds={album.coverTrackIds}
hue={hueFor(album.id)}
class="h-[168px] w-[168px] rounded-art shadow-art ring-1 ring-line transition duration-300 ease-out ring-inset group-hover/card:-translate-y-1 group-hover/card:shadow-art-lg"
class="h-[168px] w-[168px] rounded-art shadow-art ring-1 ring-line transition duration-300 ease-out ring-inset group-hover/card:shadow-art-lg"
/>
<CoverBadge label="Álbum" />
</div>
@@ -218,9 +217,8 @@
{/if}
{#if mixes.length > 0}
<section class="page-x pt-10 sm:pt-14">
<SectionHeading title="Hecho para ti" />
<div class="grid grid-cols-2 gap-3 sm:auto-rows-[100px] sm:grid-cols-4 sm:gap-4">
<section class="page-x pt-10">
<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}
@@ -228,12 +226,12 @@
featured={i === 0}
banner={i === 4}
class={i === 0
? 'col-span-2 min-h-[168px] sm:row-span-2 sm:min-h-0'
? 'col-span-2 min-h-42 sm:row-span-2 sm:min-h-0'
: i === 1
? 'col-span-2 min-h-[120px] sm:min-h-0'
? 'col-span-2 min-h-30 sm:min-h-0'
: i === 4
? 'col-span-2 min-h-[96px] sm:col-span-4 sm:min-h-0'
: 'min-h-[120px] sm:min-h-0'}
? 'col-span-2 min-h-24 sm:col-span-4 sm:min-h-0'
: 'min-h-30 sm:min-h-0'}
/>
{/each}
</div>
@@ -241,21 +239,12 @@
{/if}
{#if playlists.length > 0}
<section class="page-x pt-10 sm:pt-14">
<SectionHeading title="Tus playlists">
{#snippet actions()}
{#if data.playlistsHasMore}
<a href="/playlists" class="text-sm text-fg-3 transition hover:text-accent-soft">
Ver todas
</a>
{/if}
{/snippet}
</SectionHeading>
<section class="page-x pt-10">
<Rail>
{#each playlists as playlist, i (playlist.id)}
<a
href="/playlists/{playlist.id}"
class="group/card animate-enter block w-[150px] shrink-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"
class="group/card animate-enter block w-36 shrink-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"
>
<PlaylistArt
@@ -263,21 +252,23 @@
trackIds={playlist.coverTrackIds}
hue={hueFor(playlist.id)}
version={playlist.updatedAt}
class="h-[150px] w-[150px] rounded-art shadow-art ring-1 ring-line transition duration-300 ease-out ring-inset group-hover/card:-translate-y-1 group-hover/card:shadow-art-lg"
class="h-36 w-36 rounded-art"
/>
<div class="mt-2.5 truncate text-fg transition-colors group-hover/card:text-accent-soft">
{playlist.name}
</div>
<div class="mt-0.5 truncate text-sm text-fg-3">{playlist.description ?? 'Lista'}</div>
<div class="mt-0.5 truncate text-sm text-muted">{playlist.description}</div>
</a>
{/each}
<a
href="/playlists"
class="grid h-[150px] w-[150px] shrink-0 place-items-center rounded-art border border-dashed border-line-strong text-fg-3 transition hover:border-accent hover:text-accent-soft"
class="grid h-36 w-36 shrink-0 place-items-center text-fg-3 transition-colors hover:text-accent-soft"
>
<div class="flex flex-col items-center gap-2">
<ListMusic class="h-6 w-6" />
<span class="text-sm">Ver todas</span>
<span class="grid h-8 w-8 place-items-center">
<ArrowRight class="h-4 w-4" />
</span>
Ver todas
</div>
</a>
</Rail>
@@ -285,7 +276,7 @@
{/if}
{#if newTargets.length > 0}
<section class="page-x pt-10 sm:pt-14">
<section class="page-x pt-10">
<SectionHeading title="Canciones nuevas" />
<Rail>
{#each newTargets as target, i (targetId(target))}
@@ -304,7 +295,7 @@
hue={hueFor(targetId(target))}
size="medium"
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:-translate-y-1 group-hover/card:shadow-art-lg"
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"
>
{#if isTargetCurrent(target)}
<NowPlaying paused={!player.playing} />
@@ -329,24 +320,8 @@
</section>
{/if}
{#if artists.length > 0}
<section class="page-x pt-10 sm:pt-14">
<SectionHeading title="Top artistas del momento" />
<Rail>
{#each artists as artist, i (artist.id)}
<div
class="group/artist animate-enter shrink-0"
style="animation-delay:{Math.min(i, 10) * 45}ms"
>
<ArtistAvatar id={artist.id} name={artist.name} />
</div>
{/each}
</Rail>
</section>
{/if}
<section class="page-x pt-10 pb-16 sm:pt-14">
<SectionHeading title="Recomendado para ti" />
<!---
<section class="page-x pt-10 pb-16">
{#if recTargets.length > 0}
<Rail>
{#each recTargets as target, i (targetId(target))}
@@ -365,7 +340,7 @@
hue={hueFor(targetId(target))}
size="medium"
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:-translate-y-1 group-hover/card:shadow-art-lg"
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"
>
{#if isTargetCurrent(target)}
<NowPlaying paused={!player.playing} />
@@ -389,7 +364,7 @@
{:else}
<EmptyState icon={Music} title="Todavía no hay recomendaciones" />
{/if}
</section>
</section>-->
{#if contextMenu}
<TrackContextMenu
+3 -2
View File
@@ -2,6 +2,7 @@
@theme {
--font-sans: 'Inter Variable', ui-sans-serif, system-ui, sans-serif;
--font-display: 'Sora Variable', 'Inter Variable', ui-sans-serif, system-ui, sans-serif;
--color-bg: var(--mf-bg);
--color-elevated: var(--mf-elevated);
@@ -36,10 +37,10 @@
}
@utility page-x {
padding-inline: --spacing(4);
padding-inline: --spacing(6);
@media (width >= 40rem) {
padding-inline: --spacing(8);
padding-inline: --spacing(10);
}
}