refactor(web): share track target helpers between home and explore
Both pages reimplemented the same accessors over the target union and a byte-identical queue-item mapper. Moves the domain type (renamed ContextMenuTarget -> TrackTarget, since tiles and the queue use it too) and its helpers to $lib/tracks.ts; TrackContextMenu keeps only its menu geometry. Home drops from 341 to ~230 lines. Unifies two divergences the duplication was hiding: home compared the active tile against track.id while the queue is keyed by queueIdForTrack, so YouTube-sourced tracks never highlighted; and it hardcoded explicit to false for local tracks, hiding the badge. Also cancels in-flight youtube filler results when the effect re-runs, so a slow response cannot overwrite newer ones.
This commit is contained in:
@@ -1,20 +1,12 @@
|
|||||||
<script lang="ts" module>
|
<script lang="ts" module>
|
||||||
import type { ApiTrackLike } from '$lib/player/player.svelte';
|
import type { TrackTarget } from '$lib/tracks';
|
||||||
import type { YouTubeSong } from '$lib/types';
|
|
||||||
|
|
||||||
export type ContextMenuTarget =
|
export type ContextMenuState = TrackTarget & { x: number; y: number; openLeft: boolean };
|
||||||
| { kind: 'youtube'; song: YouTubeSong }
|
|
||||||
| { kind: 'local'; track: ApiTrackLike };
|
|
||||||
|
|
||||||
export type ContextMenuState = ContextMenuTarget & { x: number; y: number; openLeft: boolean };
|
|
||||||
|
|
||||||
const MENU_WIDTH = 208;
|
const MENU_WIDTH = 208;
|
||||||
const SUBMENU_WIDTH = 224;
|
const SUBMENU_WIDTH = 224;
|
||||||
|
|
||||||
export function contextMenuStateFor(
|
export function contextMenuStateFor(event: MouseEvent, target: TrackTarget): ContextMenuState {
|
||||||
event: MouseEvent,
|
|
||||||
target: ContextMenuTarget
|
|
||||||
): ContextMenuState {
|
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
return {
|
return {
|
||||||
...target,
|
...target,
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import {
|
||||||
|
player,
|
||||||
|
queueIdForTrack,
|
||||||
|
toQueueItems,
|
||||||
|
type ApiTrackLike,
|
||||||
|
type QueueItem
|
||||||
|
} from '$lib/player/player.svelte';
|
||||||
|
import type { YouTubeSong } from '$lib/types';
|
||||||
|
|
||||||
|
export type TrackTarget =
|
||||||
|
| { kind: 'youtube'; song: YouTubeSong }
|
||||||
|
| { kind: 'local'; track: ApiTrackLike };
|
||||||
|
|
||||||
|
export function targetId(target: TrackTarget): string | number {
|
||||||
|
return target.kind === 'youtube' ? target.song.videoId : target.track.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function targetTitle(target: TrackTarget): string {
|
||||||
|
return target.kind === 'youtube' ? target.song.title : target.track.title;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function targetArtist(target: TrackTarget): string | undefined {
|
||||||
|
return target.kind === 'youtube' ? target.song.artist : target.track.artist;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function targetExplicit(target: TrackTarget): boolean {
|
||||||
|
return target.kind === 'youtube' ? target.song.isExplicit : (target.track.isExplicit ?? false);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function targetCoverSrc(target: TrackTarget): string | undefined {
|
||||||
|
return target.kind === 'youtube' ? target.song.thumbnailUrl : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function queueIdForTarget(target: TrackTarget): string | number {
|
||||||
|
return target.kind === 'youtube' ? target.song.videoId : queueIdForTrack(target.track);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isTargetCurrent(target: TrackTarget): boolean {
|
||||||
|
return player.current.id === queueIdForTarget(target);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function queueItemForTarget(target: TrackTarget): QueueItem {
|
||||||
|
return target.kind === 'youtube'
|
||||||
|
? {
|
||||||
|
id: target.song.videoId,
|
||||||
|
title: target.song.title,
|
||||||
|
artist: target.song.artist,
|
||||||
|
source: 'youtube',
|
||||||
|
coverUrl: target.song.thumbnailUrl,
|
||||||
|
explicit: target.song.isExplicit
|
||||||
|
}
|
||||||
|
: toQueueItems([target.track])[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function targetForQueueItem(item: QueueItem): TrackTarget {
|
||||||
|
return item.source === 'youtube'
|
||||||
|
? {
|
||||||
|
kind: 'youtube',
|
||||||
|
song: {
|
||||||
|
videoId: String(item.id),
|
||||||
|
title: item.title,
|
||||||
|
artist: item.artist ?? '',
|
||||||
|
album: '',
|
||||||
|
durationSeconds: 0,
|
||||||
|
thumbnailUrl: item.coverUrl ?? '',
|
||||||
|
isExplicit: item.explicit ?? false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
kind: 'local',
|
||||||
|
track: {
|
||||||
|
id: item.id,
|
||||||
|
title: item.title,
|
||||||
|
artist: item.artist,
|
||||||
|
source: 'Local',
|
||||||
|
isExplicit: item.explicit
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Search from '@lucide/svelte/icons/search';
|
import Search from '@lucide/svelte/icons/search';
|
||||||
import Music from '@lucide/svelte/icons/music';
|
import Music from '@lucide/svelte/icons/music';
|
||||||
|
import { untrack } from 'svelte';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { player, queueIdForTrack, toQueueItems, type QueueItem } from '$lib/player/player.svelte';
|
import { player } from '$lib/player/player.svelte';
|
||||||
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';
|
||||||
@@ -16,14 +17,23 @@
|
|||||||
import TrackTile from '$lib/components/TrackTile.svelte';
|
import TrackTile from '$lib/components/TrackTile.svelte';
|
||||||
import TrackContextMenu, {
|
import TrackContextMenu, {
|
||||||
contextMenuStateFor,
|
contextMenuStateFor,
|
||||||
type ContextMenuTarget,
|
|
||||||
type ContextMenuState
|
type ContextMenuState
|
||||||
} from '$lib/components/TrackContextMenu.svelte';
|
} from '$lib/components/TrackContextMenu.svelte';
|
||||||
import { appendUnique } from '$lib/collections.js';
|
import { appendUnique } from '$lib/collections';
|
||||||
|
import {
|
||||||
|
isTargetCurrent,
|
||||||
|
queueItemForTarget,
|
||||||
|
targetArtist,
|
||||||
|
targetCoverSrc,
|
||||||
|
targetExplicit,
|
||||||
|
targetId,
|
||||||
|
targetTitle,
|
||||||
|
type TrackTarget
|
||||||
|
} from '$lib/tracks';
|
||||||
|
|
||||||
let { data, form } = $props();
|
let { data, form } = $props();
|
||||||
|
|
||||||
let searchValue = $state(data.query);
|
let searchValue = $state(untrack(() => data.query));
|
||||||
let searchTimeout: ReturnType<typeof setTimeout> | undefined;
|
let searchTimeout: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
|
||||||
let ytItems = $state<YouTubeSong[]>([]);
|
let ytItems = $state<YouTubeSong[]>([]);
|
||||||
@@ -45,31 +55,29 @@
|
|||||||
localHasNext = Boolean(data.tracks.hasNextPage);
|
localHasNext = Boolean(data.tracks.hasNextPage);
|
||||||
|
|
||||||
if (data.query) {
|
if (data.query) {
|
||||||
ytItems = appendUnique(
|
ytItems = appendUnique([], (data.ytResults?.items ?? []) as YouTubeSong[], (i) => i.videoId);
|
||||||
[],
|
|
||||||
(data.ytResults?.items ?? []) as YouTubeSong[],
|
|
||||||
item => item.videoId
|
|
||||||
);
|
|
||||||
ytContinuation = data.ytResults?.continuationToken ?? '';
|
ytContinuation = data.ytResults?.continuationToken ?? '';
|
||||||
ytSearchTerm = data.query;
|
ytSearchTerm = data.query;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
ytItems = [];
|
ytItems = [];
|
||||||
ytContinuation = '';
|
ytContinuation = '';
|
||||||
ytSearchTerm = '';
|
ytSearchTerm = '';
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
data.youtubeFiller?.then((filler) => {
|
data.youtubeFiller?.then((filler) => {
|
||||||
if (!filler) return;
|
if (cancelled || !filler) return;
|
||||||
ytItems = appendUnique(
|
ytItems = appendUnique([], filler.items, (i) => i.videoId);
|
||||||
[],
|
|
||||||
filler.items,
|
|
||||||
item => item.videoId
|
|
||||||
);
|
|
||||||
ytContinuation = filler.continuationToken;
|
ytContinuation = filler.continuationToken;
|
||||||
ytSearchTerm = filler.query;
|
ytSearchTerm = filler.query;
|
||||||
});
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
const items = $derived<ContextMenuTarget[]>([
|
const items = $derived<TrackTarget[]>([
|
||||||
...localItems.map((track) => ({ kind: 'local' as const, track })),
|
...localItems.map((track) => ({ kind: 'local' as const, track })),
|
||||||
...ytItems.map((song) => ({ kind: 'youtube' as const, song }))
|
...ytItems.map((song) => ({ kind: 'youtube' as const, song }))
|
||||||
]);
|
]);
|
||||||
@@ -80,41 +88,6 @@
|
|||||||
!!data.query && items.length === 0 && !data.needsAuth && !data.ytError
|
!!data.query && items.length === 0 && !data.needsAuth && !data.ytError
|
||||||
);
|
);
|
||||||
|
|
||||||
function itemId(item: ContextMenuTarget) {
|
|
||||||
return item.kind === 'youtube' ? item.song.videoId : item.track.id;
|
|
||||||
}
|
|
||||||
|
|
||||||
function itemTitle(item: ContextMenuTarget) {
|
|
||||||
return item.kind === 'youtube' ? item.song.title : item.track.title;
|
|
||||||
}
|
|
||||||
|
|
||||||
function itemArtist(item: ContextMenuTarget) {
|
|
||||||
return item.kind === 'youtube' ? item.song.artist : item.track.artist;
|
|
||||||
}
|
|
||||||
|
|
||||||
function itemExplicit(item: ContextMenuTarget) {
|
|
||||||
return item.kind === 'youtube' ? item.song.isExplicit : (item.track.isExplicit ?? false);
|
|
||||||
}
|
|
||||||
|
|
||||||
function itemActive(item: ContextMenuTarget) {
|
|
||||||
return (
|
|
||||||
player.current?.id === (item.kind === 'youtube' ? item.song.videoId : queueIdForTrack(item.track))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function queueItemFor(target: ContextMenuTarget): QueueItem {
|
|
||||||
return target.kind === 'youtube'
|
|
||||||
? {
|
|
||||||
id: target.song.videoId,
|
|
||||||
title: target.song.title,
|
|
||||||
artist: target.song.artist,
|
|
||||||
source: 'youtube',
|
|
||||||
coverUrl: target.song.thumbnailUrl,
|
|
||||||
explicit: target.song.isExplicit
|
|
||||||
}
|
|
||||||
: toQueueItems([target.track])[0];
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildHref(query: string) {
|
function buildHref(query: string) {
|
||||||
return query ? `/explore?q=${encodeURIComponent(query)}` : '/explore';
|
return query ? `/explore?q=${encodeURIComponent(query)}` : '/explore';
|
||||||
}
|
}
|
||||||
@@ -132,10 +105,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function togglePlay(index: number) {
|
function togglePlay(index: number) {
|
||||||
player.playOrToggle(items.map(queueItemFor), index);
|
player.playOrToggle(items.map(queueItemForTarget), index);
|
||||||
}
|
}
|
||||||
|
|
||||||
function openContextMenu(event: MouseEvent, target: ContextMenuTarget) {
|
function openContextMenu(event: MouseEvent, target: TrackTarget) {
|
||||||
contextMenu = contextMenuStateFor(event, target);
|
contextMenu = contextMenuStateFor(event, target);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -145,7 +118,7 @@
|
|||||||
|
|
||||||
function addToQueue() {
|
function addToQueue() {
|
||||||
if (!contextMenu) return;
|
if (!contextMenu) return;
|
||||||
player.addToQueue(queueItemFor(contextMenu));
|
player.addToQueue(queueItemForTarget(contextMenu));
|
||||||
closeContextMenu();
|
closeContextMenu();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -158,11 +131,7 @@
|
|||||||
const res = await fetch(`/api/tracks?${params}`);
|
const res = await fetch(`/api/tracks?${params}`);
|
||||||
if (!res.ok) throw new Error(String(res.status));
|
if (!res.ok) throw new Error(String(res.status));
|
||||||
const next = (await res.json()) as typeof data.tracks;
|
const next = (await res.json()) as typeof data.tracks;
|
||||||
localItems = appendUnique(
|
localItems = appendUnique(localItems, next.items, (track) => track.id);
|
||||||
localItems,
|
|
||||||
next.items,
|
|
||||||
track => track.id
|
|
||||||
);
|
|
||||||
localPage = Number(next.pageNumber);
|
localPage = Number(next.pageNumber);
|
||||||
localHasNext = Boolean(next.hasNextPage);
|
localHasNext = Boolean(next.hasNextPage);
|
||||||
}
|
}
|
||||||
@@ -172,11 +141,7 @@
|
|||||||
const res = await fetch(`/api/youtube/search?${params}`);
|
const res = await fetch(`/api/youtube/search?${params}`);
|
||||||
if (!res.ok) throw new Error(String(res.status));
|
if (!res.ok) throw new Error(String(res.status));
|
||||||
const next = (await res.json()) as { items: YouTubeSong[]; continuationToken: string };
|
const next = (await res.json()) as { items: YouTubeSong[]; continuationToken: string };
|
||||||
ytItems = appendUnique(
|
ytItems = appendUnique(ytItems, next.items, (i) => i.videoId);
|
||||||
ytItems,
|
|
||||||
next.items,
|
|
||||||
item => item.videoId
|
|
||||||
);
|
|
||||||
ytContinuation = next.continuationToken;
|
ytContinuation = next.continuationToken;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -223,15 +188,15 @@
|
|||||||
|
|
||||||
{#if items.length > 0}
|
{#if items.length > 0}
|
||||||
<MediaGrid as="ul" class="mt-6">
|
<MediaGrid as="ul" class="mt-6">
|
||||||
{#each items as item, i (itemId(item))}
|
{#each items as item, i (targetId(item))}
|
||||||
<TrackTile
|
<TrackTile
|
||||||
id={itemId(item)}
|
id={targetId(item)}
|
||||||
title={itemTitle(item)}
|
title={targetTitle(item)}
|
||||||
artist={itemArtist(item)}
|
artist={targetArtist(item)}
|
||||||
coverSrc={item.kind === 'youtube' ? item.song.thumbnailUrl : undefined}
|
coverSrc={targetCoverSrc(item)}
|
||||||
hue={hueFor(itemId(item))}
|
hue={hueFor(targetId(item))}
|
||||||
explicit={itemExplicit(item)}
|
explicit={targetExplicit(item)}
|
||||||
active={itemActive(item)}
|
active={isTargetCurrent(item)}
|
||||||
playing={player.isPlaying}
|
playing={player.isPlaying}
|
||||||
index={i}
|
index={i}
|
||||||
onClick={() => togglePlay(i)}
|
onClick={() => togglePlay(i)}
|
||||||
|
|||||||
@@ -1,6 +1,17 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Music from '@lucide/svelte/icons/music';
|
import Music from '@lucide/svelte/icons/music';
|
||||||
import { player, toQueueItems, type QueueItem } from '$lib/player/player.svelte';
|
import { player, toQueueItems, type QueueItem } from '$lib/player/player.svelte';
|
||||||
|
import {
|
||||||
|
isTargetCurrent,
|
||||||
|
queueItemForTarget,
|
||||||
|
targetArtist,
|
||||||
|
targetCoverSrc,
|
||||||
|
targetExplicit,
|
||||||
|
targetForQueueItem,
|
||||||
|
targetId,
|
||||||
|
targetTitle,
|
||||||
|
type TrackTarget
|
||||||
|
} 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 PlaylistCard from '$lib/components/ui/PlaylistCard.svelte';
|
||||||
@@ -14,11 +25,10 @@
|
|||||||
import TrackTile from '$lib/components/TrackTile.svelte';
|
import TrackTile from '$lib/components/TrackTile.svelte';
|
||||||
import TrackContextMenu, {
|
import TrackContextMenu, {
|
||||||
contextMenuStateFor,
|
contextMenuStateFor,
|
||||||
type ContextMenuTarget,
|
|
||||||
type ContextMenuState
|
type ContextMenuState
|
||||||
} from '$lib/components/TrackContextMenu.svelte';
|
} from '$lib/components/TrackContextMenu.svelte';
|
||||||
import type { YouTubeSong } from '$lib/types';
|
import type { YouTubeSong } from '$lib/types';
|
||||||
import { appendUnique } from '$lib/collections.js';
|
import { appendUnique } from '$lib/collections';
|
||||||
|
|
||||||
let { data } = $props();
|
let { data } = $props();
|
||||||
|
|
||||||
@@ -27,9 +37,9 @@
|
|||||||
let ytQuery = $state('');
|
let ytQuery = $state('');
|
||||||
let contextMenu = $state<ContextMenuState | null>(null);
|
let contextMenu = $state<ContextMenuState | null>(null);
|
||||||
|
|
||||||
type LocalNovedad = (typeof data.novedades)[number];
|
type LocalLatest = (typeof data.latest)[number];
|
||||||
|
|
||||||
let localNovedades = $state<LocalNovedad[]>([]);
|
let localLatest = $state<LocalLatest[]>([]);
|
||||||
let localPage = $state(1);
|
let localPage = $state(1);
|
||||||
let localHasNext = $state(false);
|
let localHasNext = $state(false);
|
||||||
let loadingMore = $state(false);
|
let loadingMore = $state(false);
|
||||||
@@ -38,24 +48,30 @@
|
|||||||
youtubeFillerItems = [];
|
youtubeFillerItems = [];
|
||||||
ytContinuation = '';
|
ytContinuation = '';
|
||||||
ytQuery = '';
|
ytQuery = '';
|
||||||
localNovedades = data.novedades;
|
localLatest = data.latest;
|
||||||
localPage = 1;
|
localPage = 1;
|
||||||
localHasNext = data.novedadesHasNext;
|
localHasNext = data.latestHasNext;
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
data.youtubeFiller.then((filler) => {
|
data.youtubeFiller.then((filler) => {
|
||||||
|
if (cancelled) return;
|
||||||
youtubeFillerItems = filler.items;
|
youtubeFillerItems = filler.items;
|
||||||
ytContinuation = filler.continuationToken;
|
ytContinuation = filler.continuationToken;
|
||||||
ytQuery = filler.query;
|
ytQuery = filler.query;
|
||||||
});
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
const novedades = $derived([
|
const latest = $derived<TrackTarget[]>([
|
||||||
...localNovedades,
|
...localLatest,
|
||||||
...youtubeFillerItems.map((song) => ({ kind: 'youtube' as const, song }))
|
...youtubeFillerItems.map((song) => ({ kind: 'youtube' as const, song }))
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const hasMoreNovedades = $derived(localHasNext || ytContinuation !== '');
|
const hasMoreLatest = $derived(localHasNext || ytContinuation !== '');
|
||||||
|
|
||||||
async function loadMoreNovedades() {
|
async function loadMoreLatest() {
|
||||||
if (loadingMore) return;
|
if (loadingMore) return;
|
||||||
loadingMore = true;
|
loadingMore = true;
|
||||||
try {
|
try {
|
||||||
@@ -67,13 +83,10 @@
|
|||||||
const res = await fetch(`/api/tracks?${params}`);
|
const res = await fetch(`/api/tracks?${params}`);
|
||||||
if (!res.ok) throw new Error(String(res.status));
|
if (!res.ok) throw new Error(String(res.status));
|
||||||
const next = await res.json();
|
const next = await res.json();
|
||||||
localNovedades = appendUnique(
|
localLatest = appendUnique(
|
||||||
localNovedades,
|
localLatest,
|
||||||
next.items.map((track: LocalNovedad['track']) => ({
|
next.items.map((track: LocalLatest['track']) => ({ kind: 'local' as const, track })),
|
||||||
kind: 'local' as const,
|
(item) => item.track.id
|
||||||
track
|
|
||||||
})),
|
|
||||||
item => item.track.id
|
|
||||||
);
|
);
|
||||||
localPage = Number(next.pageNumber);
|
localPage = Number(next.pageNumber);
|
||||||
localHasNext = Boolean(next.hasNextPage);
|
localHasNext = Boolean(next.hasNextPage);
|
||||||
@@ -82,11 +95,7 @@
|
|||||||
const res = await fetch(`/api/youtube/search?${params}`);
|
const res = await fetch(`/api/youtube/search?${params}`);
|
||||||
if (!res.ok) throw new Error(String(res.status));
|
if (!res.ok) throw new Error(String(res.status));
|
||||||
const next = (await res.json()) as { items: YouTubeSong[]; continuationToken: string };
|
const next = (await res.json()) as { items: YouTubeSong[]; continuationToken: string };
|
||||||
youtubeFillerItems = appendUnique(
|
youtubeFillerItems = appendUnique(youtubeFillerItems, next.items, (i) => i.videoId);
|
||||||
youtubeFillerItems,
|
|
||||||
next.items,
|
|
||||||
item => item.videoId
|
|
||||||
);
|
|
||||||
ytContinuation = next.continuationToken;
|
ytContinuation = next.continuationToken;
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
@@ -99,40 +108,11 @@
|
|||||||
const playlists = $derived(data.playlists);
|
const playlists = $derived(data.playlists);
|
||||||
const trackIds = $derived(data.trackIds);
|
const trackIds = $derived(data.trackIds);
|
||||||
|
|
||||||
function novedadId(item: (typeof novedades)[number]) {
|
function playLatest(index: number) {
|
||||||
return item.kind === 'youtube' ? item.song.videoId : item.track.id;
|
player.playOrToggle(latest.map(queueItemForTarget), index);
|
||||||
}
|
}
|
||||||
|
|
||||||
function novedadTitle(item: (typeof novedades)[number]) {
|
function openContextMenu(event: MouseEvent, target: TrackTarget) {
|
||||||
return item.kind === 'youtube' ? item.song.title : item.track.title;
|
|
||||||
}
|
|
||||||
|
|
||||||
function novedadArtist(item: (typeof novedades)[number]) {
|
|
||||||
return item.kind === 'youtube' ? item.song.artist : item.track.artist;
|
|
||||||
}
|
|
||||||
|
|
||||||
function novedadExplicit(item: (typeof novedades)[number]) {
|
|
||||||
return item.kind === 'youtube' ? item.song.isExplicit : false;
|
|
||||||
}
|
|
||||||
|
|
||||||
function novedadQueueItem(item: ContextMenuTarget): QueueItem {
|
|
||||||
return item.kind === 'youtube'
|
|
||||||
? {
|
|
||||||
id: item.song.videoId,
|
|
||||||
title: item.song.title,
|
|
||||||
artist: item.song.artist,
|
|
||||||
source: 'youtube',
|
|
||||||
coverUrl: item.song.thumbnailUrl,
|
|
||||||
explicit: item.song.isExplicit
|
|
||||||
}
|
|
||||||
: toQueueItems([item.track])[0];
|
|
||||||
}
|
|
||||||
|
|
||||||
function playNovedad(index: number) {
|
|
||||||
player.playOrToggle(novedades.map(novedadQueueItem), index);
|
|
||||||
}
|
|
||||||
|
|
||||||
function openContextMenu(event: MouseEvent, target: ContextMenuTarget) {
|
|
||||||
contextMenu = contextMenuStateFor(event, target);
|
contextMenu = contextMenuStateFor(event, target);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -142,36 +122,10 @@
|
|||||||
|
|
||||||
function addToQueue() {
|
function addToQueue() {
|
||||||
if (!contextMenu) return;
|
if (!contextMenu) return;
|
||||||
player.addToQueue(novedadQueueItem(contextMenu));
|
player.addToQueue(queueItemForTarget(contextMenu));
|
||||||
closeContextMenu();
|
closeContextMenu();
|
||||||
}
|
}
|
||||||
|
|
||||||
function contextMenuTargetFor(item: QueueItem): ContextMenuTarget {
|
|
||||||
return item.source === 'youtube'
|
|
||||||
? {
|
|
||||||
kind: 'youtube',
|
|
||||||
song: {
|
|
||||||
videoId: String(item.id),
|
|
||||||
title: item.title,
|
|
||||||
artist: item.artist ?? '',
|
|
||||||
album: '',
|
|
||||||
durationSeconds: 0,
|
|
||||||
thumbnailUrl: item.coverUrl ?? '',
|
|
||||||
isExplicit: item.explicit ?? false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
: {
|
|
||||||
kind: 'local',
|
|
||||||
track: {
|
|
||||||
id: item.id,
|
|
||||||
title: item.title,
|
|
||||||
artist: item.artist,
|
|
||||||
source: 'Local',
|
|
||||||
isExplicit: item.explicit
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const recentlyPlayed = $derived.by(() => {
|
const recentlyPlayed = $derived.by(() => {
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
const merged: QueueItem[] = [];
|
const merged: QueueItem[] = [];
|
||||||
@@ -204,24 +158,6 @@
|
|||||||
return merged.slice(0, 15);
|
return merged.slice(0, 15);
|
||||||
});
|
});
|
||||||
|
|
||||||
const greeting = (() => {
|
|
||||||
const h = new Date().getHours();
|
|
||||||
const pools =
|
|
||||||
h < 6
|
|
||||||
? ['¿Aún despierto?', 'La noche también tiene banda sonora', 'Un ratito más, con música']
|
|
||||||
: h < 12
|
|
||||||
? ['Buenos días', 'Que empiece bien la mañana', 'Un nuevo día, una nueva lista']
|
|
||||||
: h < 14
|
|
||||||
? ['Buenos días', 'El mediodía sabe mejor con música']
|
|
||||||
: h < 19
|
|
||||||
? ['Buenas tardes', 'La tarde pide su banda sonora', 'Un respiro, con algo de música']
|
|
||||||
: h < 23
|
|
||||||
? ['Buenas noches', 'Hora de bajar el ritmo', 'Que la noche te acompañe']
|
|
||||||
: ['¿Aún despierto?', 'La noche también tiene banda sonora'];
|
|
||||||
const pick = pools[Math.floor(Math.random() * pools.length)];
|
|
||||||
return /[.!?]$/.test(pick) ? pick : `${pick}.`;
|
|
||||||
})();
|
|
||||||
|
|
||||||
function playRecent(index: number) {
|
function playRecent(index: number) {
|
||||||
player.playOrToggle(recentlyPlayed, index);
|
player.playOrToggle(recentlyPlayed, index);
|
||||||
}
|
}
|
||||||
@@ -238,7 +174,7 @@
|
|||||||
style="background:linear-gradient(135deg, var(--mf-accent), color-mix(in oklch, var(--mf-accent), black 55%));animation:breathe 9s ease-in-out infinite"
|
style="background:linear-gradient(135deg, var(--mf-accent), color-mix(in oklch, var(--mf-accent), black 55%));animation:breathe 9s ease-in-out infinite"
|
||||||
></div>
|
></div>
|
||||||
<div class="animate-enter relative flex flex-col gap-2.5">
|
<div class="animate-enter relative flex flex-col gap-2.5">
|
||||||
<p class="tracking-[0.14em] text-fg-2 uppercase">{greeting}</p>
|
<p class="tracking-[0.14em] text-fg-2 uppercase">{data.greeting}</p>
|
||||||
<h1 class="max-w-160 text-5xl leading-[1.05] font-extrabold text-fg">
|
<h1 class="max-w-160 text-5xl leading-[1.05] font-extrabold text-fg">
|
||||||
Tu música. Sin límites.
|
Tu música. Sin límites.
|
||||||
</h1>
|
</h1>
|
||||||
@@ -253,7 +189,7 @@
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onclick={() => playRecent(i)}
|
onclick={() => playRecent(i)}
|
||||||
oncontextmenu={(e) => openContextMenu(e, contextMenuTargetFor(track))}
|
oncontextmenu={(e) => openContextMenu(e, targetForQueueItem(track))}
|
||||||
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}"
|
||||||
@@ -308,29 +244,25 @@
|
|||||||
|
|
||||||
<section class="page-x pt-6 pb-10">
|
<section class="page-x pt-6 pb-10">
|
||||||
<SectionHeading title="Novedades" />
|
<SectionHeading title="Novedades" />
|
||||||
{#if novedades.length > 0}
|
{#if latest.length > 0}
|
||||||
<MediaGrid as="ul">
|
<MediaGrid as="ul">
|
||||||
{#each novedades as item, i (novedadId(item))}
|
{#each latest as item, i (targetId(item))}
|
||||||
<TrackTile
|
<TrackTile
|
||||||
id={novedadId(item)}
|
id={targetId(item)}
|
||||||
title={novedadTitle(item)}
|
title={targetTitle(item)}
|
||||||
artist={novedadArtist(item)}
|
artist={targetArtist(item)}
|
||||||
coverSrc={item.kind === 'youtube' ? item.song.thumbnailUrl : undefined}
|
coverSrc={targetCoverSrc(item)}
|
||||||
hue={hueFor(novedadId(item))}
|
hue={hueFor(targetId(item))}
|
||||||
explicit={novedadExplicit(item)}
|
explicit={targetExplicit(item)}
|
||||||
active={player.current.id === novedadId(item)}
|
active={isTargetCurrent(item)}
|
||||||
playing={player.isPlaying}
|
playing={player.isPlaying}
|
||||||
index={i}
|
index={i}
|
||||||
onClick={() => playNovedad(i)}
|
onClick={() => playLatest(i)}
|
||||||
onContextMenu={(e) => openContextMenu(e, item)}
|
onContextMenu={(e) => openContextMenu(e, item)}
|
||||||
/>
|
/>
|
||||||
{/each}
|
{/each}
|
||||||
</MediaGrid>
|
</MediaGrid>
|
||||||
<InfiniteScroll
|
<InfiniteScroll onLoadMore={loadMoreLatest} hasMore={hasMoreLatest} loading={loadingMore} />
|
||||||
onLoadMore={loadMoreNovedades}
|
|
||||||
hasMore={hasMoreNovedades}
|
|
||||||
loading={loadingMore}
|
|
||||||
/>
|
|
||||||
{:else}
|
{:else}
|
||||||
<EmptyState icon={Music} title="Todavía no hay música" />
|
<EmptyState icon={Music} title="Todavía no hay música" />
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
Reference in New Issue
Block a user