Design Prot Merge #1
@@ -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()
|
||||||
|
|||||||
+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.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",
|
||||||
|
|||||||
Vendored
-60
@@ -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>
|
||||||
@@ -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}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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)}
|
||||||
<AlbumCard
|
<li>
|
||||||
id={album.id}
|
{#if entry.kind === 'local'}
|
||||||
title={album.title}
|
<AlbumCard
|
||||||
typeLabel="Álbum"
|
id={entry.album.id}
|
||||||
releaseYear={album.releaseYear === null ? undefined : Number(album.releaseYear)}
|
title={entry.album.title}
|
||||||
trackCount={Number(album.trackCount)}
|
typeLabel="Álbum"
|
||||||
trackIds={album.coverTrackIds}
|
releaseYear={entry.album.releaseYear === null
|
||||||
index={i}
|
? 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}
|
||||||
{#each youtubeAlbums as album, i (album.albumId)}
|
{#each fillerTracks as item, i (targetId(item))}
|
||||||
<AlbumCard
|
<TrackTile
|
||||||
id={album.albumId}
|
id={targetId(item)}
|
||||||
href="/albums/youtube/{album.albumId}"
|
title={targetTitle(item)}
|
||||||
title={album.title}
|
artist={targetArtist(item)}
|
||||||
typeLabel="Álbum"
|
typeLabel="Canción"
|
||||||
subtitle={album.artist}
|
coverShape="round"
|
||||||
releaseYear={album.releaseYear === null ? undefined : Number(album.releaseYear)}
|
coverSrc={targetCoverSrc(item)}
|
||||||
coverSrc={album.thumbnailUrl}
|
hue={hueFor(targetId(item))}
|
||||||
index={albums.length + i}
|
explicit={targetExplicit(item)}
|
||||||
|
active={isTargetCurrent(item)}
|
||||||
|
playing={player.isPlaying}
|
||||||
|
index={albumEntries.length + i}
|
||||||
|
onClick={() => togglePlay(i)}
|
||||||
|
onContextMenu={(e) => openContextMenu(e, item)}
|
||||||
/>
|
/>
|
||||||
{/each}
|
{/each}
|
||||||
</MediaGrid>
|
</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>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if showTracks && items.length > 0}
|
{#if remainingTracks.length > 0}
|
||||||
{#if showAlbums && hasAlbums}
|
{#if hasAlbums}
|
||||||
<SectionHeading title="Canciones" class="mt-10" />
|
<SectionHeading title="Canciones" class="mt-10" />
|
||||||
{/if}
|
{/if}
|
||||||
<MediaGrid as="ul" class="mt-6">
|
<MediaGrid as="ul" class="mt-6">
|
||||||
{#each items as item, i (targetId(item))}
|
{#each remainingTracks 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)}
|
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}
|
||||||
|
|||||||
@@ -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 ?? []
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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.'));
|
|
||||||
};
|
|
||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user