Design Prot Merge #1

Merged
NotS1mp merged 217 commits from design-prot into master 2026-09-21 15:48:16 +00:00
29 changed files with 1742 additions and 406 deletions
Showing only changes of commit 2f98982d83 - Show all commits
@@ -50,15 +50,6 @@ public static class AlbumEndpoints
.Produces(StatusCodes.Status401Unauthorized) .Produces(StatusCodes.Status401Unauthorized)
.Produces(StatusCodes.Status404NotFound); .Produces(StatusCodes.Status404NotFound);
group.MapPost("/youtube/{albumId}", SaveYouTubeAlbum)
.WithName("SaveYouTubeAlbum")
.WithSummary("Save A YouTube Music Album To Your Library With All Of Its Tracks.")
.RequireAuthorization()
.Produces<AlbumApplicationResponse>(StatusCodes.Status201Created)
.Produces(StatusCodes.Status401Unauthorized)
.Produces(StatusCodes.Status404NotFound)
.Produces(StatusCodes.Status409Conflict);
group.MapPut("/{albumId}", UpdateAlbum) group.MapPut("/{albumId}", UpdateAlbum)
.WithName("UpdateAlbum") .WithName("UpdateAlbum")
.WithSummary("Update An Album.") .WithSummary("Update An Album.")
@@ -152,19 +143,6 @@ public static class AlbumEndpoints
return result.ToCreatedResult(album => $"/albums/{album.Id}"); return result.ToCreatedResult(album => $"/albums/{album.Id}");
} }
private static async Task<IResult> SaveYouTubeAlbum(
IMediator mediator,
CurrentUser currentUser,
string albumId,
CancellationToken cancellationToken)
{
var result = await mediator.Send(
new SaveYouTubeAlbumCommand(currentUser.RequiredId, albumId),
cancellationToken);
return result.ToCreatedResult(album => $"/albums/{album.Id}");
}
private static async Task<IResult> UpdateAlbum( private static async Task<IResult> UpdateAlbum(
IMediator mediator, IMediator mediator,
CurrentUser currentUser, CurrentUser currentUser,
@@ -1,112 +0,0 @@
using ErrorOr;
using Mediator;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Musify.Application.Albums.Responses;
using Musify.Application.Contracts;
using Musify.Application.Services;
using Musify.Domain.Entities;
namespace Musify.Application.Albums
{
public record SaveYouTubeAlbumCommand(long UserId, string AlbumId)
: ICommand<ErrorOr<AlbumApplicationResponse>>;
public class SaveYouTubeAlbumCommandHandler(
IDatabase database,
IYouTubeMusicService youTubeMusicService,
YouTubeTrackProvisioner provisioner,
ILogger<SaveYouTubeAlbumCommandHandler> logger)
: ICommandHandler<SaveYouTubeAlbumCommand, ErrorOr<AlbumApplicationResponse>>
{
public async ValueTask<ErrorOr<AlbumApplicationResponse>> Handle(SaveYouTubeAlbumCommand request, CancellationToken cancellationToken)
{
var userExists = await database.Users
.AsNoTracking()
.AnyAsync(user => user.Id == request.UserId, cancellationToken);
if (!userExists)
return Error.NotFound(description: $"User {request.UserId} not found");
var alreadySaved = await database.UserAlbums
.AsNoTracking()
.AnyAsync(album => album.OwnerUserId == request.UserId && album.ExternalId == request.AlbumId, cancellationToken);
if (alreadySaved)
{
logger.LogInformation("Album {AlbumId} is already saved for user {UserId}", request.AlbumId, request.UserId);
return Error.Conflict(description: "The album is already in your library.");
}
var detailResult = await youTubeMusicService.GetAlbumAsync(request.AlbumId, cancellationToken);
if (detailResult.IsError)
return detailResult.Errors;
var detail = detailResult.Value;
if (detail.Tracks.Count == 0)
return Error.NotFound(description: "The album has no playable tracks.");
var title = Truncate(detail.Album.Title, 200);
var album = new UserAlbum
{
OwnerUserId = request.UserId,
ExternalId = request.AlbumId,
Title = title,
NormalizedTitle = title.ToUpperInvariant(),
Description = Truncate(detail.Description, 256),
ReleaseYear = detail.Album.ReleaseYear
};
await database.UserAlbums.AddAsync(album, cancellationToken);
var linkedTrackIds = new HashSet<Guid>();
foreach (var track in detail.Tracks)
{
var provisionResult = await provisioner.GetOrCreateAsync(track.VideoId, cancellationToken);
if (provisionResult.IsError)
{
logger.LogWarning("Skipped track {VideoId} of album {AlbumId}: {Error}",
track.VideoId, request.AlbumId, provisionResult.FirstError.Description);
continue;
}
if (!linkedTrackIds.Add(provisionResult.Value.Track.Id))
continue;
await database.AlbumHasTracks.AddAsync(new AlbumHasTrack
{
AlbumId = album.Id,
TrackId = provisionResult.Value.Track.Id,
TrackNumber = track.TrackNumber
}, cancellationToken);
}
try
{
await database.SaveChangesAsync(cancellationToken);
}
catch (Exception exception)
{
logger.LogError(exception, "Failed to save YouTube album {AlbumId} for user {UserId}", request.AlbumId, request.UserId);
return Error.Failure(description: $"Failed to save album {request.AlbumId}");
}
var albumTracks = database.AlbumHasTracks
.AsNoTracking()
.Where(albumTrack => albumTrack.AlbumId == album.Id);
var trackCount = await albumTracks.CountAsync(cancellationToken);
var coverTrackIds = await albumTracks
.OrderBy(albumTrack => albumTrack.TrackNumber)
.Take(AlbumApplicationResponse.CoverTrackCount)
.Select(albumTrack => albumTrack.TrackId)
.ToListAsync(cancellationToken);
logger.LogInformation("Saved YouTube album {AlbumId} as {AlbumGuid} with {TrackCount} tracks",
request.AlbumId, album.Id, trackCount);
return AlbumApplicationResponse.FromEntity(album, trackCount, coverTrackIds);
}
private static string Truncate(string value, int maxLength) =>
value.Length <= maxLength ? value : value[..maxLength];
}
}
@@ -9,9 +9,6 @@ namespace Musify.Domain.Entities
[Required] [Required]
public required long OwnerUserId { get; set; } public required long OwnerUserId { get; set; }
[MaxLength(64)]
public string? ExternalId { get; set; }
[ForeignKey(nameof(OwnerUserId))] [ForeignKey(nameof(OwnerUserId))]
public User Owner { get; set; } public User Owner { get; set; }
} }
@@ -111,10 +111,6 @@ namespace Musify.Infrastructure.Persistence
modelBuilder.Entity<UserAlbum>() modelBuilder.Entity<UserAlbum>()
.ToTable("UserAlbums"); .ToTable("UserAlbums");
modelBuilder.Entity<UserAlbum>()
.HasIndex(album => new { album.OwnerUserId, album.ExternalId })
.IsUnique();
modelBuilder.Entity<UserAlbum>() modelBuilder.Entity<UserAlbum>()
.HasOne(album => album.Owner) .HasOne(album => album.Owner)
.WithMany() .WithMany()
@@ -0,0 +1,48 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Musify.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class RemoveUserAlbumExternalId : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_UserAlbums_OwnerUserId_ExternalId",
table: "UserAlbums");
migrationBuilder.DropColumn(
name: "ExternalId",
table: "UserAlbums");
migrationBuilder.CreateIndex(
name: "IX_UserAlbums_OwnerUserId",
table: "UserAlbums",
column: "OwnerUserId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_UserAlbums_OwnerUserId",
table: "UserAlbums");
migrationBuilder.AddColumn<string>(
name: "ExternalId",
table: "UserAlbums",
type: "character varying(64)",
maxLength: 64,
nullable: true);
migrationBuilder.CreateIndex(
name: "IX_UserAlbums_OwnerUserId_ExternalId",
table: "UserAlbums",
columns: new[] { "OwnerUserId", "ExternalId" },
unique: true);
}
}
}
@@ -728,15 +728,10 @@ namespace Musify.Infrastructure.Persistence.Migrations
{ {
b.HasBaseType("Musify.Domain.Entities.Album"); b.HasBaseType("Musify.Domain.Entities.Album");
b.Property<string>("ExternalId")
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<long>("OwnerUserId") b.Property<long>("OwnerUserId")
.HasColumnType("bigint"); .HasColumnType("bigint");
b.HasIndex("OwnerUserId", "ExternalId") b.HasIndex("OwnerUserId");
.IsUnique();
b.ToTable("UserAlbums", (string)null); b.ToTable("UserAlbums", (string)null);
}); });
@@ -162,7 +162,7 @@ namespace Musify.Infrastructure.Services
return new YouTubeAlbumDetail( return new YouTubeAlbumDetail(
album, album,
info.Description ?? string.Empty, StripWikipediaAttribution(info.Description),
(int)info.Duration.TotalSeconds, (int)info.Duration.TotalSeconds,
tracks); tracks);
} }
@@ -173,6 +173,16 @@ namespace Musify.Infrastructure.Services
} }
} }
private static string StripWikipediaAttribution(string? description)
{
if (string.IsNullOrEmpty(description))
return string.Empty;
var markerIndex = description.IndexOf("From Wikipedia", StringComparison.OrdinalIgnoreCase);
var cleaned = markerIndex >= 0 ? description[..markerIndex] : description;
return cleaned.TrimEnd();
}
private YouTubeAlbumResult ToAlbumResult(AlbumSearchResult album) => private YouTubeAlbumResult ToAlbumResult(AlbumSearchResult album) =>
new( new(
album.Id, album.Id,
@@ -16,6 +16,9 @@
"OpenTelemetry": { "OpenTelemetry": {
"OtlpEndpoint": "http://localhost:4317" "OtlpEndpoint": "http://localhost:4317"
}, },
"YouTube": {
"GeographicalLocation": "ES"
},
"InfrastructureStorage": { "InfrastructureStorage": {
"Address": "http://localhost:8333", "Address": "http://localhost:8333",
"AccessKey": "admin_access_key", "AccessKey": "admin_access_key",
-60
View File
@@ -73,23 +73,6 @@ export interface paths {
patch?: never; patch?: never;
trace?: never; trace?: never;
}; };
"/albums/youtube/{albumId}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** Save A YouTube Music Album To Your Library With All Of Its Tracks. */
post: operations["SaveYouTubeAlbum"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/albums/{albumId}": { "/albums/{albumId}": {
parameters: { parameters: {
query?: never; query?: never;
@@ -1042,49 +1025,6 @@ export interface operations {
}; };
}; };
}; };
SaveYouTubeAlbum: {
parameters: {
query?: never;
header?: never;
path: {
albumId: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Created */
201: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["AlbumApplicationResponse"];
};
};
/** @description Unauthorized */
401: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description Not Found */
404: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description Conflict */
409: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
UpdateAlbum: { UpdateAlbum: {
parameters: { parameters: {
query?: never; query?: never;
@@ -0,0 +1,112 @@
<script lang="ts" module>
const MENU_WIDTH = 240;
const SUBMENU_WIDTH = 256;
export type AlbumMenuState = { x: number; y: number; openLeft: boolean } & (
{ kind: 'local'; albumId: string } | { kind: 'youtube'; albumId: string }
);
export function albumContextMenuStateFor(
event: MouseEvent,
kind: 'local' | 'youtube',
albumId: string
): AlbumMenuState {
event.preventDefault();
return {
kind,
albumId,
x: Math.max(8, Math.min(event.clientX, window.innerWidth - MENU_WIDTH - 8)),
y: Math.max(8, Math.min(event.clientY, window.innerHeight - 60)),
openLeft: event.clientX + MENU_WIDTH + SUBMENU_WIDTH + 16 > window.innerWidth
};
}
</script>
<script lang="ts">
import { enhance } from '$app/forms';
import ChevronRight from '@lucide/svelte/icons/chevron-right';
import PlaylistArt from '$lib/components/ui/PlaylistArt.svelte';
import { hueFor } from '$lib/theme/color';
interface Props {
menu: AlbumMenuState;
playlists: { id: string; name: string }[];
onClose: () => void;
}
let { menu, playlists, onClose }: Props = $props();
function onWindowKeydown(event: KeyboardEvent) {
if (event.key === 'Escape') onClose();
}
</script>
<svelte:window onkeydown={onWindowKeydown} />
<div
class="fixed inset-0 z-30"
role="presentation"
onclick={onClose}
oncontextmenu={(e) => {
e.preventDefault();
onClose();
}}
></div>
<div
class="fixed z-40 w-60 rounded-panel border border-line bg-elevated p-1.5 shadow-menu"
style="left:{menu.x}px; top:{menu.y}px;"
>
{#if playlists.length > 0}
<div class="group/addmenu relative">
<div
class="flex items-center gap-3 rounded-control px-2 py-2.5 text-sm text-fg-2 transition group-hover/addmenu:bg-hover"
>
<span class="flex-1 cursor-default text-base">Añadir álbum a una playlist</span>
<ChevronRight class="h-4 w-4 shrink-0 text-fg-3" />
</div>
<div
class="invisible absolute top-0 z-50 opacity-0 transition group-hover/addmenu:visible group-hover/addmenu:opacity-100 max-sm:top-full max-sm:right-0 max-sm:left-0 max-sm:px-0 max-sm:pt-1.5 {menu.openLeft
? 'right-full pr-1.5'
: 'left-full pl-1.5'}"
>
<div
class="w-64 overflow-hidden rounded-panel border border-line bg-elevated p-2 shadow-menu max-sm:w-full"
>
<div class="max-h-80 overflow-y-auto">
{#each playlists as playlist (playlist.id)}
<form
method="POST"
action={menu.kind === 'youtube'
? '?/addYoutubeAlbumToPlaylist'
: '?/addAlbumToPlaylist'}
use:enhance={() =>
({ update }) => {
onClose();
return update({ reset: false, invalidateAll: false });
}}
>
<input type="hidden" name="playlistId" value={playlist.id} />
<input type="hidden" name="albumId" value={menu.albumId} />
<button
type="submit"
class="flex w-full items-center gap-3 rounded-control px-2 py-2.5 text-left text-base text-fg transition hover:bg-hover"
>
<PlaylistArt
playlistId={playlist.id}
trackIds={[]}
hue={hueFor(playlist.id)}
size="small"
class="h-9 w-9 shrink-0 rounded-control"
/>
<span class="truncate text-fg-2">{playlist.name}</span>
</button>
</form>
{/each}
</div>
</div>
</div>
</div>
{:else}
<p class="px-2 py-2.5 text-sm text-fg-3">No tienes playlists todavía.</p>
{/if}
</div>
+2 -2
View File
@@ -81,7 +81,7 @@
class="sticky top-0 hidden h-screen shrink-0 flex-col gap-7 border-r border-line px-4.5 py-6.5 md:flex" class="sticky top-0 hidden h-screen shrink-0 flex-col gap-7 border-r border-line px-4.5 py-6.5 md:flex"
style="width:var(--mf-sidebar-w)" style="width:var(--mf-sidebar-w)"
> >
<a href="/" class="px-3 text-xl font-extrabold tracking-wide">Musify</a> <a href="/" class="px-3 text-xl font-medium tracking-wide">Musify</a>
<div class="relative flex flex-col gap-7" bind:this={navBox}> <div class="relative flex flex-col gap-7" bind:this={navBox}>
<div <div
@@ -132,7 +132,7 @@
{user.name.charAt(0)} {user.name.charAt(0)}
</span> </span>
{/if} {/if}
<span class="truncate leading-[1.35]">{user.name}</span> <span class="truncate leading-[1.35] text-fg-3">{user.name}</span>
</button> </button>
</div> </div>
</aside> </aside>
@@ -11,6 +11,7 @@
typeLabel?: string; typeLabel?: string;
coverSrc?: string | null; coverSrc?: string | null;
coverSize?: 'small' | 'medium' | 'large'; coverSize?: 'small' | 'medium' | 'large';
coverShape?: 'square' | 'round';
hue: number; hue: number;
explicit?: boolean; explicit?: boolean;
active: boolean; active: boolean;
@@ -28,6 +29,7 @@
typeLabel, typeLabel,
coverSrc, coverSrc,
coverSize = 'large', coverSize = 'large',
coverShape = 'square',
hue, hue,
explicit = false, explicit = false,
active, active,
@@ -58,7 +60,9 @@
{hue} {hue}
size={coverSize} size={coverSize}
alt={title} alt={title}
class="aspect-square w-full rounded-art shadow-art" class="aspect-square w-full shadow-art {coverShape === 'round'
? 'rounded-art-round'
: 'rounded-art'}"
> >
<PlayPauseOverlay {active} {playing} /> <PlayPauseOverlay {active} {playing} />
</Cover> </Cover>
@@ -3,8 +3,19 @@
import TransportControls from './TransportControls.svelte'; import TransportControls from './TransportControls.svelte';
import SeekBar from './SeekBar.svelte'; import SeekBar from './SeekBar.svelte';
import VolumeControl from './VolumeControl.svelte'; import VolumeControl from './VolumeControl.svelte';
import Alert from '$lib/components/ui/Alert.svelte';
import { player } from '$lib/player/player.svelte';
</script> </script>
{#if player.error}
<div
class="fixed inset-x-0 z-40 page-x py-2 md:left-[var(--mf-sidebar-w)] md:z-50"
style="bottom:calc(var(--mf-nav-h) + var(--mf-safe-b) + var(--mf-player-h))"
>
<Alert tone="danger">{player.error}</Alert>
</div>
{/if}
<div <div
class="animate-enter fixed right-0 left-0 z-40 h-[var(--mf-player-h)] border-t border-line bg-bg md:left-[var(--mf-sidebar-w)] md:z-50" class="animate-enter fixed right-0 left-0 z-40 h-[var(--mf-player-h)] border-t border-line bg-bg md:left-[var(--mf-sidebar-w)] md:z-50"
style="bottom:calc(var(--mf-nav-h) + var(--mf-safe-b))" style="bottom:calc(var(--mf-nav-h) + var(--mf-safe-b))"
@@ -14,6 +14,7 @@
trackIds?: (string | number)[]; trackIds?: (string | number)[];
coverSrc?: string | null; coverSrc?: string | null;
index?: number; index?: number;
onContextMenu?: (event: MouseEvent) => void;
} }
let { let {
@@ -26,7 +27,8 @@
trackCount, trackCount,
trackIds = [], trackIds = [],
coverSrc, coverSrc,
index = 0 index = 0,
onContextMenu
}: Props = $props(); }: Props = $props();
const meta = $derived( const meta = $derived(
@@ -47,6 +49,7 @@
href={href ?? `/albums/${id}`} href={href ?? `/albums/${id}`}
class="group animate-enter min-w-0 text-left" class="group animate-enter min-w-0 text-left"
style="animation-delay:{index * 45}ms" style="animation-delay:{index * 45}ms"
oncontextmenu={onContextMenu}
> >
{#if coverSrc} {#if coverSrc}
<Cover <Cover
@@ -11,6 +11,7 @@
alt?: string; alt?: string;
class?: string; class?: string;
children?: Snippet; children?: Snippet;
oncontextmenu?: (event: MouseEvent) => void;
} }
let { let {
@@ -20,7 +21,8 @@
src, src,
alt = '', alt = '',
class: klass = '', class: klass = '',
children children,
oncontextmenu
}: Props = $props(); }: Props = $props();
let failed = $state(false); let failed = $state(false);
@@ -40,6 +42,8 @@
<div <div
class="relative overflow-hidden {klass}" class="relative overflow-hidden {klass}"
style={loaded && !failed ? undefined : `background:${gradientForHue(hue)}`} style={loaded && !failed ? undefined : `background:${gradientForHue(hue)}`}
role="presentation"
{oncontextmenu}
> >
{#if !failed} {#if !failed}
<img <img
@@ -37,7 +37,7 @@
<div class="relative w-full"> <div class="relative w-full">
{#if Icon} {#if Icon}
<span class="pointer-events-none absolute inset-y-0 left-4 grid place-items-center text-muted"> <span class="pointer-events-none absolute inset-y-0 left-4 grid place-items-center text-muted">
<Icon class="h-4 w-4" /> <Icon class="h-5 w-5" />
</span> </span>
{/if} {/if}
<input <input
@@ -52,7 +52,7 @@
{autocomplete} {autocomplete}
{oninput} {oninput}
bind:value bind:value
class="w-full rounded-control border border-line bg-surface py-3 text-base text-fg transition placeholder:text-muted focus:border-accent/50 focus:outline-none {Icon class="w-full rounded-control bg-surface py-3 text-base text-fg transition placeholder:text-muted focus:border-accent/50 focus:outline-none {Icon
? 'pr-4 pl-11' ? 'pr-4 pl-11'
: 'px-4'} {klass}" : 'px-4'} {klass}"
/> />
@@ -1,10 +1,20 @@
<script lang="ts"> <script lang="ts">
import type { Snippet } from 'svelte';
interface Props { interface Props {
title: string; title: string;
class?: string; class?: string;
actions?: Snippet;
} }
let { title, class: klass = '' }: Props = $props(); let { title, class: klass = '', actions }: Props = $props();
</script> </script>
<h2 class="mb-4 text-xl font-bold text-fg sm:mb-6 sm:text-2xl {klass}">{title}</h2> {#if actions}
<div class="mb-4 flex items-center justify-between gap-4 sm:mb-6 {klass}">
<h2 class="text-xl font-bold text-fg sm:text-2xl">{title}</h2>
{@render actions()}
</div>
{:else}
<h2 class="mb-4 text-xl font-bold text-fg sm:mb-6 sm:text-2xl {klass}">{title}</h2>
{/if}
+3 -1
View File
@@ -6,7 +6,9 @@ export const ALBUMS_PAGE_SIZE = 50;
export const ALBUM_TRACKS_PAGE_SIZE = 200; 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 = 18; export const EXPLORE_ALBUMS_PAGE_SIZE = 10;
export const EXPLORE_ALBUM_SLOTS = 10;
export const HOME_LATEST_PAGE_SIZE = 12; export const HOME_LATEST_PAGE_SIZE = 12;
export const HOME_ALBUMS_PAGE_SIZE = 12;
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;
+15 -3
View File
@@ -75,6 +75,15 @@ export function toQueueItems(tracks: ApiTrackLike[]): QueueItem[] {
}); });
} }
async function readErrorMessage(res: Response): Promise<string> {
try {
const body = (await res.json()) as { message?: string };
return body?.message || `Error ${res.status}`;
} catch {
return `Error ${res.status}`;
}
}
const RECENT_LIMIT = 15; const RECENT_LIMIT = 15;
const EMPTY: PlayerTrack = { const EMPTY: PlayerTrack = {
@@ -286,13 +295,16 @@ class PlayerState {
if (token !== this.#loadToken) return; if (token !== this.#loadToken) return;
this.loading = false; this.loading = false;
this.playing = false; this.playing = false;
this.error = 'No se pudo reproducir la pista.'; this.error =
exception instanceof Error && exception.message
? exception.message
: 'No se pudo reproducir la pista.';
} }
} }
async #resolveLocalSrc(id: string): Promise<string> { async #resolveLocalSrc(id: string): Promise<string> {
const res = await fetch(`/api/tracks/${id}/stream`); const res = await fetch(`/api/tracks/${id}/stream`);
if (!res.ok) throw new Error(String(res.status)); if (!res.ok) throw new Error(await readErrorMessage(res));
const { manifestUrl, ticket } = (await res.json()) as { const { manifestUrl, ticket } = (await res.json()) as {
manifestUrl: string; manifestUrl: string;
ticket: string; ticket: string;
@@ -302,7 +314,7 @@ class PlayerState {
async #resolveYouTubeSrc(videoId: string): Promise<string> { async #resolveYouTubeSrc(videoId: string): Promise<string> {
const res = await fetch(`/api/youtube/tracks/${videoId}/stream`); const res = await fetch(`/api/youtube/tracks/${videoId}/stream`);
if (!res.ok) throw new Error(String(res.status)); if (!res.ok) throw new Error(await readErrorMessage(res));
const { mode, streamUrl, ticket } = (await res.json()) as { const { mode, streamUrl, ticket } = (await res.json()) as {
mode: string; mode: string;
streamUrl: string; streamUrl: string;
@@ -1,5 +1,6 @@
import { fail, type RequestEvent } from '@sveltejs/kit'; import { fail, type RequestEvent } from '@sveltejs/kit';
import { createApiClient, requireAccessTokenAction } from '$lib/server/api'; import { createApiClient, requireAccessTokenAction } from '$lib/server/api';
import { ALBUM_TRACKS_PAGE_SIZE } from '$lib/config';
export async function addTrackAction({ request, params, locals, fetch }: RequestEvent) { export async function addTrackAction({ request, params, locals, fetch }: RequestEvent) {
const accessToken = requireAccessTokenAction(locals); const accessToken = requireAccessTokenAction(locals);
@@ -59,3 +60,79 @@ export async function addYouTubeToPlaylistAction({ request, params, locals, fetc
return { added: true, videoId, playlistId }; return { added: true, videoId, playlistId };
} }
export async function addAlbumToPlaylistAction({ request, params, locals, fetch }: RequestEvent) {
const accessToken = requireAccessTokenAction(locals);
if (typeof accessToken !== 'string') return accessToken;
const form = await request.formData();
const playlistId = String(form.get('playlistId') ?? '') || String(params.id ?? '');
const albumId = String(form.get('albumId') ?? '');
if (!playlistId || !albumId) {
return fail(400, { message: 'Faltan datos del álbum.' });
}
const api = createApiClient({ fetch, accessToken });
const { data: tracks, error: tracksErr } = await api.GET('/albums/{albumId}/tracks', {
params: { path: { albumId }, query: { pageNumber: 1, pageSize: ALBUM_TRACKS_PAGE_SIZE } }
});
if (tracksErr || !tracks) {
return fail(502, { message: 'No se pudo cargar el álbum.', albumId });
}
let added = 0;
for (const track of tracks.items) {
const { error: err } = await api.POST('/playlists/{playlistId}/tracks/{trackId}', {
params: { path: { playlistId, trackId: String(track.id) } }
});
if (!err) added++;
}
return { addedAlbum: true, albumId, playlistId, added };
}
export async function addYoutubeAlbumToPlaylistAction({
request,
params,
locals,
fetch
}: RequestEvent) {
const accessToken = requireAccessTokenAction(locals);
if (typeof accessToken !== 'string') return accessToken;
const form = await request.formData();
const playlistId = String(form.get('playlistId') ?? '') || String(params.id ?? '');
const albumId = String(form.get('albumId') ?? '');
if (!playlistId || !albumId) {
return fail(400, { message: 'Faltan datos del álbum.' });
}
const api = createApiClient({ fetch, accessToken });
const { data: detail, error: detailErr } = await api.GET('/youtube/albums/{albumId}', {
params: { path: { albumId } }
});
if (detailErr || !detail) {
return fail(502, { message: 'No se pudo cargar el álbum de YouTube.', albumId });
}
let added = 0;
for (const track of detail.tracks) {
const { error: err } = await api.POST('/playlists/{playlistId}/youtube-tracks', {
params: { path: { playlistId } },
body: {
videoId: track.videoId,
title: track.title,
artist: detail.album.artist,
durationSeconds: Number(track.durationSeconds),
thumbnailUrl: detail.album.thumbnailUrl
}
});
if (!err) added++;
}
return { addedAlbum: true, albumId, playlistId, added };
}
@@ -1,12 +1,9 @@
import type { PageServerLoad, Actions } from './$types'; import type { PageServerLoad, Actions } from './$types';
import { fail, redirect } from '@sveltejs/kit'; import { createApiClient, requireUser, unwrapOrError } from '$lib/server/api';
import { import {
createApiClient, addYouTubeToPlaylistAction,
requireAccessTokenAction, addYoutubeAlbumToPlaylistAction
requireUser, } from '$lib/server/playlistActions';
unwrapOrError
} from '$lib/server/api';
import { addYouTubeToPlaylistAction } from '$lib/server/playlistActions';
export const load: PageServerLoad = async ({ params, locals, url, fetch }) => { export const load: PageServerLoad = async ({ params, locals, url, fetch }) => {
const user = requireUser(locals, url); const user = requireUser(locals, url);
@@ -26,27 +23,5 @@ export const load: PageServerLoad = async ({ params, locals, url, fetch }) => {
export const actions: Actions = { export const actions: Actions = {
addYouTubeToPlaylist: addYouTubeToPlaylistAction, addYouTubeToPlaylist: addYouTubeToPlaylistAction,
addYoutubeAlbumToPlaylist: addYoutubeAlbumToPlaylistAction
save: async ({ params, locals, fetch }) => {
const accessToken = requireAccessTokenAction(locals);
if (typeof accessToken !== 'string') return accessToken;
const api = createApiClient({ fetch, accessToken });
const {
data,
error: err,
response
} = await api.POST('/albums/youtube/{albumId}', {
params: { path: { albumId: params.albumId } }
});
if (response?.status === 409) {
return fail(409, { message: 'Ese álbum ya está en tu biblioteca.' });
}
if (err || !data) {
return fail(502, { message: 'No se pudo guardar el álbum.' });
}
redirect(303, `/albums/${data.id}`);
}
}; };
@@ -1,9 +1,7 @@
<script lang="ts"> <script lang="ts">
import { enhance } from '$app/forms';
import ArrowLeft from '@lucide/svelte/icons/arrow-left'; import ArrowLeft from '@lucide/svelte/icons/arrow-left';
import Play from '@lucide/svelte/icons/play'; import Play from '@lucide/svelte/icons/play';
import Pause from '@lucide/svelte/icons/pause'; import Pause from '@lucide/svelte/icons/pause';
import Plus from '@lucide/svelte/icons/plus';
import { player, type QueueItem } from '$lib/player/player.svelte'; import { player, type QueueItem } from '$lib/player/player.svelte';
import { hueFor } from '$lib/theme/color'; import { hueFor } from '$lib/theme/color';
import { fmtTime } from '$lib/format'; import { fmtTime } from '$lib/format';
@@ -20,6 +18,10 @@
contextMenuStateFor, contextMenuStateFor,
type ContextMenuState type ContextMenuState
} from '$lib/components/TrackContextMenu.svelte'; } from '$lib/components/TrackContextMenu.svelte';
import AlbumContextMenu, {
albumContextMenuStateFor,
type AlbumMenuState
} from '$lib/components/AlbumContextMenu.svelte';
import type { TrackTarget } from '$lib/tracks'; import type { TrackTarget } from '$lib/tracks';
let { data, form } = $props(); let { data, form } = $props();
@@ -28,7 +30,7 @@
const tracks = $derived(data.album.tracks); const tracks = $derived(data.album.tracks);
let contextMenu = $state<ContextMenuState | null>(null); let contextMenu = $state<ContextMenuState | null>(null);
let saving = $state(false); let albumMenu = $state<AlbumMenuState | null>(null);
const queue = $derived<QueueItem[]>( const queue = $derived<QueueItem[]>(
tracks.map((track) => ({ tracks.map((track) => ({
@@ -94,6 +96,7 @@
size="large" size="large"
alt={album.title} alt={album.title}
class="h-36 w-36 shrink-0 rounded-art-lg shadow-art-lg sm:h-44 sm:w-44" class="h-36 w-36 shrink-0 rounded-art-lg shadow-art-lg sm:h-44 sm:w-44"
oncontextmenu={(e) => (albumMenu = albumContextMenuStateFor(e, 'youtube', album.albumId))}
/> />
<div class="min-w-0 flex-1"> <div class="min-w-0 flex-1">
<p class="text-sm tracking-[0.14em] text-fg-3 uppercase"> <p class="text-sm tracking-[0.14em] text-fg-3 uppercase">
@@ -127,22 +130,6 @@
Reproducir Reproducir
{/if} {/if}
</Button> </Button>
<form
method="POST"
action="?/save"
use:enhance={() => {
saving = true;
return async ({ update }) => {
await update();
saving = false;
};
}}
>
<Button type="submit" size="sm" variant="secondary" loading={saving}>
<Plus class="h-4 w-4" strokeWidth={2} />
Guardar en mi biblioteca
</Button>
</form>
</div> </div>
{#if form?.message} {#if form?.message}
@@ -150,7 +137,7 @@
{/if} {/if}
{#if data.album.description} {#if data.album.description}
<p class="mt-6 max-w-3xl text-sm text-fg-3">{data.album.description}</p> <p class="mt-6 text-sm text-fg-3">{data.album.description}</p>
{/if} {/if}
<TrackTable columns="32px 1fr 64px" columnsMobile="28px 1fr 52px" class="mt-6 sm:mt-8"> <TrackTable columns="32px 1fr 64px" columnsMobile="28px 1fr 52px" class="mt-6 sm:mt-8">
@@ -197,3 +184,11 @@
}} }}
/> />
{/if} {/if}
{#if albumMenu}
<AlbumContextMenu
menu={albumMenu}
playlists={data.playlists}
onClose={() => (albumMenu = null)}
/>
{/if}
@@ -1,7 +1,12 @@
import type { PageServerLoad, Actions } from './$types'; import type { PageServerLoad, Actions } from './$types';
import { createApiClient, requireUser, unwrapOrError } from '$lib/server/api'; import { createApiClient, requireUser, unwrapOrError } from '$lib/server/api';
import { fetchYoutubeFiller } from '$lib/server/youtube'; import { fetchYoutubeFiller } from '$lib/server/youtube';
import { addTrackAction, addYouTubeToPlaylistAction } from '$lib/server/playlistActions'; import {
addAlbumToPlaylistAction,
addTrackAction,
addYouTubeToPlaylistAction,
addYoutubeAlbumToPlaylistAction
} from '$lib/server/playlistActions';
import { import {
EXPLORE_ALBUMS_PAGE_SIZE, EXPLORE_ALBUMS_PAGE_SIZE,
EXPLORE_PAGE_SIZE as PAGE_SIZE, EXPLORE_PAGE_SIZE as PAGE_SIZE,
@@ -55,7 +60,6 @@ export const load: PageServerLoad = async ({ url, locals, fetch }) => {
tracks, tracks,
albums: albumsRes?.data?.items ?? [], albums: albumsRes?.data?.items ?? [],
youtubeAlbums: youtubeAlbumsRes?.data?.items ?? [], youtubeAlbums: youtubeAlbumsRes?.data?.items ?? [],
youtubeAlbumsContinuation: youtubeAlbumsRes?.data?.continuationToken ?? '',
ytResults: searchRes?.data ?? null, ytResults: searchRes?.data ?? null,
ytError: Boolean(searchRes?.error), ytError: Boolean(searchRes?.error),
youtubeFiller: query youtubeFiller: query
@@ -67,5 +71,7 @@ export const load: PageServerLoad = async ({ url, locals, fetch }) => {
export const actions: Actions = { export const actions: Actions = {
addTrack: addTrackAction, addTrack: addTrackAction,
addYouTubeToPlaylist: addYouTubeToPlaylistAction addYouTubeToPlaylist: addYouTubeToPlaylistAction,
addAlbumToPlaylist: addAlbumToPlaylistAction,
addYoutubeAlbumToPlaylist: addYoutubeAlbumToPlaylistAction
}; };
+99 -110
View File
@@ -1,8 +1,6 @@
<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 Disc from '@lucide/svelte/icons/disc-3';
import Users from '@lucide/svelte/icons/users';
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';
@@ -12,12 +10,10 @@
import Alert from '$lib/components/ui/Alert.svelte'; import Alert from '$lib/components/ui/Alert.svelte';
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 Button from '$lib/components/ui/Button.svelte';
import InfiniteScroll from '$lib/components/ui/InfiniteScroll.svelte'; import InfiniteScroll from '$lib/components/ui/InfiniteScroll.svelte';
import ChipGroup, { type ChipOption } from '$lib/components/ui/ChipGroup.svelte';
import AlbumCard from '$lib/components/ui/AlbumCard.svelte'; import AlbumCard from '$lib/components/ui/AlbumCard.svelte';
import SectionHeading from '$lib/components/ui/SectionHeading.svelte'; import SectionHeading from '$lib/components/ui/SectionHeading.svelte';
import { EXPLORE_PAGE_SIZE, SEARCH_DEBOUNCE_MS } from '$lib/config'; import { EXPLORE_ALBUM_SLOTS, 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 TrackTile from '$lib/components/TrackTile.svelte';
@@ -25,6 +21,10 @@
contextMenuStateFor, contextMenuStateFor,
type ContextMenuState type ContextMenuState
} from '$lib/components/TrackContextMenu.svelte'; } from '$lib/components/TrackContextMenu.svelte';
import AlbumContextMenu, {
albumContextMenuStateFor,
type AlbumMenuState
} from '$lib/components/AlbumContextMenu.svelte';
import { appendUnique } from '$lib/collections'; import { appendUnique } from '$lib/collections';
import { import {
isTargetCurrent, isTargetCurrent,
@@ -42,21 +42,11 @@
let searchValue = $state(untrack(() => data.query)); let searchValue = $state(untrack(() => data.query));
let searchTimeout: ReturnType<typeof setTimeout> | undefined; let searchTimeout: ReturnType<typeof setTimeout> | undefined;
const filterOptions: ChipOption[] = [
{ value: 'tracks', label: 'Canciones', icon: Music },
{ value: 'albums', label: 'Álbumes', icon: Disc },
{ value: 'users', label: 'Usuarios', icon: Users, disabled: true, hint: 'Próximamente' }
];
let filters = $state<string[]>(['tracks', 'albums']);
const showTracks = $derived(filters.includes('tracks'));
const showAlbums = $derived(filters.includes('albums'));
let ytItems = $state<YouTubeSong[]>([]); let ytItems = $state<YouTubeSong[]>([]);
let ytContinuation = $state(''); let ytContinuation = $state('');
let ytSearchTerm = $state(''); let ytSearchTerm = $state('');
let contextMenu = $state<ContextMenuState | null>(null); let contextMenu = $state<ContextMenuState | null>(null);
let albumMenu = $state<AlbumMenuState | null>(null);
type LocalTrack = (typeof data.tracks.items)[number]; type LocalTrack = (typeof data.tracks.items)[number];
@@ -67,6 +57,7 @@
$effect(() => { $effect(() => {
contextMenu = null; contextMenu = null;
albumMenu = null;
localItems = data.tracks.items; localItems = data.tracks.items;
localPage = Number(data.tracks.pageNumber); localPage = Number(data.tracks.pageNumber);
localHasNext = Boolean(data.tracks.hasNextPage); localHasNext = Boolean(data.tracks.hasNextPage);
@@ -99,54 +90,29 @@
...ytItems.map((song) => ({ kind: 'youtube' as const, song })) ...ytItems.map((song) => ({ kind: 'youtube' as const, song }))
]); ]);
const hasMore = $derived(showTracks && (localHasNext || ytContinuation !== '')); const hasMore = $derived(localHasNext || ytContinuation !== '');
const albums = $derived(data.albums); const albums = $derived(data.albums);
const youtubeAlbums = $derived(data.youtubeAlbums);
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 };
let youtubeAlbums = $state<YouTubeAlbum[]>([]); const albumEntries = $derived<AlbumEntry[]>(
let ytAlbumsContinuation = $state(''); [
let loadingAlbums = $state(false); ...albums.map((album) => ({ kind: 'local' as const, album })),
...youtubeAlbums.map((album) => ({ kind: 'youtube' as const, album }))
$effect(() => { ].slice(0, EXPLORE_ALBUM_SLOTS)
youtubeAlbums = appendUnique([], data.youtubeAlbums, (album) => album.albumId);
ytAlbumsContinuation = data.youtubeAlbumsContinuation;
});
const hasAlbums = $derived(albums.length > 0 || youtubeAlbums.length > 0);
async function loadMoreAlbums() {
if (loadingAlbums || !ytAlbumsContinuation) return;
loadingAlbums = true;
try {
const params = new URLSearchParams({
query: data.query,
continuation: ytAlbumsContinuation
});
const res = await fetch(`/api/youtube/albums?${params}`);
if (!res.ok) throw new Error(String(res.status));
const next = (await res.json()) as {
items: YouTubeAlbum[];
continuationToken: string;
};
youtubeAlbums = appendUnique(youtubeAlbums, next.items, (album) => album.albumId);
ytAlbumsContinuation = next.continuationToken;
} catch {
ytAlbumsContinuation = '';
} finally {
loadingAlbums = false;
}
}
const nothingFound = $derived(
!!data.query &&
!data.ytError &&
(showTracks ? items.length === 0 : true) &&
(showAlbums ? !hasAlbums : true) &&
filters.length > 0
); );
const hasAlbums = $derived(albumEntries.length > 0);
const fillerCount = $derived(Math.max(0, EXPLORE_ALBUM_SLOTS - albumEntries.length));
const fillerTracks = $derived(items.slice(0, fillerCount));
const remainingTracks = $derived(items.slice(fillerCount));
const nothingFound = $derived(!!data.query && !data.ytError && items.length === 0 && !hasAlbums);
function buildHref(query: string) { function buildHref(query: string) {
return query ? `/explore?q=${encodeURIComponent(query)}` : '/explore'; return query ? `/explore?q=${encodeURIComponent(query)}` : '/explore';
} }
@@ -177,6 +143,18 @@
contextMenu = null; contextMenu = null;
} }
function isForeignAlbum(album: LocalAlbum) {
return String(album.ownerUserId) !== data.user?.sub;
}
function openAlbumMenu(event: MouseEvent, kind: 'local' | 'youtube', albumId: string) {
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));
@@ -235,26 +213,10 @@
icon={Search} icon={Search}
bind:value={searchValue} bind:value={searchValue}
oninput={onSearchInput} oninput={onSearchInput}
placeholder="Buscar en tu música y en YouTube Music..." placeholder="¿Qué quieres reproducir?"
/>
<ChipGroup
label="Qué quieres buscar"
options={filterOptions}
selected={filters}
onChange={(next) => (filters = next)}
class="mt-4"
/> />
</div> </div>
{#if filters.length === 0}
<EmptyState
icon={Search}
title="No has elegido qué buscar"
description="Marca al menos un tipo de resultado para verlos aquí."
/>
{/if}
{#if form?.message} {#if form?.message}
<Alert class="mt-4">{form.message}</Alert> <Alert class="mt-4">{form.message}</Alert>
{/if} {/if}
@@ -267,68 +229,91 @@
/> />
{/if} {/if}
{#if showAlbums && hasAlbums} {#if hasAlbums}
<div class="mt-8"> <div class="mt-8">
<SectionHeading title="Álbumes" /> <SectionHeading title="Álbumes" />
<MediaGrid class="mt-4"> <MediaGrid as="ul" class="mt-4">
{#each albums as album, i (album.id)} {#each albumEntries as entry, i (entry.kind === 'local' ? entry.album.id : entry.album.albumId)}
<li>
{#if entry.kind === 'local'}
<AlbumCard <AlbumCard
id={album.id} id={entry.album.id}
title={album.title} title={entry.album.title}
typeLabel="Álbum" typeLabel="Álbum"
releaseYear={album.releaseYear === null ? undefined : Number(album.releaseYear)} releaseYear={entry.album.releaseYear === null
trackCount={Number(album.trackCount)} ? undefined
trackIds={album.coverTrackIds} : Number(entry.album.releaseYear)}
trackCount={Number(entry.album.trackCount)}
trackIds={entry.album.coverTrackIds}
index={i} index={i}
onContextMenu={isForeignAlbum(entry.album)
? (e) => openAlbumMenu(e, 'local', entry.album.id)
: undefined}
/> />
{/each} {:else}
{#each youtubeAlbums as album, i (album.albumId)}
<AlbumCard <AlbumCard
id={album.albumId} id={entry.album.albumId}
href="/albums/youtube/{album.albumId}" href="/albums/youtube/{entry.album.albumId}"
title={album.title} title={entry.album.title}
typeLabel="Álbum" typeLabel="Álbum"
subtitle={album.artist} subtitle={entry.album.artist}
releaseYear={album.releaseYear === null ? undefined : Number(album.releaseYear)} releaseYear={entry.album.releaseYear === null
coverSrc={album.thumbnailUrl} ? undefined
index={albums.length + i} : Number(entry.album.releaseYear)}
coverSrc={entry.album.thumbnailUrl}
index={i}
onContextMenu={(e) => openAlbumMenu(e, 'youtube', entry.album.albumId)}
/> />
{/if}
</li>
{/each} {/each}
</MediaGrid> {#each fillerTracks as item, i (targetId(item))}
{#if ytAlbumsContinuation}
<div class="mt-6 flex justify-center">
<Button variant="secondary" onclick={loadMoreAlbums} loading={loadingAlbums}>
Ver más álbumes
</Button>
</div>
{/if}
</div>
{/if}
{#if showTracks && items.length > 0}
{#if showAlbums && hasAlbums}
<SectionHeading title="Canciones" class="mt-10" />
{/if}
<MediaGrid as="ul" class="mt-6">
{#each items as item, i (targetId(item))}
<TrackTile <TrackTile
id={targetId(item)} id={targetId(item)}
title={targetTitle(item)} title={targetTitle(item)}
artist={targetArtist(item)} artist={targetArtist(item)}
typeLabel="Canción" 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>
</div>
{/if}
{#if remainingTracks.length > 0}
{#if hasAlbums}
<SectionHeading title="Canciones" class="mt-10" />
{/if}
<MediaGrid as="ul" class="mt-6">
{#each remainingTracks as item, i (targetId(item))}
<TrackTile
id={targetId(item)}
title={targetTitle(item)}
artist={targetArtist(item)}
typeLabel="Canción"
coverShape="round"
coverSrc={targetCoverSrc(item)} coverSrc={targetCoverSrc(item)}
hue={hueFor(targetId(item))} hue={hueFor(targetId(item))}
explicit={targetExplicit(item)} explicit={targetExplicit(item)}
active={isTargetCurrent(item)} active={isTargetCurrent(item)}
playing={player.isPlaying} playing={player.isPlaying}
index={i} index={i}
onClick={() => togglePlay(i)} onClick={() => togglePlay(fillerCount + i)}
onContextMenu={(e) => openContextMenu(e, item)} onContextMenu={(e) => openContextMenu(e, item)}
/> />
{/each} {/each}
</MediaGrid> </MediaGrid>
{/if}
{#if hasMore}
<InfiniteScroll onLoadMore={loadMore} {hasMore} loading={loadingMore} /> <InfiniteScroll onLoadMore={loadMore} {hasMore} loading={loadingMore} />
{/if} {/if}
@@ -348,3 +333,7 @@
onAddToQueue={addToQueue} onAddToQueue={addToQueue}
/> />
{/if} {/if}
{#if albumMenu}
<AlbumContextMenu menu={albumMenu} playlists={data.playlists} onClose={closeAlbumMenu} />
{/if}
+11 -2
View File
@@ -3,7 +3,7 @@ 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 { shuffle } from '$lib/collections';
import { addTrackAction, addYouTubeToPlaylistAction } from '$lib/server/playlistActions'; import { addTrackAction, addYouTubeToPlaylistAction } from '$lib/server/playlistActions';
import { HOME_LATEST_PAGE_SIZE, YOUTUBE_FILLER_LIMIT } from '$lib/config'; import { HOME_ALBUMS_PAGE_SIZE, HOME_LATEST_PAGE_SIZE, YOUTUBE_FILLER_LIMIT } from '$lib/config';
import { pickGreeting } from '$lib/server/greeting'; import { pickGreeting } from '$lib/server/greeting';
export const load: PageServerLoad = async ({ locals, url, fetch }) => { export const load: PageServerLoad = async ({ locals, url, fetch }) => {
@@ -16,14 +16,21 @@ export const load: PageServerLoad = async ({ locals, url, fetch }) => {
const playlistsPromise = api.GET('/playlists/users/{userId}', { const playlistsPromise = api.GET('/playlists/users/{userId}', {
params: { path: { userId: user.sub }, query: { pageNumber: 1, pageSize: 12 } } params: { path: { userId: user.sub }, query: { pageNumber: 1, pageSize: 12 } }
}); });
const albumsPromise = api.GET('/albums/users/{userId}', {
params: {
path: { userId: user.sub },
query: { pageNumber: 1, pageSize: HOME_ALBUMS_PAGE_SIZE }
}
});
const mixesPromise = api.GET('/mixes'); const mixesPromise = api.GET('/mixes');
const recentlyPlayedPromise = api.GET('/users/{id}/listening-history', { const recentlyPlayedPromise = api.GET('/users/{id}/listening-history', {
params: { path: { id: user.sub } } params: { path: { id: user.sub } }
}); });
const [latest, playlists, mixes, recentlyPlayed] = await Promise.all([ const [latest, playlists, albums, mixes, recentlyPlayed] = await Promise.all([
latestPromise, latestPromise,
playlistsPromise, playlistsPromise,
albumsPromise,
mixesPromise, mixesPromise,
recentlyPlayedPromise recentlyPlayedPromise
]); ]);
@@ -41,6 +48,8 @@ export const load: PageServerLoad = async ({ locals, url, fetch }) => {
user.sub user.sub
), ),
playlists: playlistItems, playlists: playlistItems,
albums: albums.data?.items ?? [],
albumsHasMore: Boolean(albums.data?.hasNextPage),
mixes: mixes.data ?? [], mixes: mixes.data ?? [],
recentlyPlayed: recentlyPlayed.data ?? [] recentlyPlayed: recentlyPlayed.data ?? []
}; };
+115 -1
View File
@@ -14,8 +14,10 @@
} 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 PlaylistArt from '$lib/components/ui/PlaylistArt.svelte';
import PlaylistCard from '$lib/components/ui/PlaylistCard.svelte'; import PlaylistCard from '$lib/components/ui/PlaylistCard.svelte';
import MixCard from '$lib/components/ui/MixCard.svelte'; import MixCard from '$lib/components/ui/MixCard.svelte';
import AlbumCard from '$lib/components/ui/AlbumCard.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';
@@ -29,7 +31,7 @@
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'; import { appendUnique, shuffle } from '$lib/collections';
let { data } = $props(); let { data } = $props();
@@ -107,12 +109,58 @@
} }
} }
const playlists = $derived(data.playlists); const playlists = $derived(data.playlists);
const albums = $derived(data.albums);
const mixes = $derived(data.mixes); const mixes = $derived(data.mixes);
function playLatest(index: number) { function playLatest(index: number) {
player.playOrToggle(latest.map(queueItemForTarget), index); player.playOrToggle(latest.map(queueItemForTarget), index);
} }
function playShuffledLocal() {
if (localLatest.length === 0) return;
player.playQueue(shuffle(localLatest).map(queueItemForTarget), 0);
}
function playShuffledYoutube() {
if (youtubeFillerItems.length === 0) return;
const targets = youtubeFillerItems.map((song) => ({ kind: 'youtube' as const, song }));
player.playQueue(shuffle(targets).map(queueItemForTarget), 0);
}
interface QuickMix {
key: string;
title: string;
subtitle: string;
coverIds?: (string | number)[];
coverSongs?: YouTubeSong[];
onClick: () => void;
}
const quickMixes = $derived<QuickMix[]>(
(
[
localLatest.length > 0
? {
key: 'local',
title: 'Mezcla Musify',
subtitle: `${localLatest.length} ${localLatest.length === 1 ? 'canción' : 'canciones'}`,
coverIds: localLatest.map((item) => targetId(item)),
onClick: playShuffledLocal
}
: null,
youtubeFillerItems.length > 0
? {
key: 'youtube',
title: 'Mezcla YouTube Music',
subtitle: `${youtubeFillerItems.length} ${youtubeFillerItems.length === 1 ? 'canción' : 'canciones'}`,
coverSongs: youtubeFillerItems.slice(0, 4),
onClick: playShuffledYoutube
}
: null
] as (QuickMix | null)[]
).filter((mix): mix is QuickMix => mix !== null)
);
function openContextMenu(event: MouseEvent, target: TrackTarget) { function openContextMenu(event: MouseEvent, target: TrackTarget) {
contextMenu = contextMenuStateFor(event, target); contextMenu = contextMenuStateFor(event, target);
} }
@@ -225,6 +273,46 @@
</section> </section>
{/if} {/if}
{#if quickMixes.length > 0}
<section class="page-x pt-5 pb-3 sm:pt-6">
<SectionHeading title="Mezclas rápidas" />
<MediaGrid>
{#each quickMixes as mix, i (mix.key)}
<button
type="button"
onclick={mix.onClick}
class="group animate-enter min-w-0 text-left"
style="animation-delay:{i * 45}ms"
>
{#if mix.coverSongs}
<div
class="grid aspect-square w-full grid-cols-2 grid-rows-2 overflow-hidden rounded-art shadow-art transition group-hover:shadow-art-lg"
>
{#each mix.coverSongs as song (song.videoId)}
<Cover
trackId={song.videoId}
src={song.thumbnailUrl}
hue={hueFor(song.videoId)}
size="small"
class="h-full w-full"
/>
{/each}
</div>
{:else}
<PlaylistArt
trackIds={mix.coverIds ?? []}
hue={hueFor(mix.key)}
class="aspect-square w-full rounded-art shadow-art transition group-hover:shadow-art-lg"
/>
{/if}
<div class="mt-2.5 truncate text-fg">{mix.title}</div>
<div class="mt-0.5 truncate text-sm text-fg-3">{mix.subtitle}</div>
</button>
{/each}
</MediaGrid>
</section>
{/if}
{#if mixes.length > 0} {#if mixes.length > 0}
<section class="page-x pt-5 pb-3 sm:pt-6"> <section class="page-x pt-5 pb-3 sm:pt-6">
<SectionHeading title="Hechas para ti" /> <SectionHeading title="Hechas para ti" />
@@ -254,6 +342,32 @@
</section> </section>
{/if} {/if}
{#if albums.length > 0}
<section class="page-x pt-5 pb-3 sm:pt-6">
<SectionHeading title="Tus álbumes">
{#snippet actions()}
{#if data.albumsHasMore}
<a href="/albums" class="text-sm text-fg-3 transition hover:text-accent-soft">
Ver todos
</a>
{/if}
{/snippet}
</SectionHeading>
<MediaGrid>
{#each albums as album, i (album.id)}
<AlbumCard
id={album.id}
title={album.title}
releaseYear={album.releaseYear === null ? undefined : Number(album.releaseYear)}
trackCount={Number(album.trackCount)}
trackIds={album.coverTrackIds}
index={i}
/>
{/each}
</MediaGrid>
</section>
{/if}
<section class="page-x pt-5 pb-10 sm:pt-6"> <section class="page-x pt-5 pb-10 sm:pt-6">
<SectionHeading title="Novedades" /> <SectionHeading title="Novedades" />
{#if latest.length > 0} {#if latest.length > 0}
@@ -1,21 +0,0 @@
import type { RequestHandler } from './$types';
import { json, error } from '@sveltejs/kit';
import { createApiClient, requireAccessToken, unwrapOrError } from '$lib/server/api';
export const GET: RequestHandler = async ({ url, locals, fetch }) => {
const accessToken = requireAccessToken(locals, 'Inicia sesión para buscar.');
const query = url.searchParams.get('query')?.trim() ?? '';
if (!query) {
error(400, 'Falta el término de búsqueda.');
}
const continuation = url.searchParams.get('continuation') ?? undefined;
const api = createApiClient({ fetch, accessToken });
const result = await api.GET('/youtube/albums', {
params: { query: { query, continuation } }
});
return json(unwrapOrError(result, 'No se pudieron buscar álbumes en YouTube Music.'));
};
+1
View File
@@ -28,6 +28,7 @@
--radius-panel: 1rem; --radius-panel: 1rem;
--radius-art: 0.75rem; --radius-art: 0.75rem;
--radius-art-lg: 1rem; --radius-art-lg: 1rem;
--radius-art-round: 1.75rem;
--shadow-art: 0 12px 28px -10px rgba(0, 0, 0, 0.6); --shadow-art: 0 12px 28px -10px rgba(0, 0, 0, 0.6);
--shadow-art-lg: 0 20px 45px -15px rgba(0, 0, 0, 0.7); --shadow-art-lg: 0 20px 45px -15px rgba(0, 0, 0, 0.7);