diff --git a/web-player/src/lib/components/TrackTile.svelte b/web-player/src/lib/components/TrackTile.svelte deleted file mode 100644 index 16e26a9..0000000 --- a/web-player/src/lib/components/TrackTile.svelte +++ /dev/null @@ -1,84 +0,0 @@ - - -
  • - -
  • diff --git a/web-player/src/lib/components/ui/ArtistAvatar.svelte b/web-player/src/lib/components/ui/ArtistAvatar.svelte new file mode 100644 index 0000000..1e1786c --- /dev/null +++ b/web-player/src/lib/components/ui/ArtistAvatar.svelte @@ -0,0 +1,34 @@ + + +
    +
    +
    + {initials} +
    +
    {name}
    +
    diff --git a/web-player/src/lib/components/ui/CoverBadge.svelte b/web-player/src/lib/components/ui/CoverBadge.svelte new file mode 100644 index 0000000..b93cb10 --- /dev/null +++ b/web-player/src/lib/components/ui/CoverBadge.svelte @@ -0,0 +1,14 @@ + + + + {label} + diff --git a/web-player/src/lib/components/ui/MixBentoTile.svelte b/web-player/src/lib/components/ui/MixBentoTile.svelte new file mode 100644 index 0000000..2081f3c --- /dev/null +++ b/web-player/src/lib/components/ui/MixBentoTile.svelte @@ -0,0 +1,47 @@ + + + + +
    +
    +
    +
    + {mix.title} +
    + {#if mix.subtitle} +
    {mix.subtitle}
    + {/if} +
    +
    +
    diff --git a/web-player/src/lib/components/ui/MixCard.svelte b/web-player/src/lib/components/ui/MixCard.svelte deleted file mode 100644 index 321f62a..0000000 --- a/web-player/src/lib/components/ui/MixCard.svelte +++ /dev/null @@ -1,26 +0,0 @@ - - - - {#snippet art(artClass)} - - {/snippet} - diff --git a/web-player/src/lib/components/ui/Rail.svelte b/web-player/src/lib/components/ui/Rail.svelte new file mode 100644 index 0000000..e33ab9e --- /dev/null +++ b/web-player/src/lib/components/ui/Rail.svelte @@ -0,0 +1,23 @@ + + +
    + {@render children()} +
    + + diff --git a/web-player/src/lib/components/ui/SectionHeading.svelte b/web-player/src/lib/components/ui/SectionHeading.svelte index cbdd163..7bd098f 100644 --- a/web-player/src/lib/components/ui/SectionHeading.svelte +++ b/web-player/src/lib/components/ui/SectionHeading.svelte @@ -12,11 +12,11 @@ {#if actions}
    -

    {title}

    +

    {title}

    {@render actions()}
    {:else} -

    +

    {title}

    {/if} diff --git a/web-player/src/lib/config.ts b/web-player/src/lib/config.ts index a1c7c41..7a5f8c6 100644 --- a/web-player/src/lib/config.ts +++ b/web-player/src/lib/config.ts @@ -7,9 +7,10 @@ export const ALBUM_TRACKS_PAGE_SIZE = 200; export const ALBUM_EARLIEST_YEAR = 1877; export const LIBRARY_PICKER_PAGE_SIZE = 50; export const EXPLORE_ALBUMS_PAGE_SIZE = 10; -export const EXPLORE_ALBUM_SLOTS = 10; -export const HOME_LATEST_PAGE_SIZE = 12; +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; diff --git a/web-player/src/lib/server/topArtists.ts b/web-player/src/lib/server/topArtists.ts new file mode 100644 index 0000000..faee9ae --- /dev/null +++ b/web-player/src/lib/server/topArtists.ts @@ -0,0 +1,42 @@ +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(); + 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; +} diff --git a/web-player/src/lib/server/userDirectory.ts b/web-player/src/lib/server/userDirectory.ts new file mode 100644 index 0000000..1b237d3 --- /dev/null +++ b/web-player/src/lib/server/userDirectory.ts @@ -0,0 +1,24 @@ +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); +} diff --git a/web-player/src/routes/(app)/explore/+page.server.ts b/web-player/src/routes/(app)/explore/+page.server.ts index 309b239..186173b 100644 --- a/web-player/src/routes/(app)/explore/+page.server.ts +++ b/web-player/src/routes/(app)/explore/+page.server.ts @@ -13,6 +13,7 @@ import { SEARCH_MIN_LENGTH, YOUTUBE_FILLER_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() ?? ''; @@ -62,6 +63,7 @@ export const load: PageServerLoad = async ({ url, locals, fetch }) => { youtubeAlbums: youtubeAlbumsRes?.data?.items ?? [], ytResults: searchRes?.data ?? null, ytError: Boolean(searchRes?.error), + users: searchUsers(query), youtubeFiller: query ? Promise.resolve(null) : fetchYoutubeFiller(api, locals.accessToken, YOUTUBE_FILLER_LIMIT), diff --git a/web-player/src/routes/(app)/explore/+page.svelte b/web-player/src/routes/(app)/explore/+page.svelte index c68766d..e562b9b 100644 --- a/web-player/src/routes/(app)/explore/+page.svelte +++ b/web-player/src/routes/(app)/explore/+page.svelte @@ -4,6 +4,7 @@ import { untrack } from 'svelte'; import { goto } from '$app/navigation'; import { player } from '$lib/player/player.svelte'; + import { fmtTime } from '$lib/format'; import Page from '$lib/components/ui/Page.svelte'; import PageHeader from '$lib/components/ui/PageHeader.svelte'; import MediaGrid from '$lib/components/ui/MediaGrid.svelte'; @@ -11,12 +12,17 @@ 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 AlbumCard from '$lib/components/ui/AlbumCard.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'; + import ExplicitBadge from '$lib/components/ui/ExplicitBadge.svelte'; import SectionHeading from '$lib/components/ui/SectionHeading.svelte'; - import { EXPLORE_ALBUM_SLOTS, EXPLORE_PAGE_SIZE, SEARCH_DEBOUNCE_MS } from '$lib/config'; + import { EXPLORE_PAGE_SIZE, SEARCH_DEBOUNCE_MS } from '$lib/config'; import { hueFor } from '$lib/theme/color'; import type { YouTubeSong } from '$lib/types'; - import TrackTile from '$lib/components/TrackTile.svelte'; import TrackContextMenu, { contextMenuStateFor, type ContextMenuState @@ -85,33 +91,55 @@ }; }); - const items = $derived([ - ...localItems.map((track) => ({ kind: 'local' as const, track })), - ...ytItems.map((song) => ({ kind: 'youtube' as const, song })) - ]); + const songRows = $derived([ + ...localItems.map((track) => ({ + target: { kind: 'local' as const, track }, + seconds: Number(track.duration) + })), + ...ytItems.map((song) => ({ + target: { kind: 'youtube' as const, song }, + seconds: Number(song.durationSeconds) + })) + ] satisfies { target: TrackTarget; seconds: number }[]); + + const items = $derived(songRows.map((row) => row.target)); const hasMore = $derived(localHasNext || ytContinuation !== ''); const albums = $derived(data.albums); const youtubeAlbums = $derived(data.youtubeAlbums); + const users = $derived(data.users); type LocalAlbum = (typeof data.albums)[number]; type YouTubeAlbum = (typeof data.youtubeAlbums)[number]; type AlbumEntry = { kind: 'local'; album: LocalAlbum } | { kind: 'youtube'; album: YouTubeAlbum }; - const albumEntries = $derived( - [ - ...albums.map((album) => ({ kind: 'local' as const, album })), - ...youtubeAlbums.map((album) => ({ kind: 'youtube' as const, album })) - ].slice(0, EXPLORE_ALBUM_SLOTS) - ); + const albumEntries = $derived([ + ...albums.map((album) => ({ kind: 'local' as const, album })), + ...youtubeAlbums.map((album) => ({ kind: 'youtube' as const, album })) + ]); const hasAlbums = $derived(albumEntries.length > 0); - const fillerCount = $derived(Math.max(0, EXPLORE_ALBUM_SLOTS - albumEntries.length)); - const fillerTracks = $derived(items.slice(0, fillerCount)); - const remainingTracks = $derived(items.slice(fillerCount)); + const hasUsers = $derived(users.length > 0); - const nothingFound = $derived(!!data.query && !data.ytError && items.length === 0 && !hasAlbums); + const nothingFound = $derived( + !!data.query && !data.ytError && 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'; @@ -123,11 +151,7 @@ searchTimeout = setTimeout(() => { const term = value.trim(); if (term === data.query) return; - goto(buildHref(term), { - keepFocus: true, - replaceState: true, - noScroll: true - }); + goto(buildHref(term), { keepFocus: true, replaceState: true, noScroll: true }); }, SEARCH_DEBOUNCE_MS); } @@ -139,10 +163,6 @@ contextMenu = contextMenuStateFor(event, target); } - function closeContextMenu() { - contextMenu = null; - } - function isForeignAlbum(album: LocalAlbum) { return String(album.ownerUserId) !== data.user?.sub; } @@ -151,14 +171,10 @@ albumMenu = albumContextMenuStateFor(event, kind, albumId); } - function closeAlbumMenu() { - albumMenu = null; - } - function addToQueue() { if (!contextMenu) return; player.addToQueue(queueItemForTarget(contextMenu)); - closeContextMenu(); + contextMenu = null; } async function loadMoreLocal() { @@ -200,12 +216,12 @@ - Explorar · Musify - + Buscar · Musify + - +
    +
    - + {#each albumEntries as entry, i (entry.kind === 'local' ? entry.album.id : entry.album.albumId)}
  • {#if entry.kind === 'local'} - openAlbumMenu(e, 'local', entry.album.id) : undefined} - /> + > +
    + + +
    +
    + {entry.album.title} +
    +
    + {localAlbumCaption(entry.album)} +
    + {:else} - openAlbumMenu(e, '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)} + > +
    + + +
    +
    + {entry.album.title} +
    +
    + {youtubeAlbumCaption(entry.album)} +
    + {/if}
  • {/each} - {#each fillerTracks as item, i (targetId(item))} - togglePlay(i)} - onContextMenu={(e) => openContextMenu(e, item)} - /> - {/each}
    {/if} - {#if remainingTracks.length > 0} - {#if hasAlbums} - - {/if} - - {#each remainingTracks as item, i (targetId(item))} - togglePlay(fillerCount + i)} - onContextMenu={(e) => openContextMenu(e, item)} - /> - {/each} - + {#if hasUsers} +
    + + + {#each users as u, i (u.id)} +
    + +
    @{u.handle}
    +
    + {/each} +
    +
    {/if} - {#if hasMore} - - {/if} +
    + + {#if songRows.length > 0} +
      + {#each songRows as { target: item, seconds }, i (targetId(item))} +
    • + +
    • + {/each} +
    + + {#if hasMore} + + {/if} + {:else if data.query && !data.ytError} + + {/if} +
    {#if data.ytError} (contextMenu = null)} onAddToQueue={addToQueue} /> {/if} {#if albumMenu} - + (albumMenu = null)} + /> {/if} diff --git a/web-player/src/routes/+page.server.ts b/web-player/src/routes/+page.server.ts index eaedba8..4ab848f 100644 --- a/web-player/src/routes/+page.server.ts +++ b/web-player/src/routes/+page.server.ts @@ -1,15 +1,17 @@ import type { PageServerLoad, Actions } from './$types'; import { createApiClient, requireUser } from '$lib/server/api'; import { fetchYoutubeFiller } from '$lib/server/youtube'; -import { shuffle } from '$lib/collections'; 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); @@ -40,23 +42,24 @@ export const load: PageServerLoad = async ({ locals, url, fetch }) => { recentlyPlayedPromise ]); const playlistItems = playlists.data?.items ?? []; + const latestItems = latest.data?.items ?? []; return { greeting: pickGreeting(new Date().getHours()), - latest: shuffle((latest.data?.items ?? []).map((track) => ({ kind: 'local' as const, track }))), - latestHasNext: Boolean(latest.data?.hasNextPage), + recentlyPlayed: recentlyPlayed.data ?? [], + albums: albums.data ?? [], + mixes: (mixes.data ?? []).slice(0, HOME_MIXES_BENTO), + 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, YOUTUBE_FILLER_LIMIT, undefined, user.sub - ), - playlists: playlistItems.slice(0, HOME_SHELF_LIMIT), - playlistsHasMore: playlistItems.length > HOME_SHELF_LIMIT, - albums: albums.data ?? [], - mixes: (mixes.data ?? []).slice(0, HOME_SHELF_LIMIT), - recentlyPlayed: recentlyPlayed.data ?? [] + ) }; }; diff --git a/web-player/src/routes/+page.svelte b/web-player/src/routes/+page.svelte index c341478..2f502a9 100644 --- a/web-player/src/routes/+page.svelte +++ b/web-player/src/routes/+page.svelte @@ -1,5 +1,6 @@ @@ -178,7 +134,7 @@ >

    {data.greeting}

    -

    +

    Tu música. Sin límites.

    @@ -186,8 +142,8 @@ {#if recentlyPlayed.length > 0}
    - - + + {#each recentlyPlayed as track, i (track.id)}
    {/if} -{#if mixes.length > 0 || playlists.length > 0} -
    - {#if mixes.length > 0} -
    - - - {#each mixes as mix, i (mix.id)} - - {/each} - -
    - {/if} - - {#if playlists.length > 0} -
    - - {#snippet actions()} - {#if data.playlistsHasMore} - - Ver todas - - {/if} - {/snippet} - - - {#each playlists as playlist, i (playlist.id)} - - {/each} - -
    - {/if} -
    -{/if} - {#if albums.length > 0}
    - + {#snippet actions()} Ver todos {/snippet} - + {#each albums as album, i (album.id)} - +
    + + +
    +
    + {album.title} +
    +
    {albumCaption(album)}
    + + {/each} +
    +
    +{/if} + +{#if mixes.length > 0} +
    + +
    + {#each mixes as mix, i (mix.id)} + {/each} - +
    +
    +{/if} + +{#if playlists.length > 0} +
    + + {#snippet actions()} + {#if data.playlistsHasMore} + + Ver todas + + {/if} + {/snippet} + + + {#each playlists as playlist, i (playlist.id)} + + +
    + {playlist.name} +
    +
    {playlist.description ?? 'Lista'}
    +
    + {/each} + +
    + + Ver todas +
    +
    +
    +
    +{/if} + +{#if newTargets.length > 0} +
    + + + {#each newTargets as target, i (targetId(target))} + + {/each} + +
    +{/if} + +{#if artists.length > 0} +
    + + + {#each artists as artist, i (artist.id)} +
    + +
    + {/each} +
    {/if}
    - - {#if latest.length > 0} - - {#each latest as item, i (targetId(item))} - playLatest(i)} - onContextMenu={(e) => openContextMenu(e, item)} - /> + + {#if recTargets.length > 0} + + {#each recTargets as target, i (targetId(target))} + {/each} - - + {:else} - + {/if}
    @@ -321,7 +395,7 @@ (contextMenu = null)} onAddToQueue={addToQueue} /> {/if}