feat: bring over the albums, explore and player work from the worktree
Moves the remaining uncommitted work: the home gets quick mixes and a "Tus álbumes" shelf, saving a YouTube album now goes through addYoutubeAlbumToPlaylistAction instead of the SaveYouTubeAlbum command, and UserAlbum drops its unused ExternalId. The mixes section keeps its current shape (no refresh action, no badge, hidden when empty) rather than the older version the worktree carried.
This commit is contained in:
@@ -50,15 +50,6 @@ public static class AlbumEndpoints
|
||||
.Produces(StatusCodes.Status401Unauthorized)
|
||||
.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)
|
||||
.WithName("UpdateAlbum")
|
||||
.WithSummary("Update An Album.")
|
||||
@@ -152,19 +143,6 @@ public static class AlbumEndpoints
|
||||
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(
|
||||
IMediator mediator,
|
||||
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]
|
||||
public required long OwnerUserId { get; set; }
|
||||
|
||||
[MaxLength(64)]
|
||||
public string? ExternalId { get; set; }
|
||||
|
||||
[ForeignKey(nameof(OwnerUserId))]
|
||||
public User Owner { get; set; }
|
||||
}
|
||||
|
||||
@@ -111,10 +111,6 @@ namespace Musify.Infrastructure.Persistence
|
||||
modelBuilder.Entity<UserAlbum>()
|
||||
.ToTable("UserAlbums");
|
||||
|
||||
modelBuilder.Entity<UserAlbum>()
|
||||
.HasIndex(album => new { album.OwnerUserId, album.ExternalId })
|
||||
.IsUnique();
|
||||
|
||||
modelBuilder.Entity<UserAlbum>()
|
||||
.HasOne(album => album.Owner)
|
||||
.WithMany()
|
||||
|
||||
+1178
File diff suppressed because it is too large
Load Diff
+48
@@ -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.Property<string>("ExternalId")
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)");
|
||||
|
||||
b.Property<long>("OwnerUserId")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.HasIndex("OwnerUserId", "ExternalId")
|
||||
.IsUnique();
|
||||
b.HasIndex("OwnerUserId");
|
||||
|
||||
b.ToTable("UserAlbums", (string)null);
|
||||
});
|
||||
|
||||
@@ -162,7 +162,7 @@ namespace Musify.Infrastructure.Services
|
||||
|
||||
return new YouTubeAlbumDetail(
|
||||
album,
|
||||
info.Description ?? string.Empty,
|
||||
StripWikipediaAttribution(info.Description),
|
||||
(int)info.Duration.TotalSeconds,
|
||||
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) =>
|
||||
new(
|
||||
album.Id,
|
||||
|
||||
@@ -16,6 +16,9 @@
|
||||
"OpenTelemetry": {
|
||||
"OtlpEndpoint": "http://localhost:4317"
|
||||
},
|
||||
"YouTube": {
|
||||
"GeographicalLocation": "ES"
|
||||
},
|
||||
"InfrastructureStorage": {
|
||||
"Address": "http://localhost:8333",
|
||||
"AccessKey": "admin_access_key",
|
||||
|
||||
Vendored
-60
@@ -73,23 +73,6 @@ export interface paths {
|
||||
patch?: 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}": {
|
||||
parameters: {
|
||||
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: {
|
||||
parameters: {
|
||||
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>
|
||||
@@ -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"
|
||||
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
|
||||
@@ -132,7 +132,7 @@
|
||||
{user.name.charAt(0)}
|
||||
</span>
|
||||
{/if}
|
||||
<span class="truncate leading-[1.35]">{user.name}</span>
|
||||
<span class="truncate leading-[1.35] text-fg-3">{user.name}</span>
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
typeLabel?: string;
|
||||
coverSrc?: string | null;
|
||||
coverSize?: 'small' | 'medium' | 'large';
|
||||
coverShape?: 'square' | 'round';
|
||||
hue: number;
|
||||
explicit?: boolean;
|
||||
active: boolean;
|
||||
@@ -28,6 +29,7 @@
|
||||
typeLabel,
|
||||
coverSrc,
|
||||
coverSize = 'large',
|
||||
coverShape = 'square',
|
||||
hue,
|
||||
explicit = false,
|
||||
active,
|
||||
@@ -58,7 +60,9 @@
|
||||
{hue}
|
||||
size={coverSize}
|
||||
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} />
|
||||
</Cover>
|
||||
|
||||
@@ -3,8 +3,19 @@
|
||||
import TransportControls from './TransportControls.svelte';
|
||||
import SeekBar from './SeekBar.svelte';
|
||||
import VolumeControl from './VolumeControl.svelte';
|
||||
import Alert from '$lib/components/ui/Alert.svelte';
|
||||
import { player } from '$lib/player/player.svelte';
|
||||
</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
|
||||
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))"
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
trackIds?: (string | number)[];
|
||||
coverSrc?: string | null;
|
||||
index?: number;
|
||||
onContextMenu?: (event: MouseEvent) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -26,7 +27,8 @@
|
||||
trackCount,
|
||||
trackIds = [],
|
||||
coverSrc,
|
||||
index = 0
|
||||
index = 0,
|
||||
onContextMenu
|
||||
}: Props = $props();
|
||||
|
||||
const meta = $derived(
|
||||
@@ -47,6 +49,7 @@
|
||||
href={href ?? `/albums/${id}`}
|
||||
class="group animate-enter min-w-0 text-left"
|
||||
style="animation-delay:{index * 45}ms"
|
||||
oncontextmenu={onContextMenu}
|
||||
>
|
||||
{#if coverSrc}
|
||||
<Cover
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
alt?: string;
|
||||
class?: string;
|
||||
children?: Snippet;
|
||||
oncontextmenu?: (event: MouseEvent) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -20,7 +21,8 @@
|
||||
src,
|
||||
alt = '',
|
||||
class: klass = '',
|
||||
children
|
||||
children,
|
||||
oncontextmenu
|
||||
}: Props = $props();
|
||||
|
||||
let failed = $state(false);
|
||||
@@ -40,6 +42,8 @@
|
||||
<div
|
||||
class="relative overflow-hidden {klass}"
|
||||
style={loaded && !failed ? undefined : `background:${gradientForHue(hue)}`}
|
||||
role="presentation"
|
||||
{oncontextmenu}
|
||||
>
|
||||
{#if !failed}
|
||||
<img
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
<div class="relative w-full">
|
||||
{#if Icon}
|
||||
<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>
|
||||
{/if}
|
||||
<input
|
||||
@@ -52,7 +52,7 @@
|
||||
{autocomplete}
|
||||
{oninput}
|
||||
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'
|
||||
: 'px-4'} {klass}"
|
||||
/>
|
||||
|
||||
@@ -1,10 +1,20 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
class?: string;
|
||||
actions?: Snippet;
|
||||
}
|
||||
|
||||
let { title, class: klass = '' }: Props = $props();
|
||||
let { title, class: klass = '', actions }: Props = $props();
|
||||
</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}
|
||||
|
||||
@@ -6,7 +6,9 @@ export const ALBUMS_PAGE_SIZE = 50;
|
||||
export const ALBUM_TRACKS_PAGE_SIZE = 200;
|
||||
export const ALBUM_EARLIEST_YEAR = 1877;
|
||||
export const LIBRARY_PICKER_PAGE_SIZE = 50;
|
||||
export const EXPLORE_ALBUMS_PAGE_SIZE = 18;
|
||||
export const EXPLORE_ALBUMS_PAGE_SIZE = 10;
|
||||
export const EXPLORE_ALBUM_SLOTS = 10;
|
||||
export const HOME_LATEST_PAGE_SIZE = 12;
|
||||
export const HOME_ALBUMS_PAGE_SIZE = 12;
|
||||
export const YOUTUBE_FILLER_LIMIT = 27;
|
||||
export const PLAYLIST_PICKER_PAGE_SIZE = 50;
|
||||
|
||||
@@ -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 EMPTY: PlayerTrack = {
|
||||
@@ -286,13 +295,16 @@ class PlayerState {
|
||||
if (token !== this.#loadToken) return;
|
||||
this.loading = 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> {
|
||||
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 {
|
||||
manifestUrl: string;
|
||||
ticket: string;
|
||||
@@ -302,7 +314,7 @@ class PlayerState {
|
||||
|
||||
async #resolveYouTubeSrc(videoId: string): Promise<string> {
|
||||
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 {
|
||||
mode: string;
|
||||
streamUrl: string;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { fail, type RequestEvent } from '@sveltejs/kit';
|
||||
import { createApiClient, requireAccessTokenAction } from '$lib/server/api';
|
||||
import { ALBUM_TRACKS_PAGE_SIZE } from '$lib/config';
|
||||
|
||||
export async function addTrackAction({ request, params, locals, fetch }: RequestEvent) {
|
||||
const accessToken = requireAccessTokenAction(locals);
|
||||
@@ -59,3 +60,79 @@ export async function addYouTubeToPlaylistAction({ request, params, locals, fetc
|
||||
|
||||
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 { fail, redirect } from '@sveltejs/kit';
|
||||
import { createApiClient, requireUser, unwrapOrError } from '$lib/server/api';
|
||||
import {
|
||||
createApiClient,
|
||||
requireAccessTokenAction,
|
||||
requireUser,
|
||||
unwrapOrError
|
||||
} from '$lib/server/api';
|
||||
import { addYouTubeToPlaylistAction } from '$lib/server/playlistActions';
|
||||
addYouTubeToPlaylistAction,
|
||||
addYoutubeAlbumToPlaylistAction
|
||||
} from '$lib/server/playlistActions';
|
||||
|
||||
export const load: PageServerLoad = async ({ params, locals, url, fetch }) => {
|
||||
const user = requireUser(locals, url);
|
||||
@@ -26,27 +23,5 @@ export const load: PageServerLoad = async ({ params, locals, url, fetch }) => {
|
||||
|
||||
export const actions: Actions = {
|
||||
addYouTubeToPlaylist: addYouTubeToPlaylistAction,
|
||||
|
||||
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}`);
|
||||
}
|
||||
addYoutubeAlbumToPlaylist: addYoutubeAlbumToPlaylistAction
|
||||
};
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import ArrowLeft from '@lucide/svelte/icons/arrow-left';
|
||||
import Play from '@lucide/svelte/icons/play';
|
||||
import Pause from '@lucide/svelte/icons/pause';
|
||||
import Plus from '@lucide/svelte/icons/plus';
|
||||
import { player, type QueueItem } from '$lib/player/player.svelte';
|
||||
import { hueFor } from '$lib/theme/color';
|
||||
import { fmtTime } from '$lib/format';
|
||||
@@ -20,6 +18,10 @@
|
||||
contextMenuStateFor,
|
||||
type ContextMenuState
|
||||
} from '$lib/components/TrackContextMenu.svelte';
|
||||
import AlbumContextMenu, {
|
||||
albumContextMenuStateFor,
|
||||
type AlbumMenuState
|
||||
} from '$lib/components/AlbumContextMenu.svelte';
|
||||
import type { TrackTarget } from '$lib/tracks';
|
||||
|
||||
let { data, form } = $props();
|
||||
@@ -28,7 +30,7 @@
|
||||
const tracks = $derived(data.album.tracks);
|
||||
|
||||
let contextMenu = $state<ContextMenuState | null>(null);
|
||||
let saving = $state(false);
|
||||
let albumMenu = $state<AlbumMenuState | null>(null);
|
||||
|
||||
const queue = $derived<QueueItem[]>(
|
||||
tracks.map((track) => ({
|
||||
@@ -94,6 +96,7 @@
|
||||
size="large"
|
||||
alt={album.title}
|
||||
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">
|
||||
<p class="text-sm tracking-[0.14em] text-fg-3 uppercase">
|
||||
@@ -127,22 +130,6 @@
|
||||
Reproducir
|
||||
{/if}
|
||||
</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>
|
||||
|
||||
{#if form?.message}
|
||||
@@ -150,7 +137,7 @@
|
||||
{/if}
|
||||
|
||||
{#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}
|
||||
|
||||
<TrackTable columns="32px 1fr 64px" columnsMobile="28px 1fr 52px" class="mt-6 sm:mt-8">
|
||||
@@ -197,3 +184,11 @@
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if albumMenu}
|
||||
<AlbumContextMenu
|
||||
menu={albumMenu}
|
||||
playlists={data.playlists}
|
||||
onClose={() => (albumMenu = null)}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import type { PageServerLoad, Actions } from './$types';
|
||||
import { createApiClient, requireUser, unwrapOrError } from '$lib/server/api';
|
||||
import { fetchYoutubeFiller } from '$lib/server/youtube';
|
||||
import { addTrackAction, addYouTubeToPlaylistAction } from '$lib/server/playlistActions';
|
||||
import {
|
||||
addAlbumToPlaylistAction,
|
||||
addTrackAction,
|
||||
addYouTubeToPlaylistAction,
|
||||
addYoutubeAlbumToPlaylistAction
|
||||
} from '$lib/server/playlistActions';
|
||||
import {
|
||||
EXPLORE_ALBUMS_PAGE_SIZE,
|
||||
EXPLORE_PAGE_SIZE as PAGE_SIZE,
|
||||
@@ -55,7 +60,6 @@ export const load: PageServerLoad = async ({ url, locals, fetch }) => {
|
||||
tracks,
|
||||
albums: albumsRes?.data?.items ?? [],
|
||||
youtubeAlbums: youtubeAlbumsRes?.data?.items ?? [],
|
||||
youtubeAlbumsContinuation: youtubeAlbumsRes?.data?.continuationToken ?? '',
|
||||
ytResults: searchRes?.data ?? null,
|
||||
ytError: Boolean(searchRes?.error),
|
||||
youtubeFiller: query
|
||||
@@ -67,5 +71,7 @@ export const load: PageServerLoad = async ({ url, locals, fetch }) => {
|
||||
|
||||
export const actions: Actions = {
|
||||
addTrack: addTrackAction,
|
||||
addYouTubeToPlaylist: addYouTubeToPlaylistAction
|
||||
addYouTubeToPlaylist: addYouTubeToPlaylistAction,
|
||||
addAlbumToPlaylist: addAlbumToPlaylistAction,
|
||||
addYoutubeAlbumToPlaylist: addYoutubeAlbumToPlaylistAction
|
||||
};
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
<script lang="ts">
|
||||
import Search from '@lucide/svelte/icons/search';
|
||||
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 { goto } from '$app/navigation';
|
||||
import { player } from '$lib/player/player.svelte';
|
||||
@@ -12,12 +10,10 @@
|
||||
import Alert from '$lib/components/ui/Alert.svelte';
|
||||
import Input from '$lib/components/ui/Input.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 ChipGroup, { type ChipOption } from '$lib/components/ui/ChipGroup.svelte';
|
||||
import AlbumCard from '$lib/components/ui/AlbumCard.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 type { YouTubeSong } from '$lib/types';
|
||||
import TrackTile from '$lib/components/TrackTile.svelte';
|
||||
@@ -25,6 +21,10 @@
|
||||
contextMenuStateFor,
|
||||
type ContextMenuState
|
||||
} from '$lib/components/TrackContextMenu.svelte';
|
||||
import AlbumContextMenu, {
|
||||
albumContextMenuStateFor,
|
||||
type AlbumMenuState
|
||||
} from '$lib/components/AlbumContextMenu.svelte';
|
||||
import { appendUnique } from '$lib/collections';
|
||||
import {
|
||||
isTargetCurrent,
|
||||
@@ -42,21 +42,11 @@
|
||||
let searchValue = $state(untrack(() => data.query));
|
||||
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 ytContinuation = $state('');
|
||||
let ytSearchTerm = $state('');
|
||||
let contextMenu = $state<ContextMenuState | null>(null);
|
||||
let albumMenu = $state<AlbumMenuState | null>(null);
|
||||
|
||||
type LocalTrack = (typeof data.tracks.items)[number];
|
||||
|
||||
@@ -67,6 +57,7 @@
|
||||
|
||||
$effect(() => {
|
||||
contextMenu = null;
|
||||
albumMenu = null;
|
||||
localItems = data.tracks.items;
|
||||
localPage = Number(data.tracks.pageNumber);
|
||||
localHasNext = Boolean(data.tracks.hasNextPage);
|
||||
@@ -99,54 +90,29 @@
|
||||
...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 youtubeAlbums = $derived(data.youtubeAlbums);
|
||||
|
||||
type LocalAlbum = (typeof data.albums)[number];
|
||||
type YouTubeAlbum = (typeof data.youtubeAlbums)[number];
|
||||
type AlbumEntry = { kind: 'local'; album: LocalAlbum } | { kind: 'youtube'; album: YouTubeAlbum };
|
||||
|
||||
let youtubeAlbums = $state<YouTubeAlbum[]>([]);
|
||||
let ytAlbumsContinuation = $state('');
|
||||
let loadingAlbums = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
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 albumEntries = $derived<AlbumEntry[]>(
|
||||
[
|
||||
...albums.map((album) => ({ kind: 'local' as const, album })),
|
||||
...youtubeAlbums.map((album) => ({ kind: 'youtube' as const, album }))
|
||||
].slice(0, EXPLORE_ALBUM_SLOTS)
|
||||
);
|
||||
|
||||
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) {
|
||||
return query ? `/explore?q=${encodeURIComponent(query)}` : '/explore';
|
||||
}
|
||||
@@ -177,6 +143,18 @@
|
||||
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() {
|
||||
if (!contextMenu) return;
|
||||
player.addToQueue(queueItemForTarget(contextMenu));
|
||||
@@ -235,26 +213,10 @@
|
||||
icon={Search}
|
||||
bind:value={searchValue}
|
||||
oninput={onSearchInput}
|
||||
placeholder="Buscar en tu música y en YouTube Music..."
|
||||
/>
|
||||
|
||||
<ChipGroup
|
||||
label="Qué quieres buscar"
|
||||
options={filterOptions}
|
||||
selected={filters}
|
||||
onChange={(next) => (filters = next)}
|
||||
class="mt-4"
|
||||
placeholder="¿Qué quieres reproducir?"
|
||||
/>
|
||||
</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}
|
||||
<Alert class="mt-4">{form.message}</Alert>
|
||||
{/if}
|
||||
@@ -267,68 +229,91 @@
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if showAlbums && hasAlbums}
|
||||
{#if hasAlbums}
|
||||
<div class="mt-8">
|
||||
<SectionHeading title="Álbumes" />
|
||||
<MediaGrid class="mt-4">
|
||||
{#each albums as album, i (album.id)}
|
||||
<AlbumCard
|
||||
id={album.id}
|
||||
title={album.title}
|
||||
typeLabel="Álbum"
|
||||
releaseYear={album.releaseYear === null ? undefined : Number(album.releaseYear)}
|
||||
trackCount={Number(album.trackCount)}
|
||||
trackIds={album.coverTrackIds}
|
||||
index={i}
|
||||
/>
|
||||
<MediaGrid as="ul" class="mt-4">
|
||||
{#each albumEntries as entry, i (entry.kind === 'local' ? entry.album.id : entry.album.albumId)}
|
||||
<li>
|
||||
{#if entry.kind === 'local'}
|
||||
<AlbumCard
|
||||
id={entry.album.id}
|
||||
title={entry.album.title}
|
||||
typeLabel="Álbum"
|
||||
releaseYear={entry.album.releaseYear === null
|
||||
? undefined
|
||||
: Number(entry.album.releaseYear)}
|
||||
trackCount={Number(entry.album.trackCount)}
|
||||
trackIds={entry.album.coverTrackIds}
|
||||
index={i}
|
||||
onContextMenu={isForeignAlbum(entry.album)
|
||||
? (e) => openAlbumMenu(e, 'local', entry.album.id)
|
||||
: undefined}
|
||||
/>
|
||||
{:else}
|
||||
<AlbumCard
|
||||
id={entry.album.albumId}
|
||||
href="/albums/youtube/{entry.album.albumId}"
|
||||
title={entry.album.title}
|
||||
typeLabel="Álbum"
|
||||
subtitle={entry.album.artist}
|
||||
releaseYear={entry.album.releaseYear === null
|
||||
? undefined
|
||||
: Number(entry.album.releaseYear)}
|
||||
coverSrc={entry.album.thumbnailUrl}
|
||||
index={i}
|
||||
onContextMenu={(e) => openAlbumMenu(e, 'youtube', entry.album.albumId)}
|
||||
/>
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
{#each youtubeAlbums as album, i (album.albumId)}
|
||||
<AlbumCard
|
||||
id={album.albumId}
|
||||
href="/albums/youtube/{album.albumId}"
|
||||
title={album.title}
|
||||
typeLabel="Álbum"
|
||||
subtitle={album.artist}
|
||||
releaseYear={album.releaseYear === null ? undefined : Number(album.releaseYear)}
|
||||
coverSrc={album.thumbnailUrl}
|
||||
index={albums.length + i}
|
||||
{#each fillerTracks as item, i (targetId(item))}
|
||||
<TrackTile
|
||||
id={targetId(item)}
|
||||
title={targetTitle(item)}
|
||||
artist={targetArtist(item)}
|
||||
typeLabel="Canción"
|
||||
coverShape="round"
|
||||
coverSrc={targetCoverSrc(item)}
|
||||
hue={hueFor(targetId(item))}
|
||||
explicit={targetExplicit(item)}
|
||||
active={isTargetCurrent(item)}
|
||||
playing={player.isPlaying}
|
||||
index={albumEntries.length + i}
|
||||
onClick={() => togglePlay(i)}
|
||||
onContextMenu={(e) => openContextMenu(e, item)}
|
||||
/>
|
||||
{/each}
|
||||
</MediaGrid>
|
||||
|
||||
{#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}
|
||||
{#if remainingTracks.length > 0}
|
||||
{#if hasAlbums}
|
||||
<SectionHeading title="Canciones" class="mt-10" />
|
||||
{/if}
|
||||
<MediaGrid as="ul" class="mt-6">
|
||||
{#each items as item, i (targetId(item))}
|
||||
{#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)}
|
||||
hue={hueFor(targetId(item))}
|
||||
explicit={targetExplicit(item)}
|
||||
active={isTargetCurrent(item)}
|
||||
playing={player.isPlaying}
|
||||
index={i}
|
||||
onClick={() => togglePlay(i)}
|
||||
onClick={() => togglePlay(fillerCount + i)}
|
||||
onContextMenu={(e) => openContextMenu(e, item)}
|
||||
/>
|
||||
{/each}
|
||||
</MediaGrid>
|
||||
{/if}
|
||||
|
||||
{#if hasMore}
|
||||
<InfiniteScroll onLoadMore={loadMore} {hasMore} loading={loadingMore} />
|
||||
{/if}
|
||||
|
||||
@@ -348,3 +333,7 @@
|
||||
onAddToQueue={addToQueue}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if albumMenu}
|
||||
<AlbumContextMenu menu={albumMenu} playlists={data.playlists} onClose={closeAlbumMenu} />
|
||||
{/if}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { createApiClient, requireUser } from '$lib/server/api';
|
||||
import { fetchYoutubeFiller } from '$lib/server/youtube';
|
||||
import { shuffle } from '$lib/collections';
|
||||
import { addTrackAction, addYouTubeToPlaylistAction } from '$lib/server/playlistActions';
|
||||
import { HOME_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';
|
||||
|
||||
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}', {
|
||||
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 recentlyPlayedPromise = api.GET('/users/{id}/listening-history', {
|
||||
params: { path: { id: user.sub } }
|
||||
});
|
||||
|
||||
const [latest, playlists, mixes, recentlyPlayed] = await Promise.all([
|
||||
const [latest, playlists, albums, mixes, recentlyPlayed] = await Promise.all([
|
||||
latestPromise,
|
||||
playlistsPromise,
|
||||
albumsPromise,
|
||||
mixesPromise,
|
||||
recentlyPlayedPromise
|
||||
]);
|
||||
@@ -41,6 +48,8 @@ export const load: PageServerLoad = async ({ locals, url, fetch }) => {
|
||||
user.sub
|
||||
),
|
||||
playlists: playlistItems,
|
||||
albums: albums.data?.items ?? [],
|
||||
albumsHasMore: Boolean(albums.data?.hasNextPage),
|
||||
mixes: mixes.data ?? [],
|
||||
recentlyPlayed: recentlyPlayed.data ?? []
|
||||
};
|
||||
|
||||
@@ -14,8 +14,10 @@
|
||||
} from '$lib/tracks';
|
||||
import { hueFor } from '$lib/theme/color';
|
||||
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 MixCard from '$lib/components/ui/MixCard.svelte';
|
||||
import AlbumCard from '$lib/components/ui/AlbumCard.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';
|
||||
@@ -29,7 +31,7 @@
|
||||
type ContextMenuState
|
||||
} from '$lib/components/TrackContextMenu.svelte';
|
||||
import type { YouTubeSong } from '$lib/types';
|
||||
import { appendUnique } from '$lib/collections';
|
||||
import { appendUnique, shuffle } from '$lib/collections';
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
@@ -107,12 +109,58 @@
|
||||
}
|
||||
}
|
||||
const playlists = $derived(data.playlists);
|
||||
const albums = $derived(data.albums);
|
||||
const mixes = $derived(data.mixes);
|
||||
|
||||
function playLatest(index: number) {
|
||||
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) {
|
||||
contextMenu = contextMenuStateFor(event, target);
|
||||
}
|
||||
@@ -225,6 +273,46 @@
|
||||
</section>
|
||||
{/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}
|
||||
<section class="page-x pt-5 pb-3 sm:pt-6">
|
||||
<SectionHeading title="Hechas para ti" />
|
||||
@@ -254,6 +342,32 @@
|
||||
</section>
|
||||
{/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">
|
||||
<SectionHeading title="Novedades" />
|
||||
{#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.'));
|
||||
};
|
||||
@@ -28,6 +28,7 @@
|
||||
--radius-panel: 1rem;
|
||||
--radius-art: 0.75rem;
|
||||
--radius-art-lg: 1rem;
|
||||
--radius-art-round: 1.75rem;
|
||||
|
||||
--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);
|
||||
|
||||
Reference in New Issue
Block a user