Design Prot Merge #1
@@ -1,84 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import type { Snippet } from 'svelte';
|
|
||||||
import Cover from '$lib/components/ui/Cover.svelte';
|
|
||||||
import PlayPauseOverlay from '$lib/components/ui/PlayPauseOverlay.svelte';
|
|
||||||
import ExplicitBadge from '$lib/components/ui/ExplicitBadge.svelte';
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
id: string | number;
|
|
||||||
title: string;
|
|
||||||
artist?: string | null;
|
|
||||||
typeLabel?: string;
|
|
||||||
coverSrc?: string | null;
|
|
||||||
coverSize?: 'small' | 'medium' | 'large';
|
|
||||||
coverShape?: 'square' | 'round';
|
|
||||||
hue: number;
|
|
||||||
explicit?: boolean;
|
|
||||||
active: boolean;
|
|
||||||
playing: boolean;
|
|
||||||
index: number;
|
|
||||||
onClick: () => void;
|
|
||||||
onContextMenu?: (event: MouseEvent) => void;
|
|
||||||
badge?: Snippet;
|
|
||||||
}
|
|
||||||
|
|
||||||
let {
|
|
||||||
id,
|
|
||||||
title,
|
|
||||||
artist,
|
|
||||||
typeLabel,
|
|
||||||
coverSrc,
|
|
||||||
coverSize = 'large',
|
|
||||||
coverShape = 'square',
|
|
||||||
hue,
|
|
||||||
explicit = false,
|
|
||||||
active,
|
|
||||||
playing,
|
|
||||||
index,
|
|
||||||
onClick,
|
|
||||||
onContextMenu,
|
|
||||||
badge
|
|
||||||
}: Props = $props();
|
|
||||||
|
|
||||||
const meta = $derived([typeLabel, artist].filter((part) => !!part).join(' · '));
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<li
|
|
||||||
class="group animate-enter relative min-w-0"
|
|
||||||
style="animation-delay:{index * 40}ms"
|
|
||||||
oncontextmenu={onContextMenu}
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onclick={onClick}
|
|
||||||
aria-label="{active && playing ? 'Pausar' : 'Reproducir'} {title}"
|
|
||||||
class="block w-full text-left"
|
|
||||||
>
|
|
||||||
<Cover
|
|
||||||
trackId={id}
|
|
||||||
src={coverSrc}
|
|
||||||
{hue}
|
|
||||||
size={coverSize}
|
|
||||||
alt={title}
|
|
||||||
class="aspect-square w-full shadow-art {coverShape === 'round'
|
|
||||||
? 'rounded-art-round'
|
|
||||||
: 'rounded-art'}"
|
|
||||||
>
|
|
||||||
<PlayPauseOverlay {active} {playing} />
|
|
||||||
</Cover>
|
|
||||||
<div class="mt-2.5 flex min-w-0 items-center gap-1.5">
|
|
||||||
{#if explicit}
|
|
||||||
<ExplicitBadge />
|
|
||||||
{/if}
|
|
||||||
<span class="truncate text-fg" {title}>{title}</span>
|
|
||||||
</div>
|
|
||||||
{#if badge || meta}
|
|
||||||
<div class="mt-0.5 flex min-w-0 items-center gap-1.5">
|
|
||||||
{@render badge?.()}
|
|
||||||
{#if meta}
|
|
||||||
<span class="truncate text-sm text-fg-3" title={meta}>{meta}</span>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { gradientForHue, hueFor } from '$lib/theme/color';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
size?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { id, name, size = 108 }: Props = $props();
|
||||||
|
|
||||||
|
const initials = $derived(
|
||||||
|
name
|
||||||
|
.split(/\s+/)
|
||||||
|
.filter(Boolean)
|
||||||
|
.slice(0, 2)
|
||||||
|
.map((part) => part[0]?.toUpperCase() ?? '')
|
||||||
|
.join('')
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<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"
|
||||||
|
style="width:{size}px;height:{size}px;background:{gradientForHue(hueFor(id))}"
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
<div class="w-full truncate text-sm text-fg">{name}</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
<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>
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import MixArt from './MixArt.svelte';
|
||||||
|
import { hueFor } from '$lib/theme/color';
|
||||||
|
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();
|
||||||
|
</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"
|
||||||
|
>
|
||||||
|
<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]"
|
||||||
|
/>
|
||||||
|
<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>
|
||||||
|
<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 font-semibold text-white {featured ? 'text-xl sm:text-2xl' : 'text-base'}"
|
||||||
|
>
|
||||||
|
{mix.title}
|
||||||
|
</div>
|
||||||
|
{#if mix.subtitle}
|
||||||
|
<div class="mt-0.5 truncate text-xs text-white/70 sm:text-sm">{mix.subtitle}</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import MediaCard from './MediaCard.svelte';
|
|
||||||
import MixArt from './MixArt.svelte';
|
|
||||||
import { hueFor } from '$lib/theme/color';
|
|
||||||
import type { Mix } from '$lib/mixes';
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
mix: Mix;
|
|
||||||
index?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
let { mix, index = 0 }: Props = $props();
|
|
||||||
|
|
||||||
const count = $derived(Number(mix.itemCount));
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<MediaCard
|
|
||||||
href="/mixes/{mix.id}"
|
|
||||||
title={mix.title}
|
|
||||||
subtitle="{count} {count === 1 ? 'canción' : 'canciones'}"
|
|
||||||
{index}
|
|
||||||
>
|
|
||||||
{#snippet art(artClass)}
|
|
||||||
<MixArt items={mix.items} hue={hueFor(mix.id)} class={artClass} />
|
|
||||||
{/snippet}
|
|
||||||
</MediaCard>
|
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { Snippet } from 'svelte';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
class?: string;
|
||||||
|
children: Snippet;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { class: klass = '', children }: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="no-scrollbar -mx-1 flex gap-4 overflow-x-auto px-1 pb-2 {klass}">
|
||||||
|
{@render children()}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.no-scrollbar {
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
.no-scrollbar::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -12,11 +12,11 @@
|
|||||||
|
|
||||||
{#if actions}
|
{#if actions}
|
||||||
<div class="mb-3 flex items-baseline justify-between gap-4 sm:mb-4 {klass}">
|
<div class="mb-3 flex items-baseline justify-between gap-4 sm:mb-4 {klass}">
|
||||||
<h2 class="text-lg font-semibold tracking-tight text-fg sm:text-xl">{title}</h2>
|
<h2 class="text-lg tracking-tight text-fg sm:text-[28px]">{title}</h2>
|
||||||
{@render actions()}
|
{@render actions()}
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<h2 class="mb-3 text-lg font-semibold tracking-tight text-fg sm:mb-4 sm:text-xl {klass}">
|
<h2 class="mb-3 text-lg tracking-tight text-fg sm:mb-4 sm:text-[28px] {klass}">
|
||||||
{title}
|
{title}
|
||||||
</h2>
|
</h2>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -7,9 +7,10 @@ export const ALBUM_TRACKS_PAGE_SIZE = 200;
|
|||||||
export const ALBUM_EARLIEST_YEAR = 1877;
|
export const ALBUM_EARLIEST_YEAR = 1877;
|
||||||
export const LIBRARY_PICKER_PAGE_SIZE = 50;
|
export const LIBRARY_PICKER_PAGE_SIZE = 50;
|
||||||
export const EXPLORE_ALBUMS_PAGE_SIZE = 10;
|
export const EXPLORE_ALBUMS_PAGE_SIZE = 10;
|
||||||
export const EXPLORE_ALBUM_SLOTS = 10;
|
export const HOME_LATEST_PAGE_SIZE = 10;
|
||||||
export const HOME_LATEST_PAGE_SIZE = 12;
|
|
||||||
export const HOME_ALBUMS_LIMIT = 12;
|
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 HOME_SHELF_LIMIT = 6;
|
||||||
export const YOUTUBE_FILLER_LIMIT = 27;
|
export const YOUTUBE_FILLER_LIMIT = 27;
|
||||||
export const PLAYLIST_PICKER_PAGE_SIZE = 50;
|
export const PLAYLIST_PICKER_PAGE_SIZE = 50;
|
||||||
|
|||||||
@@ -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<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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
SEARCH_MIN_LENGTH,
|
SEARCH_MIN_LENGTH,
|
||||||
YOUTUBE_FILLER_LIMIT
|
YOUTUBE_FILLER_LIMIT
|
||||||
} from '$lib/config';
|
} from '$lib/config';
|
||||||
|
import { searchUsers } from '$lib/server/userDirectory';
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ url, locals, fetch }) => {
|
export const load: PageServerLoad = async ({ url, locals, fetch }) => {
|
||||||
const query = url.searchParams.get('q')?.trim() ?? '';
|
const query = url.searchParams.get('q')?.trim() ?? '';
|
||||||
@@ -62,6 +63,7 @@ export const load: PageServerLoad = async ({ url, locals, fetch }) => {
|
|||||||
youtubeAlbums: youtubeAlbumsRes?.data?.items ?? [],
|
youtubeAlbums: youtubeAlbumsRes?.data?.items ?? [],
|
||||||
ytResults: searchRes?.data ?? null,
|
ytResults: searchRes?.data ?? null,
|
||||||
ytError: Boolean(searchRes?.error),
|
ytError: Boolean(searchRes?.error),
|
||||||
|
users: searchUsers(query),
|
||||||
youtubeFiller: query
|
youtubeFiller: query
|
||||||
? Promise.resolve(null)
|
? Promise.resolve(null)
|
||||||
: fetchYoutubeFiller(api, locals.accessToken, YOUTUBE_FILLER_LIMIT),
|
: fetchYoutubeFiller(api, locals.accessToken, YOUTUBE_FILLER_LIMIT),
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
import { untrack } from 'svelte';
|
import { untrack } from 'svelte';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { player } from '$lib/player/player.svelte';
|
import { player } from '$lib/player/player.svelte';
|
||||||
|
import { fmtTime } from '$lib/format';
|
||||||
import Page from '$lib/components/ui/Page.svelte';
|
import Page from '$lib/components/ui/Page.svelte';
|
||||||
import PageHeader from '$lib/components/ui/PageHeader.svelte';
|
import PageHeader from '$lib/components/ui/PageHeader.svelte';
|
||||||
import MediaGrid from '$lib/components/ui/MediaGrid.svelte';
|
import MediaGrid from '$lib/components/ui/MediaGrid.svelte';
|
||||||
@@ -11,12 +12,17 @@
|
|||||||
import Input from '$lib/components/ui/Input.svelte';
|
import Input from '$lib/components/ui/Input.svelte';
|
||||||
import EmptyState from '$lib/components/ui/EmptyState.svelte';
|
import EmptyState from '$lib/components/ui/EmptyState.svelte';
|
||||||
import InfiniteScroll from '$lib/components/ui/InfiniteScroll.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 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 { hueFor } from '$lib/theme/color';
|
||||||
import type { YouTubeSong } from '$lib/types';
|
import type { YouTubeSong } from '$lib/types';
|
||||||
import TrackTile from '$lib/components/TrackTile.svelte';
|
|
||||||
import TrackContextMenu, {
|
import TrackContextMenu, {
|
||||||
contextMenuStateFor,
|
contextMenuStateFor,
|
||||||
type ContextMenuState
|
type ContextMenuState
|
||||||
@@ -85,33 +91,55 @@
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
const items = $derived<TrackTarget[]>([
|
const songRows = $derived([
|
||||||
...localItems.map((track) => ({ kind: 'local' as const, track })),
|
...localItems.map((track) => ({
|
||||||
...ytItems.map((song) => ({ kind: 'youtube' as const, song }))
|
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<TrackTarget[]>(songRows.map((row) => row.target));
|
||||||
|
|
||||||
const hasMore = $derived(localHasNext || ytContinuation !== '');
|
const hasMore = $derived(localHasNext || ytContinuation !== '');
|
||||||
|
|
||||||
const albums = $derived(data.albums);
|
const albums = $derived(data.albums);
|
||||||
const youtubeAlbums = $derived(data.youtubeAlbums);
|
const youtubeAlbums = $derived(data.youtubeAlbums);
|
||||||
|
const users = $derived(data.users);
|
||||||
|
|
||||||
type LocalAlbum = (typeof data.albums)[number];
|
type LocalAlbum = (typeof data.albums)[number];
|
||||||
type YouTubeAlbum = (typeof data.youtubeAlbums)[number];
|
type YouTubeAlbum = (typeof data.youtubeAlbums)[number];
|
||||||
type AlbumEntry = { kind: 'local'; album: LocalAlbum } | { kind: 'youtube'; album: YouTubeAlbum };
|
type AlbumEntry = { kind: 'local'; album: LocalAlbum } | { kind: 'youtube'; album: YouTubeAlbum };
|
||||||
|
|
||||||
const albumEntries = $derived<AlbumEntry[]>(
|
const albumEntries = $derived<AlbumEntry[]>([
|
||||||
[
|
|
||||||
...albums.map((album) => ({ kind: 'local' as const, album })),
|
...albums.map((album) => ({ kind: 'local' as const, album })),
|
||||||
...youtubeAlbums.map((album) => ({ kind: 'youtube' as const, album }))
|
...youtubeAlbums.map((album) => ({ kind: 'youtube' as const, album }))
|
||||||
].slice(0, EXPLORE_ALBUM_SLOTS)
|
]);
|
||||||
);
|
|
||||||
|
|
||||||
const hasAlbums = $derived(albumEntries.length > 0);
|
const hasAlbums = $derived(albumEntries.length > 0);
|
||||||
const fillerCount = $derived(Math.max(0, EXPLORE_ALBUM_SLOTS - albumEntries.length));
|
const hasUsers = $derived(users.length > 0);
|
||||||
const fillerTracks = $derived(items.slice(0, fillerCount));
|
|
||||||
const remainingTracks = $derived(items.slice(fillerCount));
|
|
||||||
|
|
||||||
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) {
|
function buildHref(query: string) {
|
||||||
return query ? `/explore?q=${encodeURIComponent(query)}` : '/explore';
|
return query ? `/explore?q=${encodeURIComponent(query)}` : '/explore';
|
||||||
@@ -123,11 +151,7 @@
|
|||||||
searchTimeout = setTimeout(() => {
|
searchTimeout = setTimeout(() => {
|
||||||
const term = value.trim();
|
const term = value.trim();
|
||||||
if (term === data.query) return;
|
if (term === data.query) return;
|
||||||
goto(buildHref(term), {
|
goto(buildHref(term), { keepFocus: true, replaceState: true, noScroll: true });
|
||||||
keepFocus: true,
|
|
||||||
replaceState: true,
|
|
||||||
noScroll: true
|
|
||||||
});
|
|
||||||
}, SEARCH_DEBOUNCE_MS);
|
}, SEARCH_DEBOUNCE_MS);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -139,10 +163,6 @@
|
|||||||
contextMenu = contextMenuStateFor(event, target);
|
contextMenu = contextMenuStateFor(event, target);
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeContextMenu() {
|
|
||||||
contextMenu = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isForeignAlbum(album: LocalAlbum) {
|
function isForeignAlbum(album: LocalAlbum) {
|
||||||
return String(album.ownerUserId) !== data.user?.sub;
|
return String(album.ownerUserId) !== data.user?.sub;
|
||||||
}
|
}
|
||||||
@@ -151,14 +171,10 @@
|
|||||||
albumMenu = albumContextMenuStateFor(event, kind, albumId);
|
albumMenu = albumContextMenuStateFor(event, kind, albumId);
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeAlbumMenu() {
|
|
||||||
albumMenu = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function addToQueue() {
|
function addToQueue() {
|
||||||
if (!contextMenu) return;
|
if (!contextMenu) return;
|
||||||
player.addToQueue(queueItemForTarget(contextMenu));
|
player.addToQueue(queueItemForTarget(contextMenu));
|
||||||
closeContextMenu();
|
contextMenu = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadMoreLocal() {
|
async function loadMoreLocal() {
|
||||||
@@ -200,12 +216,12 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<title>Explorar · Musify</title>
|
<title>Buscar · Musify</title>
|
||||||
<meta name="description" content="Explora tu música y busca canciones en YouTube Music." />
|
<meta name="description" content="Busca canciones, álbumes y usuarios en Musify." />
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<Page>
|
<Page>
|
||||||
<PageHeader title="Explorar" subtitle="Busca a la vez en tu música y en YouTube Music." />
|
<PageHeader title="Buscar" subtitle="Encuentra canciones, álbumes y personas." />
|
||||||
|
|
||||||
<div class="mt-8">
|
<div class="mt-8">
|
||||||
<Input
|
<Input
|
||||||
@@ -230,92 +246,139 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if hasAlbums}
|
{#if hasAlbums}
|
||||||
<div class="mt-8">
|
<div class="mt-10">
|
||||||
<SectionHeading title="Álbumes" />
|
<SectionHeading title="Álbumes" />
|
||||||
<MediaGrid as="ul" class="mt-4">
|
<MediaGrid as="ul" min="168px" minMobile="150px">
|
||||||
{#each albumEntries as entry, i (entry.kind === 'local' ? entry.album.id : entry.album.albumId)}
|
{#each albumEntries as entry, i (entry.kind === 'local' ? entry.album.id : entry.album.albumId)}
|
||||||
<li>
|
<li>
|
||||||
{#if entry.kind === 'local'}
|
{#if entry.kind === 'local'}
|
||||||
<AlbumCard
|
<a
|
||||||
id={entry.album.id}
|
href="/albums/{entry.album.id}"
|
||||||
title={entry.album.title}
|
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"
|
||||||
typeLabel="Álbum"
|
style="animation-delay:{Math.min(i, 10) * 45}ms"
|
||||||
releaseYear={entry.album.releaseYear === null
|
oncontextmenu={isForeignAlbum(entry.album)
|
||||||
? undefined
|
|
||||||
: Number(entry.album.releaseYear)}
|
|
||||||
trackCount={Number(entry.album.trackCount)}
|
|
||||||
trackIds={entry.album.coverTrackIds}
|
|
||||||
index={i}
|
|
||||||
onContextMenu={isForeignAlbum(entry.album)
|
|
||||||
? (e) => openAlbumMenu(e, 'local', entry.album.id)
|
? (e) => openAlbumMenu(e, 'local', entry.album.id)
|
||||||
: undefined}
|
: undefined}
|
||||||
|
>
|
||||||
|
<div class="relative">
|
||||||
|
<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"
|
||||||
/>
|
/>
|
||||||
|
<CoverBadge label="Álbum" />
|
||||||
|
</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>
|
||||||
{:else}
|
{:else}
|
||||||
<AlbumCard
|
<a
|
||||||
id={entry.album.albumId}
|
|
||||||
href="/albums/youtube/{entry.album.albumId}"
|
href="/albums/youtube/{entry.album.albumId}"
|
||||||
title={entry.album.title}
|
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"
|
||||||
typeLabel="Álbum"
|
style="animation-delay:{Math.min(i, 10) * 45}ms"
|
||||||
subtitle={entry.album.artist}
|
oncontextmenu={(e) => openAlbumMenu(e, 'youtube', entry.album.albumId)}
|
||||||
releaseYear={entry.album.releaseYear === null
|
>
|
||||||
? undefined
|
<div class="relative">
|
||||||
: Number(entry.album.releaseYear)}
|
<Cover
|
||||||
coverSrc={entry.album.thumbnailUrl}
|
trackId={entry.album.albumId}
|
||||||
index={i}
|
src={entry.album.thumbnailUrl}
|
||||||
onContextMenu={(e) => openAlbumMenu(e, 'youtube', entry.album.albumId)}
|
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"
|
||||||
/>
|
/>
|
||||||
|
<CoverBadge label="Álbum" />
|
||||||
|
</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>
|
||||||
{/if}
|
{/if}
|
||||||
</li>
|
</li>
|
||||||
{/each}
|
{/each}
|
||||||
{#each fillerTracks as item, i (targetId(item))}
|
|
||||||
<TrackTile
|
|
||||||
id={targetId(item)}
|
|
||||||
title={targetTitle(item)}
|
|
||||||
artist={targetArtist(item)}
|
|
||||||
typeLabel="Canción"
|
|
||||||
coverShape="round"
|
|
||||||
coverSrc={targetCoverSrc(item)}
|
|
||||||
hue={hueFor(targetId(item))}
|
|
||||||
explicit={targetExplicit(item)}
|
|
||||||
active={isTargetCurrent(item)}
|
|
||||||
playing={player.isPlaying}
|
|
||||||
index={albumEntries.length + i}
|
|
||||||
onClick={() => togglePlay(i)}
|
|
||||||
onContextMenu={(e) => openContextMenu(e, item)}
|
|
||||||
/>
|
|
||||||
{/each}
|
|
||||||
</MediaGrid>
|
</MediaGrid>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if remainingTracks.length > 0}
|
{#if hasUsers}
|
||||||
{#if hasAlbums}
|
<div class="mt-10">
|
||||||
<SectionHeading title="Canciones" class="mt-10" />
|
<SectionHeading title="Usuarios" />
|
||||||
{/if}
|
<Rail>
|
||||||
<MediaGrid as="ul" class="mt-6">
|
{#each users as u, i (u.id)}
|
||||||
{#each remainingTracks as item, i (targetId(item))}
|
<div
|
||||||
<TrackTile
|
class="group/artist animate-enter shrink-0"
|
||||||
id={targetId(item)}
|
style="animation-delay:{Math.min(i, 10) * 45}ms"
|
||||||
title={targetTitle(item)}
|
>
|
||||||
artist={targetArtist(item)}
|
<ArtistAvatar id={u.id} name={u.name} size={96} />
|
||||||
typeLabel="Canción"
|
<div class="mt-1 w-24 truncate text-center text-xs text-fg-3">@{u.handle}</div>
|
||||||
coverShape="round"
|
</div>
|
||||||
coverSrc={targetCoverSrc(item)}
|
|
||||||
hue={hueFor(targetId(item))}
|
|
||||||
explicit={targetExplicit(item)}
|
|
||||||
active={isTargetCurrent(item)}
|
|
||||||
playing={player.isPlaying}
|
|
||||||
index={i}
|
|
||||||
onClick={() => togglePlay(fillerCount + i)}
|
|
||||||
onContextMenu={(e) => openContextMenu(e, item)}
|
|
||||||
/>
|
|
||||||
{/each}
|
{/each}
|
||||||
</MediaGrid>
|
</Rail>
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
<div class="mt-10">
|
||||||
|
<SectionHeading title="Canciones" />
|
||||||
|
{#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)}
|
||||||
|
hue={hueFor(targetId(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-fg transition-colors group-hover/row:text-accent-soft">
|
||||||
|
{targetTitle(item)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{#if targetArtist(item)}
|
||||||
|
<div class="truncate text-sm text-fg-3">{targetArtist(item)}</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<span class="shrink-0 text-sm text-muted tabular-nums">{fmtTime(seconds)}</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
|
||||||
{#if hasMore}
|
{#if hasMore}
|
||||||
<InfiniteScroll onLoadMore={loadMore} {hasMore} loading={loadingMore} />
|
<InfiniteScroll onLoadMore={loadMore} {hasMore} loading={loadingMore} />
|
||||||
{/if}
|
{/if}
|
||||||
|
{:else if data.query && !data.ytError}
|
||||||
|
<EmptyState icon={Music} description="No hay canciones que coincidan con «{data.query}»." />
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
{#if data.ytError}
|
{#if data.ytError}
|
||||||
<EmptyState
|
<EmptyState
|
||||||
@@ -329,11 +392,15 @@
|
|||||||
<TrackContextMenu
|
<TrackContextMenu
|
||||||
menu={contextMenu}
|
menu={contextMenu}
|
||||||
playlists={data.playlists}
|
playlists={data.playlists}
|
||||||
onClose={closeContextMenu}
|
onClose={() => (contextMenu = null)}
|
||||||
onAddToQueue={addToQueue}
|
onAddToQueue={addToQueue}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if albumMenu}
|
{#if albumMenu}
|
||||||
<AlbumContextMenu menu={albumMenu} playlists={data.playlists} onClose={closeAlbumMenu} />
|
<AlbumContextMenu
|
||||||
|
menu={albumMenu}
|
||||||
|
playlists={data.playlists}
|
||||||
|
onClose={() => (albumMenu = null)}
|
||||||
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -1,15 +1,17 @@
|
|||||||
import type { PageServerLoad, Actions } from './$types';
|
import type { PageServerLoad, Actions } from './$types';
|
||||||
import { createApiClient, requireUser } from '$lib/server/api';
|
import { createApiClient, requireUser } from '$lib/server/api';
|
||||||
import { fetchYoutubeFiller } from '$lib/server/youtube';
|
import { fetchYoutubeFiller } from '$lib/server/youtube';
|
||||||
import { shuffle } from '$lib/collections';
|
|
||||||
import { addTrackAction, addYouTubeToPlaylistAction } from '$lib/server/playlistActions';
|
import { addTrackAction, addYouTubeToPlaylistAction } from '$lib/server/playlistActions';
|
||||||
import {
|
import {
|
||||||
HOME_ALBUMS_LIMIT,
|
HOME_ALBUMS_LIMIT,
|
||||||
|
HOME_ARTISTS_LIMIT,
|
||||||
HOME_LATEST_PAGE_SIZE,
|
HOME_LATEST_PAGE_SIZE,
|
||||||
|
HOME_MIXES_BENTO,
|
||||||
HOME_SHELF_LIMIT,
|
HOME_SHELF_LIMIT,
|
||||||
YOUTUBE_FILLER_LIMIT
|
YOUTUBE_FILLER_LIMIT
|
||||||
} from '$lib/config';
|
} from '$lib/config';
|
||||||
import { pickGreeting } from '$lib/server/greeting';
|
import { pickGreeting } from '$lib/server/greeting';
|
||||||
|
import { pickTopArtists } from '$lib/server/topArtists';
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ locals, url, fetch }) => {
|
export const load: PageServerLoad = async ({ locals, url, fetch }) => {
|
||||||
const user = requireUser(locals, url);
|
const user = requireUser(locals, url);
|
||||||
@@ -40,23 +42,24 @@ export const load: PageServerLoad = async ({ locals, url, fetch }) => {
|
|||||||
recentlyPlayedPromise
|
recentlyPlayedPromise
|
||||||
]);
|
]);
|
||||||
const playlistItems = playlists.data?.items ?? [];
|
const playlistItems = playlists.data?.items ?? [];
|
||||||
|
const latestItems = latest.data?.items ?? [];
|
||||||
|
|
||||||
return {
|
return {
|
||||||
greeting: pickGreeting(new Date().getHours()),
|
greeting: pickGreeting(new Date().getHours()),
|
||||||
latest: shuffle((latest.data?.items ?? []).map((track) => ({ kind: 'local' as const, track }))),
|
recentlyPlayed: recentlyPlayed.data ?? [],
|
||||||
latestHasNext: Boolean(latest.data?.hasNextPage),
|
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(
|
youtubeFiller: fetchYoutubeFiller(
|
||||||
api,
|
api,
|
||||||
locals.accessToken,
|
locals.accessToken,
|
||||||
YOUTUBE_FILLER_LIMIT,
|
YOUTUBE_FILLER_LIMIT,
|
||||||
undefined,
|
undefined,
|
||||||
user.sub
|
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 ?? []
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
+226
-152
@@ -1,5 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Music from '@lucide/svelte/icons/music';
|
import Music from '@lucide/svelte/icons/music';
|
||||||
|
import ListMusic from '@lucide/svelte/icons/list-music';
|
||||||
import { player, toQueueItems, type QueueItem } from '$lib/player/player.svelte';
|
import { player, toQueueItems, type QueueItem } from '$lib/player/player.svelte';
|
||||||
import {
|
import {
|
||||||
isTargetCurrent,
|
isTargetCurrent,
|
||||||
@@ -14,17 +15,16 @@
|
|||||||
} from '$lib/tracks';
|
} from '$lib/tracks';
|
||||||
import { hueFor } from '$lib/theme/color';
|
import { hueFor } from '$lib/theme/color';
|
||||||
import Cover from '$lib/components/ui/Cover.svelte';
|
import Cover from '$lib/components/ui/Cover.svelte';
|
||||||
import PlaylistCard from '$lib/components/ui/PlaylistCard.svelte';
|
import PlaylistArt from '$lib/components/ui/PlaylistArt.svelte';
|
||||||
import MixCard from '$lib/components/ui/MixCard.svelte';
|
import MixBentoTile from '$lib/components/ui/MixBentoTile.svelte';
|
||||||
import AlbumCard from '$lib/components/ui/AlbumCard.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 NowPlaying from '$lib/components/ui/NowPlaying.svelte';
|
||||||
import EmptyState from '$lib/components/ui/EmptyState.svelte';
|
import EmptyState from '$lib/components/ui/EmptyState.svelte';
|
||||||
import ExplicitBadge from '$lib/components/ui/ExplicitBadge.svelte';
|
import ExplicitBadge from '$lib/components/ui/ExplicitBadge.svelte';
|
||||||
import InfiniteScroll from '$lib/components/ui/InfiniteScroll.svelte';
|
|
||||||
import { HOME_LATEST_PAGE_SIZE } from '$lib/config';
|
|
||||||
import MediaGrid from '$lib/components/ui/MediaGrid.svelte';
|
import MediaGrid from '$lib/components/ui/MediaGrid.svelte';
|
||||||
import SectionHeading from '$lib/components/ui/SectionHeading.svelte';
|
import SectionHeading from '$lib/components/ui/SectionHeading.svelte';
|
||||||
import TrackTile from '$lib/components/TrackTile.svelte';
|
|
||||||
import TrackContextMenu, {
|
import TrackContextMenu, {
|
||||||
contextMenuStateFor,
|
contextMenuStateFor,
|
||||||
type ContextMenuState
|
type ContextMenuState
|
||||||
@@ -34,100 +34,32 @@
|
|||||||
|
|
||||||
let { data } = $props();
|
let { data } = $props();
|
||||||
|
|
||||||
let youtubeFillerItems = $state<YouTubeSong[]>([]);
|
|
||||||
let ytContinuation = $state('');
|
|
||||||
let ytQuery = $state('');
|
|
||||||
let contextMenu = $state<ContextMenuState | null>(null);
|
let contextMenu = $state<ContextMenuState | null>(null);
|
||||||
|
let youtubeFillerItems = $state<YouTubeSong[]>([]);
|
||||||
type LocalLatest = (typeof data.latest)[number];
|
|
||||||
|
|
||||||
let localLatest = $state<LocalLatest[]>([]);
|
|
||||||
let localPage = $state(1);
|
|
||||||
let localHasNext = $state(false);
|
|
||||||
let loadingMore = $state(false);
|
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
youtubeFillerItems = [];
|
youtubeFillerItems = [];
|
||||||
ytContinuation = '';
|
|
||||||
ytQuery = '';
|
|
||||||
localLatest = data.latest;
|
|
||||||
localPage = 1;
|
|
||||||
localHasNext = data.latestHasNext;
|
|
||||||
|
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
data.youtubeFiller.then((filler) => {
|
data.youtubeFiller.then((filler) => {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
youtubeFillerItems = filler.items;
|
youtubeFillerItems = appendUnique([], filler.items, (song) => song.videoId);
|
||||||
ytContinuation = filler.continuationToken;
|
|
||||||
ytQuery = filler.query;
|
|
||||||
});
|
});
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
const latest = $derived<TrackTarget[]>([
|
|
||||||
...localLatest,
|
|
||||||
...youtubeFillerItems.map((song) => ({ kind: 'youtube' as const, song }))
|
|
||||||
]);
|
|
||||||
|
|
||||||
const hasMoreLatest = $derived(localHasNext || ytContinuation !== '');
|
|
||||||
|
|
||||||
async function loadMoreLatest() {
|
|
||||||
if (loadingMore) return;
|
|
||||||
loadingMore = true;
|
|
||||||
try {
|
|
||||||
if (localHasNext) {
|
|
||||||
const params = new URLSearchParams({
|
|
||||||
pageNumber: String(localPage + 1),
|
|
||||||
pageSize: String(HOME_LATEST_PAGE_SIZE)
|
|
||||||
});
|
|
||||||
const res = await fetch(`/api/tracks?${params}`);
|
|
||||||
if (!res.ok) throw new Error(String(res.status));
|
|
||||||
const next = await res.json();
|
|
||||||
localLatest = appendUnique(
|
|
||||||
localLatest,
|
|
||||||
next.items.map((track: LocalLatest['track']) => ({ kind: 'local' as const, track })),
|
|
||||||
(item) => item.track.id
|
|
||||||
);
|
|
||||||
localPage = Number(next.pageNumber);
|
|
||||||
localHasNext = Boolean(next.hasNextPage);
|
|
||||||
} else if (ytContinuation && ytQuery) {
|
|
||||||
const params = new URLSearchParams({ query: ytQuery, continuation: ytContinuation });
|
|
||||||
const res = await fetch(`/api/youtube/search?${params}`);
|
|
||||||
if (!res.ok) throw new Error(String(res.status));
|
|
||||||
const next = (await res.json()) as { items: YouTubeSong[]; continuationToken: string };
|
|
||||||
youtubeFillerItems = appendUnique(youtubeFillerItems, next.items, (i) => i.videoId);
|
|
||||||
ytContinuation = next.continuationToken;
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
if (localHasNext) localHasNext = false;
|
|
||||||
else ytContinuation = '';
|
|
||||||
} finally {
|
|
||||||
loadingMore = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const playlists = $derived(data.playlists);
|
|
||||||
const albums = $derived(data.albums);
|
const albums = $derived(data.albums);
|
||||||
const mixes = $derived(data.mixes);
|
const mixes = $derived(data.mixes);
|
||||||
|
const playlists = $derived(data.playlists);
|
||||||
|
const artists = $derived(data.topArtists);
|
||||||
|
|
||||||
function playLatest(index: number) {
|
const newTargets = $derived<TrackTarget[]>(
|
||||||
player.playOrToggle(latest.map(queueItemForTarget), index);
|
data.newReleases.map((track) => ({ kind: 'local' as const, track }))
|
||||||
}
|
);
|
||||||
|
const recTargets = $derived<TrackTarget[]>(
|
||||||
function openContextMenu(event: MouseEvent, target: TrackTarget) {
|
youtubeFillerItems.map((song) => ({ kind: 'youtube' as const, song }))
|
||||||
contextMenu = contextMenuStateFor(event, target);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
function closeContextMenu() {
|
|
||||||
contextMenu = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function addToQueue() {
|
|
||||||
if (!contextMenu) return;
|
|
||||||
player.addToQueue(queueItemForTarget(contextMenu));
|
|
||||||
closeContextMenu();
|
|
||||||
}
|
|
||||||
|
|
||||||
const recentlyPlayed = $derived.by(() => {
|
const recentlyPlayed = $derived.by(() => {
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
@@ -158,12 +90,36 @@
|
|||||||
explicit: item.explicit
|
explicit: item.explicit
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return merged.slice(0, 15);
|
return merged.slice(0, 12);
|
||||||
});
|
});
|
||||||
|
|
||||||
function playRecent(index: number) {
|
function playRecent(index: number) {
|
||||||
player.playOrToggle(recentlyPlayed, index);
|
player.playOrToggle(recentlyPlayed, index);
|
||||||
}
|
}
|
||||||
|
function playNew(index: number) {
|
||||||
|
player.playOrToggle(newTargets.map(queueItemForTarget), index);
|
||||||
|
}
|
||||||
|
function playRecommended(index: number) {
|
||||||
|
player.playOrToggle(recTargets.map(queueItemForTarget), index);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openContextMenu(event: MouseEvent, target: TrackTarget) {
|
||||||
|
contextMenu = contextMenuStateFor(event, target);
|
||||||
|
}
|
||||||
|
function addToQueue() {
|
||||||
|
if (!contextMenu) return;
|
||||||
|
player.addToQueue(queueItemForTarget(contextMenu));
|
||||||
|
contextMenu = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
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(' · ');
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
@@ -178,7 +134,7 @@
|
|||||||
></div>
|
></div>
|
||||||
<div class="animate-enter relative flex flex-col gap-2 sm:gap-2.5">
|
<div class="animate-enter relative flex flex-col gap-2 sm:gap-2.5">
|
||||||
<p class="text-sm tracking-[0.14em] text-fg-2 uppercase sm:text-base">{data.greeting}</p>
|
<p class="text-sm tracking-[0.14em] text-fg-2 uppercase sm:text-base">{data.greeting}</p>
|
||||||
<h1 class="max-w-160 text-3xl leading-[1.05] font-semibold text-fg sm:text-5xl">
|
<h1 class="text-3xl leading-[1.05] font-semibold text-fg sm:text-6xl">
|
||||||
Tu música. Sin límites.
|
Tu música. Sin límites.
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
@@ -186,8 +142,8 @@
|
|||||||
|
|
||||||
{#if recentlyPlayed.length > 0}
|
{#if recentlyPlayed.length > 0}
|
||||||
<section class="page-x pt-6 sm:pt-8">
|
<section class="page-x pt-6 sm:pt-8">
|
||||||
<SectionHeading title="Volver a escuchar" />
|
<SectionHeading title="Escuchar otra vez" />
|
||||||
<MediaGrid min="280px" minMobile="220px">
|
<MediaGrid min="260px" minMobile="220px">
|
||||||
{#each recentlyPlayed as track, i (track.id)}
|
{#each recentlyPlayed as track, i (track.id)}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -196,7 +152,7 @@
|
|||||||
aria-label="{player.current.id === track.id && player.playing
|
aria-label="{player.current.id === track.id && player.playing
|
||||||
? 'Pausar'
|
? 'Pausar'
|
||||||
: 'Reproducir'} {track.title}"
|
: 'Reproducir'} {track.title}"
|
||||||
class="flex min-w-0 items-center gap-3 rounded-control bg-surface p-2 text-left transition hover:bg-surface-hover"
|
class="group/row flex min-w-0 items-center gap-3 rounded-control bg-surface p-2 text-left transition duration-200"
|
||||||
>
|
>
|
||||||
<Cover
|
<Cover
|
||||||
trackId={track.id}
|
trackId={track.id}
|
||||||
@@ -204,7 +160,7 @@
|
|||||||
hue={hueFor(track.id)}
|
hue={hueFor(track.id)}
|
||||||
size="small"
|
size="small"
|
||||||
alt={track.title}
|
alt={track.title}
|
||||||
class="h-14 w-14 shrink-0 rounded-control"
|
class="h-14 w-14 shrink-0 rounded-control shadow-art ring-line transition duration-200"
|
||||||
>
|
>
|
||||||
{#if player.current.id === track.id}
|
{#if player.current.id === track.id}
|
||||||
<NowPlaying paused={!player.playing} />
|
<NowPlaying paused={!player.playing} />
|
||||||
@@ -215,7 +171,9 @@
|
|||||||
{#if track.explicit}
|
{#if track.explicit}
|
||||||
<ExplicitBadge />
|
<ExplicitBadge />
|
||||||
{/if}
|
{/if}
|
||||||
<span class="truncate text-fg">{track.title}</span>
|
<span class="truncate text-fg transition-colors group-hover/row:text-accent-soft">
|
||||||
|
{track.title}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{#if track.artist}
|
{#if track.artist}
|
||||||
<div class="truncate text-sm text-muted">{track.artist}</div>
|
<div class="truncate text-sm text-muted">{track.artist}</div>
|
||||||
@@ -227,22 +185,64 @@
|
|||||||
</section>
|
</section>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if mixes.length > 0 || playlists.length > 0}
|
{#if albums.length > 0}
|
||||||
<section class="grid gap-x-8 gap-y-10 page-x pt-10 sm:pt-14 lg:grid-cols-2">
|
<section class="page-x pt-10 sm:pt-14">
|
||||||
{#if mixes.length > 0}
|
<SectionHeading title="Álbumes recién escuchados">
|
||||||
<div class="min-w-0">
|
{#snippet actions()}
|
||||||
<SectionHeading title="Hechas para ti" />
|
<a href="/albums" class="text-sm text-fg-3 transition hover:text-accent-soft">Ver todos</a>
|
||||||
<MediaGrid min="150px" minMobile="130px">
|
{/snippet}
|
||||||
{#each mixes as mix, i (mix.id)}
|
</SectionHeading>
|
||||||
<MixCard {mix} index={i} />
|
<Rail>
|
||||||
{/each}
|
{#each albums as album, i (album.id)}
|
||||||
</MediaGrid>
|
<a
|
||||||
|
href="/albums/{album.id}"
|
||||||
|
class="group/card animate-enter block w-[168px] 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"
|
||||||
|
>
|
||||||
|
<div class="relative">
|
||||||
|
<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"
|
||||||
|
/>
|
||||||
|
<CoverBadge label="Álbum" />
|
||||||
</div>
|
</div>
|
||||||
|
<div class="mt-2.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-3">{albumCaption(album)}</div>
|
||||||
|
</a>
|
||||||
|
{/each}
|
||||||
|
</Rail>
|
||||||
|
</section>
|
||||||
|
{/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">
|
||||||
|
{#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-[168px] sm:row-span-2 sm:min-h-0'
|
||||||
|
: i === 1
|
||||||
|
? 'col-span-2 min-h-[120px] 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'}
|
||||||
|
/>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if playlists.length > 0}
|
{#if playlists.length > 0}
|
||||||
<div class="min-w-0">
|
<section class="page-x pt-10 sm:pt-14">
|
||||||
<SectionHeading title="Mis listas">
|
<SectionHeading title="Tus playlists">
|
||||||
{#snippet actions()}
|
{#snippet actions()}
|
||||||
{#if data.playlistsHasMore}
|
{#if data.playlistsHasMore}
|
||||||
<a href="/playlists" class="text-sm text-fg-3 transition hover:text-accent-soft">
|
<a href="/playlists" class="text-sm text-fg-3 transition hover:text-accent-soft">
|
||||||
@@ -251,69 +251,143 @@
|
|||||||
{/if}
|
{/if}
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</SectionHeading>
|
</SectionHeading>
|
||||||
<MediaGrid min="150px" minMobile="130px">
|
<Rail>
|
||||||
{#each playlists as playlist, i (playlist.id)}
|
{#each playlists as playlist, i (playlist.id)}
|
||||||
<PlaylistCard
|
<a
|
||||||
id={playlist.id}
|
href="/playlists/{playlist.id}"
|
||||||
name={playlist.name}
|
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"
|
||||||
description={playlist.description}
|
style="animation-delay:{Math.min(i, 10) * 45}ms"
|
||||||
|
>
|
||||||
|
<PlaylistArt
|
||||||
|
playlistId={playlist.id}
|
||||||
trackIds={playlist.coverTrackIds}
|
trackIds={playlist.coverTrackIds}
|
||||||
updatedAt={playlist.updatedAt}
|
hue={hueFor(playlist.id)}
|
||||||
index={i}
|
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"
|
||||||
/>
|
/>
|
||||||
{/each}
|
<div class="mt-2.5 truncate text-fg transition-colors group-hover/card:text-accent-soft">
|
||||||
</MediaGrid>
|
{playlist.name}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
<div class="mt-0.5 truncate text-sm text-fg-3">{playlist.description ?? 'Lista'}</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"
|
||||||
|
>
|
||||||
|
<div class="flex flex-col items-center gap-2">
|
||||||
|
<ListMusic class="h-6 w-6" />
|
||||||
|
<span class="text-sm">Ver todas</span>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</Rail>
|
||||||
</section>
|
</section>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if albums.length > 0}
|
{#if newTargets.length > 0}
|
||||||
<section class="page-x pt-10 sm:pt-14">
|
<section class="page-x pt-10 sm:pt-14">
|
||||||
<SectionHeading title="Álbumes escuchados recientemente">
|
<SectionHeading title="Canciones nuevas" />
|
||||||
{#snippet actions()}
|
<Rail>
|
||||||
<a href="/albums" class="text-sm text-fg-3 transition hover:text-accent-soft">Ver todos</a>
|
{#each newTargets as target, i (targetId(target))}
|
||||||
{/snippet}
|
<button
|
||||||
</SectionHeading>
|
type="button"
|
||||||
<MediaGrid>
|
onclick={() => playNew(i)}
|
||||||
{#each albums as album, i (album.id)}
|
oncontextmenu={(e) => openContextMenu(e, target)}
|
||||||
<AlbumCard
|
aria-label="Reproducir {targetTitle(target)}"
|
||||||
id={album.id}
|
class="group/card animate-enter block w-[148px] 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"
|
||||||
title={album.title}
|
style="animation-delay:{Math.min(i, 10) * 45}ms"
|
||||||
releaseYear={album.releaseYear === null ? undefined : Number(album.releaseYear)}
|
>
|
||||||
trackCount={Number(album.trackCount)}
|
<div class="relative h-[198px] w-[148px]">
|
||||||
trackIds={album.coverTrackIds}
|
<Cover
|
||||||
index={i}
|
trackId={targetId(target)}
|
||||||
/>
|
src={targetCoverSrc(target)}
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
{#if isTargetCurrent(target)}
|
||||||
|
<NowPlaying paused={!player.playing} />
|
||||||
|
{/if}
|
||||||
|
</Cover>
|
||||||
|
<CoverBadge label="Nuevo" />
|
||||||
|
</div>
|
||||||
|
<div class="mt-2.5 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)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{#if targetArtist(target)}
|
||||||
|
<div class="mt-0.5 truncate text-sm text-fg-3">{targetArtist(target)}</div>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
</MediaGrid>
|
</Rail>
|
||||||
|
</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>
|
</section>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<section class="page-x pt-10 pb-16 sm:pt-14">
|
<section class="page-x pt-10 pb-16 sm:pt-14">
|
||||||
<SectionHeading title="Descubrir" />
|
<SectionHeading title="Recomendado para ti" />
|
||||||
{#if latest.length > 0}
|
{#if recTargets.length > 0}
|
||||||
<MediaGrid as="ul">
|
<Rail>
|
||||||
{#each latest as item, i (targetId(item))}
|
{#each recTargets as target, i (targetId(target))}
|
||||||
<TrackTile
|
<button
|
||||||
id={targetId(item)}
|
type="button"
|
||||||
title={targetTitle(item)}
|
onclick={() => playRecommended(i)}
|
||||||
artist={targetArtist(item)}
|
oncontextmenu={(e) => openContextMenu(e, target)}
|
||||||
coverShape="round"
|
aria-label="Reproducir {targetTitle(target)}"
|
||||||
coverSrc={targetCoverSrc(item)}
|
class="group/card animate-enter block w-[168px] 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"
|
||||||
hue={hueFor(targetId(item))}
|
style="animation-delay:{Math.min(i, 10) * 45}ms"
|
||||||
explicit={targetExplicit(item)}
|
>
|
||||||
active={isTargetCurrent(item)}
|
<div class="relative h-[168px] w-[168px]">
|
||||||
playing={player.isPlaying}
|
<Cover
|
||||||
index={i}
|
trackId={targetId(target)}
|
||||||
onClick={() => playLatest(i)}
|
src={targetCoverSrc(target)}
|
||||||
onContextMenu={(e) => openContextMenu(e, item)}
|
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"
|
||||||
|
>
|
||||||
|
{#if isTargetCurrent(target)}
|
||||||
|
<NowPlaying paused={!player.playing} />
|
||||||
|
{/if}
|
||||||
|
</Cover>
|
||||||
|
</div>
|
||||||
|
<div class="mt-2.5 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)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{#if targetArtist(target)}
|
||||||
|
<div class="mt-0.5 truncate text-sm text-fg-3">{targetArtist(target)}</div>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
</MediaGrid>
|
</Rail>
|
||||||
<InfiniteScroll onLoadMore={loadMoreLatest} hasMore={hasMoreLatest} loading={loadingMore} />
|
|
||||||
{:else}
|
{:else}
|
||||||
<EmptyState icon={Music} title="Todavía no hay música" />
|
<EmptyState icon={Music} title="Todavía no hay recomendaciones" />
|
||||||
{/if}
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -321,7 +395,7 @@
|
|||||||
<TrackContextMenu
|
<TrackContextMenu
|
||||||
menu={contextMenu}
|
menu={contextMenu}
|
||||||
{playlists}
|
{playlists}
|
||||||
onClose={closeContextMenu}
|
onClose={() => (contextMenu = null)}
|
||||||
onAddToQueue={addToQueue}
|
onAddToQueue={addToQueue}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
Reference in New Issue
Block a user