feat: recommend server-generated mixes from Musify and YouTube Music
Mixes are their own concept, not playlists: they live in their own Mixes/MixItems tables, cannot be edited and never show up among the user's playlists. Each item is either a Musify track or a YouTube Music reference, so YouTube songs stream directly instead of being downloaded. A daily Hangfire job seeds the mixes from the listening history: one discovery mix, one daily mix and one per top artist. The per-user YouTube filler query was already in the working tree and rides along here because it shares files with this change.
This commit is contained in:
Vendored
+122
@@ -126,6 +126,40 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/mixes": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/** Get The Server-Generated Mixes Of The Current User. */
|
||||
get: operations["GetMixes"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/mixes/{id}": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/** Get A Mix With All Of Its Songs. */
|
||||
get: operations["GetMixById"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/playlists": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -585,6 +619,31 @@ export interface components {
|
||||
[key: string]: string[];
|
||||
};
|
||||
};
|
||||
MixApplicationResponse: {
|
||||
/** Format: uuid */
|
||||
id: string;
|
||||
title: string;
|
||||
subtitle: null | string;
|
||||
/** Format: date */
|
||||
generatedOn: string;
|
||||
/** Format: int32 */
|
||||
itemCount: number | string;
|
||||
items: components["schemas"]["MixItemApplicationResponse"][];
|
||||
};
|
||||
MixItemApplicationResponse: {
|
||||
source: components["schemas"]["MixItemSource"];
|
||||
/** Format: uuid */
|
||||
trackId: null | string;
|
||||
videoId: null | string;
|
||||
title: string;
|
||||
artist: null | string;
|
||||
thumbnailUrl: null | string;
|
||||
/** Format: int32 */
|
||||
durationSeconds: number | string;
|
||||
isExplicit: boolean;
|
||||
};
|
||||
/** @enum {unknown} */
|
||||
MixItemSource: "Musify" | "YouTube";
|
||||
PaginatedResponseOfAlbumApplicationResponse: {
|
||||
items: components["schemas"]["AlbumApplicationResponse"][];
|
||||
/** Format: int32 */
|
||||
@@ -1184,6 +1243,69 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
GetMixes: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["MixApplicationResponse"][];
|
||||
};
|
||||
};
|
||||
/** @description Unauthorized */
|
||||
401: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
GetMixById: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["MixApplicationResponse"];
|
||||
};
|
||||
};
|
||||
/** @description Unauthorized */
|
||||
401: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
/** @description Not Found */
|
||||
404: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
GetPlayLists: {
|
||||
parameters: {
|
||||
query?: {
|
||||
|
||||
@@ -1,3 +1,12 @@
|
||||
export function shuffle<T>(items: T[]): T[] {
|
||||
const copy = [...items];
|
||||
for (let i = copy.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[copy[i], copy[j]] = [copy[j], copy[i]];
|
||||
}
|
||||
return copy;
|
||||
}
|
||||
|
||||
export function appendUnique<T>(
|
||||
current: T[],
|
||||
incoming: T[],
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
<script lang="ts">
|
||||
import Cover from './Cover.svelte';
|
||||
import { gradientForHue, hueFor } from '$lib/theme/color';
|
||||
import { mixItemKey, type MixItem } from '$lib/mixes';
|
||||
|
||||
interface Props {
|
||||
items: MixItem[];
|
||||
hue: number;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
let { items, hue, class: klass = '' }: Props = $props();
|
||||
|
||||
const tiles = $derived(items.slice(0, 4));
|
||||
</script>
|
||||
|
||||
{#if tiles.length >= 4}
|
||||
<div class="grid grid-cols-2 grid-rows-2 overflow-hidden {klass}">
|
||||
{#each tiles as item (mixItemKey(item))}
|
||||
<Cover
|
||||
trackId={item.trackId ?? item.videoId ?? ''}
|
||||
src={item.thumbnailUrl}
|
||||
hue={hueFor(mixItemKey(item))}
|
||||
size="small"
|
||||
class="h-full w-full"
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if tiles.length > 0}
|
||||
<Cover
|
||||
trackId={tiles[0].trackId ?? tiles[0].videoId ?? ''}
|
||||
src={tiles[0].thumbnailUrl}
|
||||
hue={hueFor(mixItemKey(tiles[0]))}
|
||||
size="medium"
|
||||
class={klass}
|
||||
/>
|
||||
{:else}
|
||||
<div class={klass} style="background:{gradientForHue(hue)}"></div>
|
||||
{/if}
|
||||
@@ -0,0 +1,31 @@
|
||||
<script lang="ts">
|
||||
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>
|
||||
|
||||
<a
|
||||
href="/mixes/{mix.id}"
|
||||
class="group animate-enter min-w-0 text-left"
|
||||
style="animation-delay:{index * 45}ms"
|
||||
>
|
||||
<MixArt
|
||||
items={mix.items}
|
||||
hue={hueFor(mix.id)}
|
||||
class="aspect-square w-full rounded-art shadow-art transition group-hover:shadow-art-lg"
|
||||
/>
|
||||
<div class="mt-2.5 truncate text-fg">{mix.title}</div>
|
||||
<div class="mt-0.5 truncate text-sm text-fg-3">
|
||||
{count}
|
||||
{count === 1 ? 'canción' : 'canciones'} · Musify y YouTube Music
|
||||
</div>
|
||||
</a>
|
||||
@@ -9,3 +9,4 @@ export const LIBRARY_PICKER_PAGE_SIZE = 50;
|
||||
export const EXPLORE_ALBUMS_PAGE_SIZE = 18;
|
||||
export const HOME_LATEST_PAGE_SIZE = 12;
|
||||
export const YOUTUBE_FILLER_LIMIT = 27;
|
||||
export const PLAYLIST_PICKER_PAGE_SIZE = 50;
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { components } from '$lib/api/schema';
|
||||
import type { TrackTarget } from '$lib/tracks';
|
||||
|
||||
export type Mix = components['schemas']['MixApplicationResponse'];
|
||||
export type MixItem = components['schemas']['MixItemApplicationResponse'];
|
||||
|
||||
export function mixItemKey(item: MixItem): string {
|
||||
return item.source === 'YouTube' ? `yt:${item.videoId}` : `mf:${item.trackId}`;
|
||||
}
|
||||
|
||||
export function mixItemTarget(item: MixItem): TrackTarget {
|
||||
if (item.source === 'YouTube') {
|
||||
return {
|
||||
kind: 'youtube',
|
||||
song: {
|
||||
videoId: item.videoId ?? '',
|
||||
title: item.title,
|
||||
artist: item.artist ?? '',
|
||||
album: '',
|
||||
durationSeconds: Number(item.durationSeconds),
|
||||
thumbnailUrl: item.thumbnailUrl ?? '',
|
||||
isExplicit: item.isExplicit
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
kind: 'local',
|
||||
track: {
|
||||
id: item.trackId ?? '',
|
||||
title: item.title,
|
||||
artist: item.artist,
|
||||
source: 'Local',
|
||||
isExplicit: item.isExplicit
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -28,23 +28,31 @@ function pickRandomFillerQuery(): string {
|
||||
return YOUTUBE_FILLER_QUERIES[Math.floor(Math.random() * YOUTUBE_FILLER_QUERIES.length)];
|
||||
}
|
||||
|
||||
export function shuffle<T>(items: T[]): T[] {
|
||||
const copy = [...items];
|
||||
for (let i = copy.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[copy[i], copy[j]] = [copy[j], copy[i]];
|
||||
function hash(text: string): number {
|
||||
let h = 0;
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
h = (h * 31 + text.charCodeAt(i)) | 0;
|
||||
}
|
||||
return copy;
|
||||
return Math.abs(h);
|
||||
}
|
||||
|
||||
function pickFillerQueryForUser(userId: string, date: Date): string {
|
||||
const dayKey = date.toISOString().slice(0, 10);
|
||||
const index = hash(`${userId}:${dayKey}`) % YOUTUBE_FILLER_QUERIES.length;
|
||||
return YOUTUBE_FILLER_QUERIES[index];
|
||||
}
|
||||
|
||||
export async function fetchYoutubeFiller(
|
||||
api: ReturnType<typeof createApiClient>,
|
||||
accessToken: string | null | undefined,
|
||||
limit: number,
|
||||
seed?: string
|
||||
seedQuery?: string,
|
||||
userId?: string
|
||||
): Promise<YoutubeFiller> {
|
||||
if (!accessToken) return { query: '', items: [], continuationToken: '' };
|
||||
const query = seed?.trim() || pickRandomFillerQuery();
|
||||
const query =
|
||||
seedQuery?.trim() ||
|
||||
(userId ? pickFillerQueryForUser(userId, new Date()) : pickRandomFillerQuery());
|
||||
const { data } = await api.GET('/youtube/search', { params: { query: { query } } });
|
||||
return {
|
||||
query,
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { PageServerLoad, Actions } from './$types';
|
||||
import { createApiClient, requireUser, unwrapOrError } from '$lib/server/api';
|
||||
import { addTrackAction, addYouTubeToPlaylistAction } from '$lib/server/playlistActions';
|
||||
import { PLAYLIST_PICKER_PAGE_SIZE } from '$lib/config';
|
||||
|
||||
export const load: PageServerLoad = async ({ params, locals, url, fetch }) => {
|
||||
const user = requireUser(locals, url);
|
||||
const api = createApiClient({ fetch, accessToken: locals.accessToken ?? undefined });
|
||||
|
||||
const [mixRes, playlistsRes] = await Promise.all([
|
||||
api.GET('/mixes/{id}', { params: { path: { id: params.id } } }),
|
||||
api.GET('/playlists/users/{userId}', {
|
||||
params: {
|
||||
path: { userId: user.sub },
|
||||
query: { pageNumber: 1, pageSize: PLAYLIST_PICKER_PAGE_SIZE }
|
||||
}
|
||||
})
|
||||
]);
|
||||
|
||||
const mix = unwrapOrError(mixRes, 'Mezcla no encontrada.', 404);
|
||||
|
||||
return { mix, playlists: playlistsRes.data?.items ?? [] };
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
addTrack: addTrackAction,
|
||||
addYouTubeToPlaylist: addYouTubeToPlaylistAction
|
||||
};
|
||||
@@ -0,0 +1,189 @@
|
||||
<script lang="ts">
|
||||
import ArrowLeft from '@lucide/svelte/icons/arrow-left';
|
||||
import Sparkles from '@lucide/svelte/icons/sparkles';
|
||||
import Play from '@lucide/svelte/icons/play';
|
||||
import Pause from '@lucide/svelte/icons/pause';
|
||||
import Shuffle from '@lucide/svelte/icons/shuffle';
|
||||
import { player } from '$lib/player/player.svelte';
|
||||
import { hueFor } from '$lib/theme/color';
|
||||
import { fmtTime } from '$lib/format';
|
||||
import { shuffle } from '$lib/collections';
|
||||
import Page from '$lib/components/ui/Page.svelte';
|
||||
import Button from '$lib/components/ui/Button.svelte';
|
||||
import Alert from '$lib/components/ui/Alert.svelte';
|
||||
import Badge from '$lib/components/ui/Badge.svelte';
|
||||
import MixArt from '$lib/components/ui/MixArt.svelte';
|
||||
import TrackTable from '$lib/components/ui/TrackTable.svelte';
|
||||
import TrackRow from '$lib/components/ui/TrackRow.svelte';
|
||||
import TrackMeta from '$lib/components/ui/TrackMeta.svelte';
|
||||
import TrackIndexCell from '$lib/components/ui/TrackIndexCell.svelte';
|
||||
import TrackTitleCell from '$lib/components/ui/TrackTitleCell.svelte';
|
||||
import TrackContextMenu, {
|
||||
contextMenuStateFor,
|
||||
type ContextMenuState
|
||||
} from '$lib/components/TrackContextMenu.svelte';
|
||||
import { mixItemKey, mixItemTarget } from '$lib/mixes';
|
||||
import {
|
||||
isTargetCurrent,
|
||||
queueItemForTarget,
|
||||
targetArtist,
|
||||
targetCoverSrc,
|
||||
targetId,
|
||||
targetTitle
|
||||
} from '$lib/tracks';
|
||||
|
||||
let { data, form } = $props();
|
||||
|
||||
const mix = $derived(data.mix);
|
||||
const items = $derived(mix.items);
|
||||
const targets = $derived(items.map(mixItemTarget));
|
||||
const queue = $derived(targets.map(queueItemForTarget));
|
||||
|
||||
const totalSeconds = $derived(
|
||||
items.reduce((total, item) => total + Number(item.durationSeconds), 0)
|
||||
);
|
||||
const fromYouTube = $derived(items.filter((item) => item.source === 'YouTube').length);
|
||||
const fromMusify = $derived(items.length - fromYouTube);
|
||||
const isCurrentQueue = $derived(targets.some(isTargetCurrent));
|
||||
|
||||
let contextMenu = $state<ContextMenuState | null>(null);
|
||||
|
||||
function playFrom(index: number) {
|
||||
player.playOrToggle(queue, index);
|
||||
}
|
||||
|
||||
function playAll() {
|
||||
if (queue.length === 0) return;
|
||||
if (isCurrentQueue) player.toggle();
|
||||
else player.playQueue(queue, 0);
|
||||
}
|
||||
|
||||
function playShuffled() {
|
||||
if (queue.length === 0) return;
|
||||
player.playQueue(shuffle(queue), 0);
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{mix.title} · Musify</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="Mezcla {mix.title}, hecha para ti con Musify y YouTube Music."
|
||||
/>
|
||||
</svelte:head>
|
||||
|
||||
<Page>
|
||||
<a
|
||||
href="/"
|
||||
class="group inline-flex items-center text-fg-3 transition-colors hover:text-accent-soft"
|
||||
>
|
||||
<span class="grid h-8 w-8 place-items-center transition-colors group-hover:text-accent-soft">
|
||||
<ArrowLeft class="h-4 w-4" />
|
||||
</span>
|
||||
Volver al inicio
|
||||
</a>
|
||||
|
||||
<div class="mt-5 flex flex-col gap-5 sm:mt-6 sm:flex-row sm:items-end sm:gap-6">
|
||||
<MixArt
|
||||
{items}
|
||||
hue={hueFor(mix.id)}
|
||||
class="h-36 w-36 shrink-0 rounded-art-lg shadow-art-lg sm:h-44 sm:w-44"
|
||||
/>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="inline-flex items-center gap-1.5 text-sm tracking-[0.14em] text-fg-3 uppercase">
|
||||
<Sparkles class="h-3.5 w-3.5 text-accent" />
|
||||
Hecha para ti
|
||||
</p>
|
||||
<h1
|
||||
class="mt-1.5 text-3xl font-bold tracking-tight break-words text-fg sm:text-5xl md:text-7xl"
|
||||
>
|
||||
{mix.title}
|
||||
</h1>
|
||||
{#if mix.subtitle}
|
||||
<p class="mt-3 text-base text-fg-2">{mix.subtitle}</p>
|
||||
{/if}
|
||||
<p class="mt-1 text-sm text-muted">
|
||||
{items.length}
|
||||
{items.length === 1 ? 'canción' : 'canciones'} · {fmtTime(totalSeconds)}
|
||||
</p>
|
||||
<div class="mt-3 flex flex-wrap items-center gap-2">
|
||||
{#if fromMusify > 0}
|
||||
<Badge>{fromMusify} de Musify</Badge>
|
||||
{/if}
|
||||
{#if fromYouTube > 0}
|
||||
<Badge tone="accent">{fromYouTube} de YouTube Music</Badge>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex flex-wrap items-center gap-2.5 sm:mt-7">
|
||||
<Button size="sm" onclick={playAll} disabled={queue.length === 0}>
|
||||
{#if isCurrentQueue && player.playing}
|
||||
<Pause class="h-4 w-4" fill="currentColor" strokeWidth={1.5} />
|
||||
Pausar
|
||||
{:else}
|
||||
<Play class="h-4 w-4" fill="currentColor" strokeWidth={1.5} />
|
||||
Reproducir
|
||||
{/if}
|
||||
</Button>
|
||||
<Button variant="secondary" size="sm" onclick={playShuffled} disabled={queue.length === 0}>
|
||||
<Shuffle class="h-4 w-4" />
|
||||
Aleatorio
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{#if form?.message}
|
||||
<Alert tone="danger" class="mt-4">{form.message}</Alert>
|
||||
{/if}
|
||||
|
||||
<TrackTable columns="32px 1fr 64px" columnsMobile="28px 1fr 52px" class="mt-6 sm:mt-8">
|
||||
{#snippet headers()}
|
||||
<span class="text-center">#</span>
|
||||
<span>Título</span>
|
||||
<span class="hidden text-center sm:block">Duración</span>
|
||||
{/snippet}
|
||||
{#each items as item, i (mixItemKey(item))}
|
||||
<TrackRow oncontextmenu={(e) => (contextMenu = contextMenuStateFor(e, targets[i]))}>
|
||||
<TrackIndexCell
|
||||
index={i}
|
||||
active={isTargetCurrent(targets[i])}
|
||||
playing={player.playing}
|
||||
onToggle={() => playFrom(i)}
|
||||
/>
|
||||
<TrackTitleCell
|
||||
trackId={targetId(targets[i])}
|
||||
title={targetTitle(targets[i])}
|
||||
artist={targetArtist(targets[i])}
|
||||
coverSrc={targetCoverSrc(targets[i])}
|
||||
hue={hueFor(mixItemKey(item))}
|
||||
explicit={item.isExplicit}
|
||||
onClick={() => playFrom(i)}
|
||||
>
|
||||
{#snippet badge()}
|
||||
{#if item.source === 'YouTube'}
|
||||
<Badge tone="accent" class="hidden sm:inline">YouTube</Badge>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</TrackTitleCell>
|
||||
<TrackMeta>{fmtTime(Number(item.durationSeconds))}</TrackMeta>
|
||||
</TrackRow>
|
||||
{/each}
|
||||
</TrackTable>
|
||||
</Page>
|
||||
|
||||
{#if contextMenu}
|
||||
<TrackContextMenu
|
||||
menu={contextMenu}
|
||||
playlists={data.playlists}
|
||||
onClose={() => (contextMenu = null)}
|
||||
onAddToQueue={() => {
|
||||
const menu = contextMenu;
|
||||
if (menu) {
|
||||
const index = targets.findIndex((target) => targetId(target) === targetId(menu));
|
||||
if (index >= 0) player.addToQueue(queue[index]);
|
||||
}
|
||||
contextMenu = null;
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
@@ -8,7 +8,8 @@ import {
|
||||
unwrapOrFail
|
||||
} from '$lib/server/api';
|
||||
import { uploadPresignedImage } from '$lib/server/upload';
|
||||
import { fetchYoutubeFiller, shuffle } from '$lib/server/youtube';
|
||||
import { fetchYoutubeFiller } from '$lib/server/youtube';
|
||||
import { shuffle } from '$lib/collections';
|
||||
import { addTrackAction, addYouTubeToPlaylistAction } from '$lib/server/playlistActions';
|
||||
import type { YouTubeSong } from '$lib/types';
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { PageServerLoad, Actions } from './$types';
|
||||
import { createApiClient, playlistCoverTrackIds, requireUser } from '$lib/server/api';
|
||||
import { fetchYoutubeFiller, shuffle } from '$lib/server/youtube';
|
||||
import { fetchYoutubeFiller } from '$lib/server/youtube';
|
||||
import { shuffle } from '$lib/collections';
|
||||
import { addTrackAction, addYouTubeToPlaylistAction } from '$lib/server/playlistActions';
|
||||
import { HOME_LATEST_PAGE_SIZE, YOUTUBE_FILLER_LIMIT } from '$lib/config';
|
||||
import { pickGreeting } from '$lib/server/greeting';
|
||||
@@ -15,13 +16,15 @@ export const load: PageServerLoad = async ({ locals, url, fetch }) => {
|
||||
const playlistsPromise = api.GET('/playlists/users/{userId}', {
|
||||
params: { path: { userId: user.sub }, query: { pageNumber: 1, pageSize: 12 } }
|
||||
});
|
||||
const mixesPromise = api.GET('/mixes');
|
||||
const recentlyPlayedPromise = api.GET('/users/{id}/listening-history', {
|
||||
params: { path: { id: user.sub } }
|
||||
});
|
||||
|
||||
const [latest, playlists, recentlyPlayed] = await Promise.all([
|
||||
const [latest, playlists, mixes, recentlyPlayed] = await Promise.all([
|
||||
latestPromise,
|
||||
playlistsPromise,
|
||||
mixesPromise,
|
||||
recentlyPlayedPromise
|
||||
]);
|
||||
const playlistItems = playlists.data?.items ?? [];
|
||||
@@ -34,8 +37,15 @@ export const load: PageServerLoad = async ({ locals, url, fetch }) => {
|
||||
greeting: pickGreeting(new Date().getHours()),
|
||||
latest: shuffle((latest.data?.items ?? []).map((track) => ({ kind: 'local' as const, track }))),
|
||||
latestHasNext: Boolean(latest.data?.hasNextPage),
|
||||
youtubeFiller: fetchYoutubeFiller(api, locals.accessToken, YOUTUBE_FILLER_LIMIT),
|
||||
youtubeFiller: fetchYoutubeFiller(
|
||||
api,
|
||||
locals.accessToken,
|
||||
YOUTUBE_FILLER_LIMIT,
|
||||
undefined,
|
||||
user.sub
|
||||
),
|
||||
playlists: playlistItems,
|
||||
mixes: mixes.data ?? [],
|
||||
trackIds,
|
||||
recentlyPlayed: recentlyPlayed.data ?? []
|
||||
};
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
import { hueFor } from '$lib/theme/color';
|
||||
import Cover from '$lib/components/ui/Cover.svelte';
|
||||
import PlaylistCard from '$lib/components/ui/PlaylistCard.svelte';
|
||||
import MixCard from '$lib/components/ui/MixCard.svelte';
|
||||
import NowPlaying from '$lib/components/ui/NowPlaying.svelte';
|
||||
import EmptyState from '$lib/components/ui/EmptyState.svelte';
|
||||
import ExplicitBadge from '$lib/components/ui/ExplicitBadge.svelte';
|
||||
@@ -106,6 +107,7 @@
|
||||
}
|
||||
}
|
||||
const playlists = $derived(data.playlists);
|
||||
const mixes = $derived(data.mixes);
|
||||
const trackIds = $derived(data.trackIds);
|
||||
|
||||
function playLatest(index: number) {
|
||||
@@ -224,6 +226,17 @@
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if mixes.length > 0}
|
||||
<section class="page-x pt-5 pb-3 sm:pt-6">
|
||||
<SectionHeading title="Hechas para ti" />
|
||||
<MediaGrid>
|
||||
{#each mixes as mix, i (mix.id)}
|
||||
<MixCard {mix} index={i} />
|
||||
{/each}
|
||||
</MediaGrid>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if playlists.length > 0}
|
||||
<section class="page-x pt-5 pb-3 sm:pt-6">
|
||||
<SectionHeading title="Mis listas" />
|
||||
|
||||
Reference in New Issue
Block a user