Compare commits
10
Commits
b011ccdd5b
...
f210fa4702
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f210fa4702 | ||
|
|
4b708aa987 | ||
|
|
bd07b1134e | ||
|
|
c041daf649 | ||
|
|
975fa7e4fd | ||
|
|
1a2ae37f49 | ||
|
|
1cad36b929 | ||
|
|
8cb9386447 | ||
|
|
5cda7cb4c1 | ||
|
|
e89463fb83 |
@@ -0,0 +1,86 @@
|
|||||||
|
using ErrorOr;
|
||||||
|
using Mediator;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Musify.Application.Contracts;
|
||||||
|
using Musify.Application.PlayLists.Responses;
|
||||||
|
using Musify.Domain.Entities;
|
||||||
|
|
||||||
|
namespace Musify.Application.PlayLists
|
||||||
|
{
|
||||||
|
public record AddAlbumToPlayListCommand(long UserId, Guid PlayListId, Guid AlbumId)
|
||||||
|
: ICommand<ErrorOr<AddAlbumToPlayListResponse>>;
|
||||||
|
|
||||||
|
public class AddAlbumToPlayListCommandHandler(
|
||||||
|
IDatabase database,
|
||||||
|
ILogger<AddAlbumToPlayListCommandHandler> logger)
|
||||||
|
: ICommandHandler<AddAlbumToPlayListCommand, ErrorOr<AddAlbumToPlayListResponse>>
|
||||||
|
{
|
||||||
|
public async ValueTask<ErrorOr<AddAlbumToPlayListResponse>> Handle(AddAlbumToPlayListCommand request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var playList = await database.PlayLists
|
||||||
|
.SingleOrDefaultAsync(pl => pl.Id == request.PlayListId, cancellationToken);
|
||||||
|
if (playList == null)
|
||||||
|
{
|
||||||
|
logger.LogInformation("Playlist {PlayListId} not found", request.PlayListId);
|
||||||
|
return Error.NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (playList.UserId != request.UserId)
|
||||||
|
{
|
||||||
|
logger.LogWarning("User {UserId} is not the owner of playlist {PlayListId}", request.UserId, request.PlayListId);
|
||||||
|
return Error.Unauthorized();
|
||||||
|
}
|
||||||
|
|
||||||
|
var albumExists = await database.Albums
|
||||||
|
.AsNoTracking()
|
||||||
|
.AnyAsync(album => album.Id == request.AlbumId, cancellationToken);
|
||||||
|
if (!albumExists)
|
||||||
|
{
|
||||||
|
logger.LogInformation("Album {AlbumId} not found", request.AlbumId);
|
||||||
|
return Error.NotFound(description: $"Album {request.AlbumId} not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
var albumTrackIds = await database.AlbumHasTracks
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(albumTrack => albumTrack.AlbumId == request.AlbumId)
|
||||||
|
.OrderBy(albumTrack => albumTrack.TrackNumber)
|
||||||
|
.Select(albumTrack => albumTrack.TrackId)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
var playListTracksQuery = database.PlayListHasTracks
|
||||||
|
.Where(plt => plt.PlayListId == request.PlayListId);
|
||||||
|
|
||||||
|
var existingTrackIds = (await playListTracksQuery
|
||||||
|
.Select(plt => plt.TrackId)
|
||||||
|
.ToListAsync(cancellationToken)).ToHashSet();
|
||||||
|
|
||||||
|
var newTrackIds = albumTrackIds
|
||||||
|
.Where(trackId => !existingTrackIds.Contains(trackId))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
if (newTrackIds.Count == 0)
|
||||||
|
return new AddAlbumToPlayListResponse(0);
|
||||||
|
|
||||||
|
var nextPosition = existingTrackIds.Count > 0
|
||||||
|
? await playListTracksQuery.MaxAsync(plt => plt.Position, cancellationToken) + 1
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
await database.PlayListHasTracks.AddRangeAsync(
|
||||||
|
newTrackIds.Select((trackId, index) => new PlayListHasTrack
|
||||||
|
{
|
||||||
|
PlayListId = request.PlayListId,
|
||||||
|
TrackId = trackId,
|
||||||
|
Position = nextPosition + index
|
||||||
|
}),
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
await database.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
|
logger.LogInformation("Added {Count} tracks from album {AlbumId} to playlist {PlayListId}",
|
||||||
|
newTrackIds.Count, request.AlbumId, request.PlayListId);
|
||||||
|
|
||||||
|
return new AddAlbumToPlayListResponse(newTrackIds.Count);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
namespace Musify.Application.PlayLists.Responses
|
||||||
|
{
|
||||||
|
public record AddAlbumToPlayListResponse(int AddedCount);
|
||||||
|
}
|
||||||
@@ -94,6 +94,14 @@ public static class PlayListEndpoints
|
|||||||
.Produces(StatusCodes.Status404NotFound)
|
.Produces(StatusCodes.Status404NotFound)
|
||||||
.Produces(StatusCodes.Status409Conflict);
|
.Produces(StatusCodes.Status409Conflict);
|
||||||
|
|
||||||
|
group.MapPost("/{playlistId}/albums/{albumId}", AddAlbumToPlayList)
|
||||||
|
.WithName("AddAlbumToPlayList")
|
||||||
|
.WithSummary("Add All The Tracks Of An Album To One Of Your PlayLists, Skipping Those Already In It.")
|
||||||
|
.RequireAuthorization()
|
||||||
|
.Produces<AddAlbumToPlayListResponse>()
|
||||||
|
.Produces(StatusCodes.Status401Unauthorized)
|
||||||
|
.Produces(StatusCodes.Status404NotFound);
|
||||||
|
|
||||||
group.MapDelete("/{playlistId}/tracks/{trackId}", RemoveTrackFromPlayList)
|
group.MapDelete("/{playlistId}/tracks/{trackId}", RemoveTrackFromPlayList)
|
||||||
.WithName("RemoveTrackFromPlayList")
|
.WithName("RemoveTrackFromPlayList")
|
||||||
.WithSummary("Remove A Track From A PlayList.")
|
.WithSummary("Remove A Track From A PlayList.")
|
||||||
@@ -234,6 +242,20 @@ public static class PlayListEndpoints
|
|||||||
return trackResult.ToCreatedResult(track => $"/tracks/{track.Id}");
|
return trackResult.ToCreatedResult(track => $"/tracks/{track.Id}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> AddAlbumToPlayList(
|
||||||
|
IMediator mediator,
|
||||||
|
CurrentUser currentUser,
|
||||||
|
Guid playlistId,
|
||||||
|
Guid albumId,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var result = await mediator.Send(
|
||||||
|
new AddAlbumToPlayListCommand(currentUser.RequiredId, playlistId, albumId),
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
return result.ToHttpResult();
|
||||||
|
}
|
||||||
|
|
||||||
private static async Task<IResult> RemoveTrackFromPlayList(
|
private static async Task<IResult> RemoveTrackFromPlayList(
|
||||||
IMediator mediator,
|
IMediator mediator,
|
||||||
CurrentUser currentUser,
|
CurrentUser currentUser,
|
||||||
|
|||||||
+91
@@ -0,0 +1,91 @@
|
|||||||
|
using ErrorOr;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Musify.Application.PlayLists;
|
||||||
|
using Musify.Application.Tests.TestSupport;
|
||||||
|
using Musify.Domain.Entities;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Musify.Application.Tests.PlayLists
|
||||||
|
{
|
||||||
|
public sealed class AddAlbumToPlayListCommandHandlerTests : HandlerTestBase
|
||||||
|
{
|
||||||
|
private AddAlbumToPlayListCommandHandler CreateHandler() => new(Database, NoOpLogger<AddAlbumToPlayListCommandHandler>());
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_AddsAlbumTracksInAlbumOrder()
|
||||||
|
{
|
||||||
|
var owner = TestEntities.User();
|
||||||
|
var playList = TestEntities.PlayList(owner.Id);
|
||||||
|
var album = TestEntities.Album(owner.Id);
|
||||||
|
var first = TestEntities.Track(owner, "First");
|
||||||
|
var second = TestEntities.Track(owner, "Second");
|
||||||
|
await SeedAsync(
|
||||||
|
owner, playList, album, first, second,
|
||||||
|
new AlbumHasTrack { AlbumId = album.Id, TrackId = second.Id, TrackNumber = 2 },
|
||||||
|
new AlbumHasTrack { AlbumId = album.Id, TrackId = first.Id, TrackNumber = 1 });
|
||||||
|
|
||||||
|
var result = await CreateHandler().Handle(new AddAlbumToPlayListCommand(owner.Id, playList.Id, album.Id), TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
Assert.False(result.IsError);
|
||||||
|
Assert.Equal(2, result.Value.AddedCount);
|
||||||
|
var links = await Database.PlayListHasTracks.OrderBy(link => link.Position).ToListAsync(TestContext.Current.CancellationToken);
|
||||||
|
Assert.Equal([first.Id, second.Id], links.Select(link => link.TrackId));
|
||||||
|
Assert.Equal([0, 1], links.Select(link => link.Position));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_SkipsTracksAlreadyInPlayList_AndAppendsAfterThem()
|
||||||
|
{
|
||||||
|
var owner = TestEntities.User();
|
||||||
|
var playList = TestEntities.PlayList(owner.Id);
|
||||||
|
var album = TestEntities.Album(owner.Id);
|
||||||
|
var existing = TestEntities.Track(owner, "Existing");
|
||||||
|
var fresh = TestEntities.Track(owner, "Fresh");
|
||||||
|
await SeedAsync(
|
||||||
|
owner, playList, album, existing, fresh,
|
||||||
|
new AlbumHasTrack { AlbumId = album.Id, TrackId = existing.Id, TrackNumber = 1 },
|
||||||
|
new AlbumHasTrack { AlbumId = album.Id, TrackId = fresh.Id, TrackNumber = 2 },
|
||||||
|
new PlayListHasTrack { PlayListId = playList.Id, TrackId = existing.Id, Position = 4 });
|
||||||
|
|
||||||
|
var result = await CreateHandler().Handle(new AddAlbumToPlayListCommand(owner.Id, playList.Id, album.Id), TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
Assert.Equal(1, result.Value.AddedCount);
|
||||||
|
var link = await Database.PlayListHasTracks.SingleAsync(l => l.TrackId == fresh.Id, TestContext.Current.CancellationToken);
|
||||||
|
Assert.Equal(5, link.Position);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_PlayListMissing_ReturnsNotFound()
|
||||||
|
{
|
||||||
|
var result = await CreateHandler().Handle(new AddAlbumToPlayListCommand(1, Guid.NewGuid(), Guid.NewGuid()), TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_NotOwner_ReturnsUnauthorized()
|
||||||
|
{
|
||||||
|
var owner = TestEntities.User(1, "owner");
|
||||||
|
var stranger = TestEntities.User(2, "stranger");
|
||||||
|
var playList = TestEntities.PlayList(owner.Id);
|
||||||
|
var album = TestEntities.Album(owner.Id);
|
||||||
|
await SeedAsync(owner, stranger, playList, album);
|
||||||
|
|
||||||
|
var result = await CreateHandler().Handle(new AddAlbumToPlayListCommand(stranger.Id, playList.Id, album.Id), TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
Assert.Equal(ErrorType.Unauthorized, result.FirstError.Type);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_AlbumMissing_ReturnsNotFound()
|
||||||
|
{
|
||||||
|
var owner = TestEntities.User();
|
||||||
|
var playList = TestEntities.PlayList(owner.Id);
|
||||||
|
await SeedAsync(owner, playList);
|
||||||
|
|
||||||
|
var result = await CreateHandler().Handle(new AddAlbumToPlayListCommand(owner.Id, playList.Id, Guid.NewGuid()), TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+35
-14
@@ -15,7 +15,8 @@ npm run dev # dev server (port 5173)
|
|||||||
npm run build
|
npm run build
|
||||||
npm run check # svelte-check, must exit 0 errors before closing a change
|
npm run check # svelte-check, must exit 0 errors before closing a change
|
||||||
npm run format # prettier --write .
|
npm run format # prettier --write .
|
||||||
npm run lint # prettier --check . && eslint . && npm run lint:tokens
|
npm run test # vitest run (pure functions, mappers, form parsers)
|
||||||
|
npm run lint # prettier --check . && eslint . && npm run lint:tokens && npm run test
|
||||||
npm run lint:tokens # scripts/check-tokens.mjs, the design system guardrail (see below)
|
npm run lint:tokens # scripts/check-tokens.mjs, the design system guardrail (see below)
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -23,6 +24,7 @@ npm run lint:tokens # scripts/check-tokens.mjs, the design system guardrail
|
|||||||
|
|
||||||
- **No comments** in code unless essential.
|
- **No comments** in code unless essential.
|
||||||
- `npm run lint` and `npm run check` must exit clean before closing a change.
|
- `npm run lint` and `npm run check` must exit clean before closing a change.
|
||||||
|
- **Imports:** always `$lib/...`, never `../` (ESLint enforces it). Non-null assertions are forbidden too.
|
||||||
- **Always Svelte 5 runes:** `$props()` with `interface Props`, `$state`, `$derived`, `$effect`. No `export let` or `$:`. `{#each}` always keyed.
|
- **Always Svelte 5 runes:** `$props()` with `interface Props`, `$state`, `$derived`, `$effect`. No `export let` or `$:`. `{#each}` always keyed.
|
||||||
- **Server only code goes under `$lib/server/`**, and it's never imported from client code.
|
- **Server only code goes under `$lib/server/`**, and it's never imported from client code.
|
||||||
- SSR data comes through `load` in `+page.server.ts`/`+layout.server.ts` and lands in the `data` prop.
|
- SSR data comes through `load` in `+page.server.ts`/`+layout.server.ts` and lands in the `data` prop.
|
||||||
@@ -31,25 +33,37 @@ npm run lint:tokens # scripts/check-tokens.mjs, the design system guardrail
|
|||||||
|
|
||||||
## Folder structure
|
## Folder structure
|
||||||
|
|
||||||
- `src/lib/state/` holds reactive rune based state (`*.svelte.ts` files that don't have a more specific home). Player state stays in `$lib/player/`, theme mode in `$lib/theme/`.
|
- `src/lib/types.ts` holds the domain types (`Track`, `Album`, `Playlist`, `Mix`, `Paged<T>`). Pages and the player only ever see these, never raw API DTOs.
|
||||||
- `src/lib/data/` holds client side domain helpers: albums, collections, genres, mixes, recentlyPlayed, search.
|
- `src/lib/player/` is the player: `player.svelte.ts` (state and orchestration), `queue.ts` (pure queue operations), `stream.ts`, `mediaSession.ts`, `progressClock.ts`, `listenTracker.ts`, `liked.svelte.ts`, and `actions.ts` (`playAllOrToggle`, `playShuffled`, `isQueueCurrent`).
|
||||||
- `src/lib/utils/` holds generic utilities with no domain knowledge, like `format.ts`.
|
- `src/lib/state/` holds reactive rune state: `panels.svelte.ts` (queue panel, create playlist modal), `history.svelte.ts` (previous page for `BackLink`), `menu.svelte.ts` (`createMenu<T>()`), `pagedList.svelte.ts` (`createPagedList`), `dialog.svelte.ts`, and `scroll.ts`. Nothing in `state/` imports from `components/`.
|
||||||
- `src/lib/components/layout/` holds the app chrome: Sidebar, TopBar, TabsBar, MobileHeader.
|
- `src/lib/theme/` holds the theme: `accent.svelte.ts` (accent hue per track and the hue fade), `mode.svelte.ts`, `palette.ts`, `tokens.ts`.
|
||||||
|
- `src/lib/data/` holds client side domain helpers: albums, genres, recentlyPlayed, search.
|
||||||
|
- `src/lib/utils/` holds generic utilities: `format.ts`, `collections.ts`, `hrefs.ts` (search, genre and cover URLs), `menuPosition.ts`, `storage.ts`, `transitions.ts`.
|
||||||
|
- `src/lib/validation.ts` holds the form limits (`LIMITS`) shared by client forms and server parsers.
|
||||||
|
- `src/lib/server/` is server only code (see below).
|
||||||
|
- `src/lib/components/layout/` holds the app chrome: Sidebar, TopBar, TabsBar, MobileHeader, and `navLinks.ts`.
|
||||||
- `src/lib/components/player/` holds the player UI: PlayerBar, PlayerDock, Queue, TrackInfo, TransportControls.
|
- `src/lib/components/player/` holds the player UI: PlayerBar, PlayerDock, Queue, TrackInfo, TransportControls.
|
||||||
- `src/lib/components/ui/` holds the design system components, split into five categories:
|
- `src/lib/components/ui/` holds the design system components, split into five categories:
|
||||||
- `primitives/` for generic controls with no music domain knowledge (Button, Input, Chip, Slider, and so on).
|
- `primitives/` for generic controls with no music domain knowledge (Button, Input, Chip, Slider, and so on).
|
||||||
- `overlay/` for anything that floats above the content (Modal, ContextMenu, AccountMenu, and so on).
|
- `overlay/` for anything that floats above the content (Modal, ContextMenu, TrackContextMenu, AlbumContextMenu, AccountMenu, and so on).
|
||||||
- `media/` for music domain display components (Artwork, TrackList, MediaCard, PlayButton, and so on).
|
- `media/` for music domain display components (Artwork, TrackList, MediaCard, PlayButton, PlayAllButton, and so on).
|
||||||
- `forms/` for composite entity forms (AlbumForm, PlaylistForm, CoverForm, ImageDropzone).
|
- `forms/` for composite entity forms (AlbumForm, PlaylistForm, CoverForm, ImageDropzone).
|
||||||
- `layout/` for page level scaffolding (Page, PageHeader, SectionHeading). This is different from `components/layout/` above, which is the global app chrome.
|
- `layout/` for page level scaffolding (Page, PageHeader, SectionHeading). This is different from `components/layout/` above, which is the global app chrome.
|
||||||
- Auth routes live under `src/routes/auth/`: `+page.svelte` is the login screen, and `login/`, `logout/`, `callback/` are the OIDC endpoints. The public user profile route is `src/routes/(app)/user/[id]/`, and its followers/following lists share one route, `user/[id]/[list=followList]/` (matcher in `src/params/followList.ts`).
|
- Everything the user sees after signing in lives under `src/routes/(app)/`, including Home (`(app)/+page.svelte`). Auth routes live under `src/routes/auth/`: `+page.svelte` is the login screen, and `login/`, `logout/`, `callback/` are the OIDC endpoints. The public user profile route is `src/routes/(app)/user/[id]/`, and its followers/following lists share one route, `user/[id]/[list=followList]/` (matcher in `src/params/followList.ts`).
|
||||||
|
|
||||||
|
## Naming conventions and glossary
|
||||||
|
|
||||||
|
- Variant maps are module level constants in upper case (`SIZE`, `VARIANT`).
|
||||||
|
- Errors: destructure as `const { data, error } = ...` and name the caught value `err` in `catch (err)`.
|
||||||
|
- Play handlers: `playFrom(index)` and `playAll()`. Form actions: `create`, `edit`, `delete`, `addTrack`, `removeTrack`.
|
||||||
|
- UI copy: "Playlist" (never "Lista"), "Mezcla" (never "Mix"), "Modo claro". Total durations always go through `fmtDurationLong`.
|
||||||
|
|
||||||
## Styles
|
## Styles
|
||||||
|
|
||||||
The design system lives in `src/routes/layout.css` (`@theme` tokens plus `@utility` recipes) and in `src/lib/theme/` (`theme.css` for the static neutrals, `tokens.ts` for the dynamic tokens that depend on the user's hue). `scripts/check-tokens.mjs` (run as `npm run lint:tokens`, part of `lint`) fails on a stock radius, an `h-N w-N` pair, fractional quarter spacing, a literal hex/`rgba()`, or an arbitrary `-[…]` value outside its whitelist, so check that script before adding one.
|
The design system lives in `src/routes/layout.css` (`@theme` tokens plus `@utility` recipes) and in `src/lib/theme/` (`theme.css` for the static neutrals, `tokens.ts` for the dynamic tokens that depend on the user's hue). `scripts/check-tokens.mjs` (run as `npm run lint:tokens`, part of `lint`) fails on a stock radius, an `h-N w-N` pair, fractional quarter spacing, a literal hex/`rgba()`, or an arbitrary `-[…]` value outside its whitelist, so check that script before adding one.
|
||||||
|
|
||||||
- **Radii:** always `rounded-tag/thumb/control/art/art-lg/panel/panel-lg` (or `rounded-full` for pills and circles). Never `rounded-sm/md/lg/xl/2xl/3xl` or a bare `rounded`.
|
- **Radii:** always `rounded-tag/thumb/control/art/art-lg/panel/panel-lg` (or `rounded-full` for pills and circles). Never `rounded-sm/md/lg/xl/2xl/3xl` or a bare `rounded`.
|
||||||
- **Square sizes:** use `size-*` instead of repeating `h-N w-N` (icons: `size-icon-xs/sm/md/lg/xl`; covers: `size-cover-xs` through `size-cover-hero`).
|
- **Square sizes:** use `size-*` instead of repeating `h-N w-N` (icons: `size-icon-xs/sm/md/lg/xl/2xl/3xl`; covers: `size-cover-xs` through `size-cover-hero`).
|
||||||
- **Typography:** use the recipes `text-display-1/2/3/4`, `text-eyebrow`, `text-body`, with tracking through `tracking-display`/`tracking-eyebrow` rather than a loose `tracking-[…]`.
|
- **Typography:** use the recipes `text-display-1/2/3/4`, `text-eyebrow`, `text-body`, with tracking through `tracking-display`/`tracking-eyebrow` rather than a loose `tracking-[…]`.
|
||||||
- **Spacing:** stick to the Tailwind scale in `.5` steps and avoid quarters like `.25`/`.75`, unless the semantic token calls for it (for example `mb-4.5` in `SectionHeading`).
|
- **Spacing:** stick to the Tailwind scale in `.5` steps and avoid quarters like `.25`/`.75`, unless the semantic token calls for it (for example `mb-4.5` in `SectionHeading`).
|
||||||
- **Colors:** always use `--mf-*`/`text-*`/`bg-*`/`border-*` from `theme.css`/`tokens.ts`. Never a literal hex or `rgba()` inside a `.svelte` file; those literals only belong in `theme.css`/`tokens.ts` as the source of truth.
|
- **Colors:** always use `--mf-*`/`text-*`/`bg-*`/`border-*` from `theme.css`/`tokens.ts`. Never a literal hex or `rgba()` inside a `.svelte` file; those literals only belong in `theme.css`/`tokens.ts` as the source of truth.
|
||||||
@@ -77,12 +91,19 @@ Environment variables live in `.env` (see `.env.example`): `ZITADEL_*`, `AUTH_RE
|
|||||||
|
|
||||||
## Backend API
|
## Backend API
|
||||||
|
|
||||||
Typed client via `openapi-fetch` over the `Musify.Api` OpenAPI spec. Inside a server `load` or action:
|
Typed client via `openapi-fetch` over the `Musify.Api` OpenAPI spec. Inside a server `load` or action use the helpers in `$lib/server/api.ts`:
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
import { createApiClient } from '$lib/server/api';
|
const api = apiFor(event); // or apiFor({ fetch, locals }); adds the bearer token when there is a session
|
||||||
const api = createApiClient({ fetch, accessToken: locals.accessToken ?? undefined });
|
const { data, error } = await api.GET('/tracks');
|
||||||
const { data, error: err } = await api.GET('/tracks');
|
|
||||||
```
|
```
|
||||||
|
|
||||||
It returns `{ data, error }` (ErrorOr style) and never throws. The types live in `src/lib/api/schema.d.ts` and get regenerated with `npm run gen:api` (this needs the dev API running at `API_BASE_URL`, which only exposes `/openapi/v1.json` in Development).
|
It returns `{ data, error }` (ErrorOr style) and never throws except on network errors. The types live in `src/lib/api/schema.d.ts` and get regenerated with `npm run gen:api` (this needs the dev API running at `API_BASE_URL`, which only exposes `/openapi/v1.json` in Development).
|
||||||
|
|
||||||
|
Server helpers:
|
||||||
|
|
||||||
|
- `mappers.ts` (`toTrack`, `toAlbum`, `toPlaylist`, `toMix`, `toPage`, ...) turn DTOs into the domain types. Every load and `/api` endpoint that returns tracks, albums or playlists goes through them, so `Number()` conversions never reach the client.
|
||||||
|
- `authedAction(handler)` wraps a form action: it returns 401 without a token, reads the form and hands `{ api, form, params, locals, url }` to the handler. Use `formString`/`formFile` to read fields, `failOnError(result, message)` to turn an API error into a `fail(...)`, and `requireData(result, message)` when you also need the response data narrowed.
|
||||||
|
- `forms/` holds the form parsers (`parseAlbumForm`, `parsePlaylistForm`, `parseTrackUploadForm`); they validate with `LIMITS` from `$lib/validation`. `uploadOptionalCover` handles the optional cover upload.
|
||||||
|
- `genres.ts` caches the genre list; `playbackConfig.ts` caches the anonymous listening setting.
|
||||||
|
- Page sizes come from `$lib/config`; do not hardcode them.
|
||||||
|
|||||||
+34
-112
@@ -1,128 +1,50 @@
|
|||||||
# Musify, page guide
|
# Musify, page guide
|
||||||
|
|
||||||
Living document describing what each page of the web frontend should contain. It works as a map for prioritizing features. The design system (tokens, recipes, components) lives in the `musify-web` skill; here we describe **content and structure**, not styling.
|
Living document describing what each page of the web frontend contains. The design system (tokens, recipes, components) lives in the `musify-web` skill and in `CLAUDE.md`; here we describe **content and structure**, not styling.
|
||||||
|
|
||||||
Status legend: ✅ done · 🟡 partial / mockup · ⬜ pending.
|
|
||||||
|
|
||||||
> **Note (app redesign):** the `Musify.dc.html` prototype is being ported over (an app with a sidebar, views, and a persistent player with a dynamic accent per track). The marketing landing page was removed. The sections below still describe the older design and are pending an update to match the app views (Home, Search, Library, Upload, Import, Downloads, Playlists). The **player** is already built as components in `$lib/components/player/`, with its theme in `$lib/theme/` (`theme.css` for the palette, `color.ts` for the accent and gradient).
|
|
||||||
|
|
||||||
## Home (app) `/` 🟡
|
|
||||||
|
|
||||||
- **Hero**: a greeting plus "Your music. No limits." with a color wash taken from the current track's hue.
|
|
||||||
- **Quick access, meaning recently played** (`GET /users/{id}/listening-history` merged with `player.recentlyPlayed` via `mergeRecentlyPlayed`). Empty state with an icon and an "Explore" CTA.
|
|
||||||
- **Your playlists**: mosaic cards for the user's playlists (sample data for now). Has an empty state.
|
|
||||||
- **Resume on load**: the root layout fetches `GET /users/{id}/last-listened-track` and hydrates the player (`player.hydrate`) with it, paused, so the player bar isn't empty on a fresh session. Only applies when nothing is already loaded (won't override an in-progress session).
|
|
||||||
|
|
||||||
### Pending backend work (ASP.NET) for Home
|
|
||||||
|
|
||||||
- **Playlists by last listen**: add `lastPlayedAt` to the playlist (the schema currently only has `createdAt`/`updatedAt`) and allow sorting `GET /playlists/users/{userId}` by that field (for example `?sort=lastPlayed`), to show "your most recently played" playlists.
|
|
||||||
- Tracks will need an **artist** and a **color/hue** (or a real cover to extract one from) for the artwork and the dynamic accent. Right now `TrackApplicationResponse` carries neither artist nor color.
|
|
||||||
|
|
||||||
## Route structure
|
## Route structure
|
||||||
|
|
||||||
Routes are grouped to separate the public experience from the authenticated one (the groups don't change the URL):
|
Everything the user sees after signing in lives under the `(app)` group (the group doesn't change the URL). `auth/` and `api/` sit outside it.
|
||||||
|
|
||||||
```
|
```
|
||||||
src/routes/
|
src/routes/
|
||||||
(marketing)/ Public, no session required
|
(app)/
|
||||||
+page.svelte / Landing ✅
|
+page.svelte / Home
|
||||||
(app)/ The app experience
|
explore/ /explore Search and genre discovery
|
||||||
explore/ /explore Explore / search ✅
|
liked/ /liked Liked songs
|
||||||
upload/ /upload Upload music 🟡 mockup
|
library/ /library Your uploaded songs
|
||||||
library/ /library Your library ⬜
|
upload/ /upload Upload a song
|
||||||
playlists/ /playlists Your playlists ⬜
|
albums/ /albums Your albums
|
||||||
[id]/ /playlists/:id Playlist detail ⬜
|
[id]/ /albums/:id Album detail
|
||||||
auth auth/login auth/logout auth/callback Auth (page + endpoints) ✅
|
playlists/ /playlists Your playlists
|
||||||
api/tracks/[id]/stream Stream proxy ✅
|
[id]/ /playlists/:id Playlist detail
|
||||||
|
mixes/[id]/ /mixes/:id Generated mix
|
||||||
|
user/[id]/ /user/:id Public profile
|
||||||
|
[list=followList]/ /user/:id/followers|following
|
||||||
|
auth/ auth/login auth/logout auth/callback Login screen and OIDC endpoints
|
||||||
|
api/ Same origin JSON endpoints used by the client
|
||||||
```
|
```
|
||||||
|
|
||||||
Global components (present on every page): **Navbar**, **Footer**, and **PlayerBar** (the fixed playback bar, shown while something is playing).
|
Global chrome on every app page: sidebar (desktop) or tab bar (mobile), top bar with search and account menu, the persistent player dock, and the queue panel.
|
||||||
|
|
||||||
---
|
## Pages
|
||||||
|
|
||||||
## 1. Landing `/` ✅ (marketing)
|
- **Home `/`**: a greeting, weekly listening stats, "continue listening" (recently played merged with the in-session history), the featured playlist with its first tracks (play and shuffle load the full list on demand), the user's mixes and playlists. Anonymous visitors are redirected to `/explore`.
|
||||||
|
- **Explore `/explore`**: genre tiles when there is no query, otherwise search results grouped by songs, albums, users and playlists with filter chips and counts taken from the API totals. Songs paginate with infinite scroll. Track and album context menus offer play next, add to queue and add to playlist.
|
||||||
|
- **Liked `/liked`**: the songs the user liked, newest first, with unlike per row.
|
||||||
|
- **Library `/library`**: the songs the user uploaded, with a confirmed delete.
|
||||||
|
- **Upload `/upload`**: audio, cover, title, genres (incompatible genres are blocked) and explicit flag. The server reserves presigned URLs, uploads both files and creates the track; processing happens asynchronously in the backend.
|
||||||
|
- **Albums `/albums`, `/albums/:id`**: list and create albums. The owner can edit, delete and add or remove tracks from their library.
|
||||||
|
- **Playlists `/playlists`, `/playlists/:id`**: list and create playlists. The owner can edit, delete and remove tracks; everyone else gets a read only view.
|
||||||
|
- **Mix `/mixes/:id`**: a generated mix with play, shuffle and a track menu.
|
||||||
|
- **Profile `/user/:id`**: public playlists, follow button and links to the followers and following lists, which respect the profile's privacy.
|
||||||
|
- **Auth `/auth`**: the login screen. Its buttons lead to `/auth/login`, which starts the Zitadel OIDC flow; `?mode=register` opens the sign up variant and `?returnTo=` (local paths only) sends the user back afterwards. `/auth/logout` ends the session and `/auth/callback` creates the encrypted `mf_session` cookie.
|
||||||
|
|
||||||
The entry page for logged out visitors. Its job is to sell the product and drive people to sign up or explore.
|
## Data flow
|
||||||
|
|
||||||
- **Hero**: a status badge (beta), a title, a subtitle, a primary CTA (Start for free, leading to sign up) and a secondary one (Explore songs).
|
Each page loads its data in `+page.server.ts` through the typed API client, and normalizes it with the mappers in `$lib/server/mappers.ts` so the client only ever sees the domain types in `$lib/types.ts`. Mutations are form actions built with `authedAction`. Client side pagination goes through the `/api/*` endpoints.
|
||||||
- **Features**: three cards covering listening, playlists, and uploading.
|
|
||||||
- **Suggested next steps**: a screenshots or demo section, social proof, a short FAQ.
|
|
||||||
|
|
||||||
## 2. Login / Auth ✅ (endpoints)
|
## Pending backend work
|
||||||
|
|
||||||
`/auth` is Musify's login screen; its buttons lead to `/auth/login`, which kicks off the Zitadel OIDC flow and redirects. `?mode=register` starts on the sign up variant, and `?returnTo=` sends the user back to the route they came from once they're in. `/auth/logout` ends the session. `/auth/callback` exchanges the code and creates the encrypted `mf_session` cookie. The credentials UI itself is served by Zitadel, not by Musify.
|
- Sorting playlists by last listen (`lastPlayedAt`) for a "recently played playlists" shelf.
|
||||||
|
- An endpoint returning the most listened tracks, to bring back a "popular" section on Home.
|
||||||
## 3. Explore / Search `/explore` ✅ (app)
|
|
||||||
|
|
||||||
Discovery and search over the catalog.
|
|
||||||
|
|
||||||
- **Search box**: a text input (`?q=`) that filters by name on the backend.
|
|
||||||
- **Results count** and a **track grid** (2 to 4 columns): cover, title, date, and a play/pause button on hover that queues and plays through the global player.
|
|
||||||
- **Pagination**: previous/next (`?page=`, 24 per page).
|
|
||||||
- **Empty state**: no results, or an empty catalog.
|
|
||||||
- **Suggested next steps**: filters (most recent, by user), loading skeletons, a real track cover (the listing API doesn't currently return the image key).
|
|
||||||
|
|
||||||
## 4. Upload music `/upload` 🟡 mockup (app)
|
|
||||||
|
|
||||||
Adding a new song. The real backend flow, for when it gets wired up:
|
|
||||||
|
|
||||||
1. `POST /tracks/upload-urls` returns presigned URLs for cover and audio, plus `intentId`s.
|
|
||||||
2. `PUT` each file to its presigned URL (a direct upload to storage).
|
|
||||||
3. `POST /tracks` with `title`, `pictureIntentId`, `audioIntentId`.
|
|
||||||
4. The backend processes the audio asynchronously (transcoding it to DASH).
|
|
||||||
|
|
||||||
What the page needs:
|
|
||||||
|
|
||||||
- **Audio zone**: drag and drop or a file picker, showing the file's name and size, with type (audio) and size validation.
|
|
||||||
- **Cover**: an image picker with a square preview.
|
|
||||||
- **Metadata**: title (required), with room for future fields like a description.
|
|
||||||
- **Visible pipeline**: the Upload, Process, Publish steps and their status.
|
|
||||||
- **Progress bar** for the upload and the processing state.
|
|
||||||
- **States**: empty, file selected, uploading, processing, published, error.
|
|
||||||
- **Requires a session** (once wired up: redirect to `/auth?returnTo=/upload`).
|
|
||||||
|
|
||||||
## 5. Your library `/library` ⬜ (app)
|
|
||||||
|
|
||||||
The authenticated user's personal panel, everything of theirs in one place.
|
|
||||||
|
|
||||||
- **Tabs / sections**: "Your songs" (`GET /tracks/users/{userId}`) and "Your playlists" (`GET /playlists/users/{userId}`).
|
|
||||||
- **Your songs**: a list with play, plus management actions (delete via `DELETE /tracks/{id}`, add to a playlist).
|
|
||||||
- **Your playlists**: a grid of covers with a "Create playlist" button.
|
|
||||||
- **Empty state** with a CTA to upload or create.
|
|
||||||
- **Requires a session.**
|
|
||||||
|
|
||||||
## 6. Playlists `/playlists` ⬜ (app)
|
|
||||||
|
|
||||||
A listing of the user's playlists.
|
|
||||||
|
|
||||||
- **Grid** of playlists (cover, name, track count), linking to the detail page.
|
|
||||||
- **Create playlist**: a modal or page with a name, description, and cover (`POST /playlists`, cover via `POST /playlists/upload-picture`).
|
|
||||||
- **Empty state** with a CTA.
|
|
||||||
- **Requires a session.**
|
|
||||||
|
|
||||||
## 7. Playlist detail `/playlists/[id]` ⬜ (app)
|
|
||||||
|
|
||||||
- **Header**: a large cover, name, description, track count, and a "Play all" button.
|
|
||||||
- **Track list** (`GET /playlists/{id}/tracks`, paginated): index, title, date, play, and remove from the playlist (`DELETE /playlists/{id}/tracks/{trackId}`).
|
|
||||||
- **Owner actions**: edit (`PUT`), delete (`DELETE`) the playlist, reorder (a future addition).
|
|
||||||
- **Add tracks**: from explore or the library (`POST /playlists/{id}/tracks/{trackId}`).
|
|
||||||
|
|
||||||
## 8. User profile `/user/[id]` ⬜ (future)
|
|
||||||
|
|
||||||
A public view of a user: avatar, name, their public songs and playlists (`GET /users/{id}`, `GET /tracks/users/{id}`, `GET /playlists/users/{id}`).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Global player (PlayerBar) ✅
|
|
||||||
|
|
||||||
A fixed bottom bar, visible while something is playing. Shows track info, transport controls (previous, play/pause, next), a progress bar, and volume control. Queues lists from any grid (`player.playQueue`). Plays DASH through the `/api/tracks/:id/stream` proxy, which requests a manifest and a signed ticket from the backend.
|
|
||||||
|
|
||||||
## Available backend (summary)
|
|
||||||
|
|
||||||
| Resource | Endpoints |
|
|
||||||
| --------- | ----------------------------------------------------------------- |
|
|
||||||
| Tracks | list/search, detail, by user, stream, create, delete, upload-urls |
|
|
||||||
| Playlists | CRUD, playlist tracks, add/remove track, upload-picture |
|
|
||||||
| Users | list/search, detail, create |
|
|
||||||
|
|
||||||
Typed client in `$lib/server/api.ts` (`openapi-fetch`, ErrorOr style). Use it inside `load`/server actions, never from the client. Types live in `src/lib/api/schema.d.ts`.
|
|
||||||
|
|||||||
@@ -19,6 +19,19 @@ export default ts.config(
|
|||||||
'error',
|
'error',
|
||||||
{ argsIgnorePattern: '^_', varsIgnorePattern: '^_' }
|
{ argsIgnorePattern: '^_', varsIgnorePattern: '^_' }
|
||||||
],
|
],
|
||||||
|
'@typescript-eslint/no-non-null-assertion': 'error',
|
||||||
|
'no-restricted-imports': [
|
||||||
|
'error',
|
||||||
|
{
|
||||||
|
patterns: [
|
||||||
|
{
|
||||||
|
group: ['../*'],
|
||||||
|
message: 'Use $lib/... instead of relative parent imports.'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
'svelte/no-unused-props': 'error',
|
||||||
'svelte/no-navigation-without-resolve': 'off',
|
'svelte/no-navigation-without-resolve': 'off',
|
||||||
'svelte/prefer-svelte-reactivity': 'off'
|
'svelte/prefer-svelte-reactivity': 'off'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,12 +24,16 @@ const PAIRED_HW = /\b(?:([a-z0-9-]+:))?h-([\d.]+)\s+\1w-\2\b/g;
|
|||||||
const QUARTER_STEP_SPACING = /\b[a-z-]+-\d+\.(25|75)\b/g;
|
const QUARTER_STEP_SPACING = /\b[a-z-]+-\d+\.(25|75)\b/g;
|
||||||
const HEX_COLOR = /#[0-9a-fA-F]{3,8}\b/g;
|
const HEX_COLOR = /#[0-9a-fA-F]{3,8}\b/g;
|
||||||
const RGBA_LITERAL = /\brgba?\(/g;
|
const RGBA_LITERAL = /\brgba?\(/g;
|
||||||
|
const LUCIDE_IMPORT = /import (\w+) from '@lucide\/svelte\/icons\/[\w-]+'/g;
|
||||||
|
const ICON_PIXEL_SIZE = /\bsize=\{\d+\}/;
|
||||||
|
const ICON_RAW_SIZE = /\bclass="[^"]*\bsize-\d[\w.]*/;
|
||||||
|
|
||||||
let violations = [];
|
let violations = [];
|
||||||
|
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
const content = readFileSync(file, 'utf8');
|
const content = readFileSync(file, 'utf8');
|
||||||
const lines = content.split('\n');
|
const lines = content.split('\n');
|
||||||
|
const icons = [...content.matchAll(LUCIDE_IMPORT)].map((m) => m[1]);
|
||||||
|
|
||||||
lines.forEach((line, i) => {
|
lines.forEach((line, i) => {
|
||||||
const lineNo = i + 1;
|
const lineNo = i + 1;
|
||||||
@@ -40,6 +44,17 @@ for (const file of files) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for (const icon of icons) {
|
||||||
|
const tag = line.match(new RegExp(`<${icon}\\s[^>]*`));
|
||||||
|
if (!tag) continue;
|
||||||
|
if (ICON_PIXEL_SIZE.test(tag[0])) {
|
||||||
|
violations.push(`${file}:${lineNo} — icono con size={N}: usa una clase size-icon-*`);
|
||||||
|
}
|
||||||
|
if (ICON_RAW_SIZE.test(tag[0])) {
|
||||||
|
violations.push(`${file}:${lineNo} — icono con size-N: usa una clase size-icon-*`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for (const match of line.matchAll(RAW_ROUNDED)) {
|
for (const match of line.matchAll(RAW_ROUNDED)) {
|
||||||
violations.push(`${file}:${lineNo} — radio de Tailwind sin tokenizar: \`${match[0]}\``);
|
violations.push(`${file}:${lineNo} — radio de Tailwind sin tokenizar: \`${match[0]}\``);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import type { Action } from 'svelte/action';
|
||||||
|
|
||||||
|
export const onEscape: Action<HTMLElement, (() => void) | undefined> = (_node, handler) => {
|
||||||
|
function onKeydown(event: KeyboardEvent) {
|
||||||
|
if (event.key === 'Escape') handler?.();
|
||||||
|
}
|
||||||
|
window.addEventListener('keydown', onKeydown);
|
||||||
|
|
||||||
|
return {
|
||||||
|
update(next) {
|
||||||
|
handler = next;
|
||||||
|
},
|
||||||
|
destroy() {
|
||||||
|
window.removeEventListener('keydown', onKeydown);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
Vendored
+62
@@ -331,6 +331,23 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/playlists/{playlistId}/albums/{albumId}": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put?: never;
|
||||||
|
/** Add All The Tracks Of An Album To One Of Your PlayLists, Skipping Those Already In It. */
|
||||||
|
post: operations["AddAlbumToPlayList"];
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/playlists/{playlistId}/tracks": {
|
"/playlists/{playlistId}/tracks": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -731,6 +748,10 @@ export interface paths {
|
|||||||
export type webhooks = Record<string, never>;
|
export type webhooks = Record<string, never>;
|
||||||
export interface components {
|
export interface components {
|
||||||
schemas: {
|
schemas: {
|
||||||
|
AddAlbumToPlayListResponse: {
|
||||||
|
/** Format: int32 */
|
||||||
|
addedCount: number | string;
|
||||||
|
};
|
||||||
AddPlayListTrackRequest: {
|
AddPlayListTrackRequest: {
|
||||||
/** Format: uuid */
|
/** Format: uuid */
|
||||||
trackId: string;
|
trackId: string;
|
||||||
@@ -942,6 +963,10 @@ export interface components {
|
|||||||
largeImageKeyName: null | string;
|
largeImageKeyName: null | string;
|
||||||
ownerUserId: string;
|
ownerUserId: string;
|
||||||
visibility: components["schemas"]["PlaylistVisibility"];
|
visibility: components["schemas"]["PlaylistVisibility"];
|
||||||
|
/** Format: int32 */
|
||||||
|
trackCount: number | string;
|
||||||
|
/** Format: double */
|
||||||
|
durationSeconds: number | string;
|
||||||
/** Format: date-time */
|
/** Format: date-time */
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
/** Format: date-time */
|
/** Format: date-time */
|
||||||
@@ -1888,6 +1913,43 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
AddAlbumToPlayList: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
playlistId: string;
|
||||||
|
albumId: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description OK */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["AddAlbumToPlayListResponse"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Unauthorized */
|
||||||
|
401: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
/** @description Not Found */
|
||||||
|
404: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
AddPlayListTrack: {
|
AddPlayListTrack: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
|
|||||||
@@ -3,11 +3,10 @@
|
|||||||
import Upload from '@lucide/svelte/icons/upload';
|
import Upload from '@lucide/svelte/icons/upload';
|
||||||
import Folder from '@lucide/svelte/icons/folder';
|
import Folder from '@lucide/svelte/icons/folder';
|
||||||
import LogIn from '@lucide/svelte/icons/log-in';
|
import LogIn from '@lucide/svelte/icons/log-in';
|
||||||
import MenuItem from '../ui/overlay/MenuItem.svelte';
|
import MenuItem from '$lib/components/ui/overlay/MenuItem.svelte';
|
||||||
import AccountMenu from '../ui/overlay/AccountMenu.svelte';
|
import AccountMenu from '$lib/components/ui/overlay/AccountMenu.svelte';
|
||||||
import Avatar from '../ui/media/Avatar.svelte';
|
import IconButton from '$lib/components/ui/primitives/IconButton.svelte';
|
||||||
import IconButton from '../ui/primitives/IconButton.svelte';
|
import Logo from '$lib/components/ui/primitives/Logo.svelte';
|
||||||
import Logo from '../ui/primitives/Logo.svelte';
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
user: SessionUser | null;
|
user: SessionUser | null;
|
||||||
@@ -26,17 +25,6 @@
|
|||||||
|
|
||||||
{#if user}
|
{#if user}
|
||||||
<AccountMenu {user} {accountUrl} width="md">
|
<AccountMenu {user} {accountUrl} width="md">
|
||||||
{#snippet trigger({ toggle, open })}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onclick={toggle}
|
|
||||||
aria-label="Tu cuenta"
|
|
||||||
aria-expanded={open}
|
|
||||||
class="flex items-center rounded-full"
|
|
||||||
>
|
|
||||||
<Avatar name={user.name} src={user.picture} size="sm" />
|
|
||||||
</button>
|
|
||||||
{/snippet}
|
|
||||||
{#snippet extraItems({ close })}
|
{#snippet extraItems({ close })}
|
||||||
<MenuItem icon={Folder} label="Canciones subidas" href="/library" onclick={close} />
|
<MenuItem icon={Folder} label="Canciones subidas" href="/library" onclick={close} />
|
||||||
<MenuItem icon={Upload} label="Subir música" href="/upload" onclick={close} />
|
<MenuItem icon={Upload} label="Subir música" href="/upload" onclick={close} />
|
||||||
|
|||||||
@@ -1,20 +1,21 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
import { isSectionActive, appNavLinks } from '$lib/state/navigation.svelte';
|
import { appNavLinks, isNavActive } from './navLinks';
|
||||||
|
import { playlistCover } from '$lib/utils/hrefs';
|
||||||
import { liked } from '$lib/player/liked.svelte';
|
import { liked } from '$lib/player/liked.svelte';
|
||||||
import { createPlaylistModal } from '$lib/state/playlists.svelte';
|
import { createPlaylistModal } from '$lib/state/panels.svelte';
|
||||||
import type { SessionUser } from '$lib/types';
|
import type { SessionUser } from '$lib/types';
|
||||||
import ListRow from '../ui/media/ListRow.svelte';
|
import ListRow from '$lib/components/ui/media/ListRow.svelte';
|
||||||
import IconButton from '../ui/primitives/IconButton.svelte';
|
import IconButton from '$lib/components/ui/primitives/IconButton.svelte';
|
||||||
import Plus from '@lucide/svelte/icons/plus';
|
import Plus from '@lucide/svelte/icons/plus';
|
||||||
import Logo from '../ui/primitives/Logo.svelte';
|
import Logo from '$lib/components/ui/primitives/Logo.svelte';
|
||||||
|
|
||||||
const SIDEBAR_PLAYLISTS_LIMIT = 8;
|
const SIDEBAR_PLAYLISTS_LIMIT = 8;
|
||||||
|
|
||||||
interface SidebarPlaylist {
|
interface SidebarPlaylist {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
coverTrackIds: (string | number)[];
|
coverTrackIds: string[];
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -29,15 +30,10 @@
|
|||||||
const visiblePlaylists = $derived(playlists.slice(0, SIDEBAR_PLAYLISTS_LIMIT));
|
const visiblePlaylists = $derived(playlists.slice(0, SIDEBAR_PLAYLISTS_LIMIT));
|
||||||
|
|
||||||
const navLinks = $derived(appNavLinks(user, { playlists: playlistCount, liked: liked.count }));
|
const navLinks = $derived(appNavLinks(user, { playlists: playlistCount, liked: liked.count }));
|
||||||
|
|
||||||
function isActive(href: string) {
|
|
||||||
return isSectionActive(href, page.url.pathname, page.data.section);
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<aside
|
<aside
|
||||||
class="hidden min-h-0 shrink-0 flex-col border-r border-hairline p-5 lg:flex"
|
class="hidden min-h-0 w-(--mf-sidebar-w) shrink-0 flex-col border-r border-hairline p-5 lg:flex"
|
||||||
style="width:var(--mf-sidebar-w)"
|
|
||||||
>
|
>
|
||||||
<a href="/" class="mb-4">
|
<a href="/" class="mb-4">
|
||||||
<Logo size="sm" />
|
<Logo size="sm" />
|
||||||
@@ -45,7 +41,7 @@
|
|||||||
|
|
||||||
<nav class="flex flex-col gap-1">
|
<nav class="flex flex-col gap-1">
|
||||||
{#each navLinks as link (link.href)}
|
{#each navLinks as link (link.href)}
|
||||||
{@const active = isActive(link.href)}
|
{@const active = isNavActive(link.href)}
|
||||||
<a
|
<a
|
||||||
href={link.href}
|
href={link.href}
|
||||||
aria-current={active ? 'page' : undefined}
|
aria-current={active ? 'page' : undefined}
|
||||||
@@ -78,7 +74,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex min-h-0 flex-1 flex-col gap-px overflow-y-auto pb-icon-md">
|
<div class="flex min-h-0 flex-1 flex-col gap-px overflow-y-auto pb-4.5">
|
||||||
{#each visiblePlaylists as playlist (playlist.id)}
|
{#each visiblePlaylists as playlist (playlist.id)}
|
||||||
{@const active = page.url.pathname === `/playlists/${playlist.id}`}
|
{@const active = page.url.pathname === `/playlists/${playlist.id}`}
|
||||||
<ListRow
|
<ListRow
|
||||||
@@ -86,9 +82,7 @@
|
|||||||
{active}
|
{active}
|
||||||
size="xs"
|
size="xs"
|
||||||
title={playlist.name}
|
title={playlist.name}
|
||||||
coverSrc="/api/playlists/{playlist.id}/cover?size=small&v={encodeURIComponent(
|
coverSrc={playlistCover(playlist, 'small')}
|
||||||
playlist.updatedAt
|
|
||||||
)}"
|
|
||||||
trackIds={playlist.coverTrackIds}
|
trackIds={playlist.coverTrackIds}
|
||||||
class="px-3 py-2"
|
class="px-3 py-2"
|
||||||
/>
|
/>
|
||||||
@@ -98,8 +92,8 @@
|
|||||||
onclick={() => createPlaylistModal.show()}
|
onclick={() => createPlaylistModal.show()}
|
||||||
class="flex items-center gap-2 rounded-control px-3 py-2 text-xs text-fg-2 transition-colors hover:bg-hover"
|
class="flex items-center gap-2 rounded-control px-3 py-2 text-xs text-fg-2 transition-colors hover:bg-hover"
|
||||||
>
|
>
|
||||||
<Plus class="size-3.5" />
|
<Plus class="size-icon-xs" />
|
||||||
Crear tu primera lista
|
Crear tu primera playlist
|
||||||
</button>
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { page } from '$app/state';
|
import { appNavLinks, isNavActive } from './navLinks';
|
||||||
import { isSectionActive, appNavLinks } from '$lib/state/navigation.svelte';
|
|
||||||
import type { SessionUser } from '$lib/types';
|
import type { SessionUser } from '$lib/types';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -11,10 +10,6 @@
|
|||||||
let { user, class: klass = '' }: Props = $props();
|
let { user, class: klass = '' }: Props = $props();
|
||||||
|
|
||||||
const links = $derived(appNavLinks(user).filter((link) => link.primary));
|
const links = $derived(appNavLinks(user).filter((link) => link.primary));
|
||||||
|
|
||||||
function isActive(href: string) {
|
|
||||||
return isSectionActive(href, page.url.pathname, page.data.section);
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<nav
|
<nav
|
||||||
@@ -22,7 +17,7 @@
|
|||||||
style="padding-bottom:calc(0.4375rem + var(--mf-safe-b))"
|
style="padding-bottom:calc(0.4375rem + var(--mf-safe-b))"
|
||||||
>
|
>
|
||||||
{#each links as link (link.href)}
|
{#each links as link (link.href)}
|
||||||
{@const active = isActive(link.href)}
|
{@const active = isNavActive(link.href)}
|
||||||
<a
|
<a
|
||||||
href={link.href}
|
href={link.href}
|
||||||
aria-current={active ? 'page' : undefined}
|
aria-current={active ? 'page' : undefined}
|
||||||
@@ -30,7 +25,7 @@
|
|||||||
? 'text-fg'
|
? 'text-fg'
|
||||||
: 'text-fg-2'}"
|
: 'text-fg-2'}"
|
||||||
>
|
>
|
||||||
<link.icon class="size-4" />
|
<link.icon class="size-icon-sm" />
|
||||||
<span class="text-xs font-medium">{link.label}</span>
|
<span class="text-xs font-medium">{link.label}</span>
|
||||||
</a>
|
</a>
|
||||||
{/each}
|
{/each}
|
||||||
|
|||||||
@@ -3,14 +3,13 @@
|
|||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
import type { SessionUser } from '$lib/types';
|
import type { SessionUser } from '$lib/types';
|
||||||
import { SEARCH_DEBOUNCE_MS, SEARCH_MIN_LENGTH } from '$lib/config';
|
import { SEARCH_DEBOUNCE_MS, SEARCH_MIN_LENGTH } from '$lib/config';
|
||||||
import { searchHref } from '$lib/state/navigation.svelte';
|
import { searchHref } from '$lib/utils/hrefs';
|
||||||
import Search from '@lucide/svelte/icons/search';
|
import Search from '@lucide/svelte/icons/search';
|
||||||
import X from '@lucide/svelte/icons/x';
|
import X from '@lucide/svelte/icons/x';
|
||||||
import ChevronLeft from '@lucide/svelte/icons/chevron-left';
|
import ChevronLeft from '@lucide/svelte/icons/chevron-left';
|
||||||
import AccountMenu from '../ui/overlay/AccountMenu.svelte';
|
import AccountMenu from '$lib/components/ui/overlay/AccountMenu.svelte';
|
||||||
import Avatar from '../ui/media/Avatar.svelte';
|
import IconButton from '$lib/components/ui/primitives/IconButton.svelte';
|
||||||
import IconButton from '../ui/primitives/IconButton.svelte';
|
import Button from '$lib/components/ui/primitives/Button.svelte';
|
||||||
import Button from '../ui/primitives/Button.svelte';
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
user: SessionUser | null;
|
user: SessionUser | null;
|
||||||
@@ -62,7 +61,7 @@
|
|||||||
<label
|
<label
|
||||||
class="flex h-9.5 w-full items-center gap-2.5 rounded-control bg-surface-2 px-3.5 transition focus-within:bg-surface-hover"
|
class="flex h-9.5 w-full items-center gap-2.5 rounded-control bg-surface-2 px-3.5 transition focus-within:bg-surface-hover"
|
||||||
>
|
>
|
||||||
<Search class="size-4 shrink-0 text-fg-2" />
|
<Search class="size-icon-sm shrink-0 text-fg-2" />
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
bind:this={searchInput}
|
bind:this={searchInput}
|
||||||
@@ -78,7 +77,7 @@
|
|||||||
aria-label="Borrar búsqueda"
|
aria-label="Borrar búsqueda"
|
||||||
class="grid size-5 shrink-0 place-items-center rounded-full text-fg-2 transition hover:text-fg"
|
class="grid size-5 shrink-0 place-items-center rounded-full text-fg-2 transition hover:text-fg"
|
||||||
>
|
>
|
||||||
<X class="size-3.5" />
|
<X class="size-icon-xs" />
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
</label>
|
</label>
|
||||||
@@ -86,24 +85,7 @@
|
|||||||
|
|
||||||
<div class="ml-auto shrink-0">
|
<div class="ml-auto shrink-0">
|
||||||
{#if user}
|
{#if user}
|
||||||
<AccountMenu {user} {accountUrl}>
|
<AccountMenu {user} {accountUrl} />
|
||||||
{#snippet trigger({ toggle, open })}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onclick={toggle}
|
|
||||||
aria-label="Tu cuenta"
|
|
||||||
aria-expanded={open}
|
|
||||||
class="block shrink-0 rounded-full transition active:scale-95"
|
|
||||||
>
|
|
||||||
<Avatar
|
|
||||||
name={user.name}
|
|
||||||
src={user.picture}
|
|
||||||
size="sm"
|
|
||||||
class="border border-line-strong opacity-90"
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
{/snippet}
|
|
||||||
</AccountMenu>
|
|
||||||
{:else}
|
{:else}
|
||||||
<Button href="/auth/login" reload variant="accent" size="sm" class="shrink-0"
|
<Button href="/auth/login" reload variant="accent" size="sm" class="shrink-0"
|
||||||
>Iniciar sesión</Button
|
>Iniciar sesión</Button
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { page } from '$app/state';
|
||||||
|
import type { LucideIcon } from '@lucide/svelte';
|
||||||
|
import Home from '@lucide/svelte/icons/house';
|
||||||
|
import Compass from '@lucide/svelte/icons/compass';
|
||||||
|
import ListMusic from '@lucide/svelte/icons/list-music';
|
||||||
|
import Heart from '@lucide/svelte/icons/heart';
|
||||||
|
import Disc from '@lucide/svelte/icons/disc-2';
|
||||||
|
import Folder from '@lucide/svelte/icons/folder';
|
||||||
|
import Upload from '@lucide/svelte/icons/upload';
|
||||||
|
import type { SessionUser } from '$lib/types';
|
||||||
|
|
||||||
|
export interface AppNavLink {
|
||||||
|
href: string;
|
||||||
|
label: string;
|
||||||
|
icon: LucideIcon;
|
||||||
|
count?: number;
|
||||||
|
primary: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AppNavCounts {
|
||||||
|
playlists?: number;
|
||||||
|
liked?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isNavActive(href: string): boolean {
|
||||||
|
const section: string | null | undefined = page.data.section;
|
||||||
|
if (section !== undefined) return section === href;
|
||||||
|
return href === '/' ? page.url.pathname === '/' : page.url.pathname.startsWith(href);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function appNavLinks(user: SessionUser | null, counts: AppNavCounts = {}): AppNavLink[] {
|
||||||
|
if (!user) return [{ href: '/explore', label: 'Descubrir', icon: Compass, primary: true }];
|
||||||
|
return [
|
||||||
|
{ href: '/', label: 'Inicio', icon: Home, primary: true },
|
||||||
|
{ href: '/explore', label: 'Descubrir', icon: Compass, primary: true },
|
||||||
|
{
|
||||||
|
href: '/playlists',
|
||||||
|
label: 'Playlists',
|
||||||
|
icon: ListMusic,
|
||||||
|
count: counts.playlists,
|
||||||
|
primary: true
|
||||||
|
},
|
||||||
|
{ href: '/liked', label: 'Me gusta', icon: Heart, count: counts.liked, primary: true },
|
||||||
|
{ href: '/albums', label: 'Mis álbumes', icon: Disc, primary: false },
|
||||||
|
{ href: '/library', label: 'Canciones subidas', icon: Folder, primary: false },
|
||||||
|
{ href: '/upload', label: 'Subir música', icon: Upload, primary: false }
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
import Slider from '$lib/components/ui/primitives/Slider.svelte';
|
import Slider from '$lib/components/ui/primitives/Slider.svelte';
|
||||||
import IconButton from '$lib/components/ui/primitives/IconButton.svelte';
|
import IconButton from '$lib/components/ui/primitives/IconButton.svelte';
|
||||||
import { player } from '$lib/player/player.svelte';
|
import { player } from '$lib/player/player.svelte';
|
||||||
import { queuePanel } from '$lib/player/queuePanel.svelte';
|
import { queuePanel } from '$lib/state/panels.svelte';
|
||||||
import { fmtTime } from '$lib/utils/format';
|
import { fmtTime } from '$lib/utils/format';
|
||||||
import Volume1 from '@lucide/svelte/icons/volume-1';
|
import Volume1 from '@lucide/svelte/icons/volume-1';
|
||||||
import Volume2 from '@lucide/svelte/icons/volume-2';
|
import Volume2 from '@lucide/svelte/icons/volume-2';
|
||||||
@@ -17,7 +17,7 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="animate-enter pointer-events-auto flex items-center gap-icon-md px-4 py-3 glass-bar sm:hidden"
|
class="animate-enter pointer-events-auto flex items-center gap-4.5 px-4 py-3 glass-bar sm:hidden"
|
||||||
>
|
>
|
||||||
<div class="min-w-0 flex-1">
|
<div class="min-w-0 flex-1">
|
||||||
<TrackInfo compact />
|
<TrackInfo compact />
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import PlayerBar from './PlayerBar.svelte';
|
import PlayerBar from './PlayerBar.svelte';
|
||||||
import TabsBar from '../layout/TabsBar.svelte';
|
import TabsBar from '$lib/components/layout/TabsBar.svelte';
|
||||||
import { player } from '$lib/player/player.svelte';
|
import { player } from '$lib/player/player.svelte';
|
||||||
import type { SessionUser } from '$lib/types';
|
import type { SessionUser } from '$lib/types';
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { player } from '$lib/player/player.svelte';
|
import { player } from '$lib/player/player.svelte';
|
||||||
import { queuePanel } from '$lib/player/queuePanel.svelte';
|
import { queuePanel } from '$lib/state/panels.svelte';
|
||||||
import MediaIdentity from '$lib/components/ui/media/MediaIdentity.svelte';
|
import MediaIdentity from '$lib/components/ui/media/MediaIdentity.svelte';
|
||||||
import ListRow from '$lib/components/ui/media/ListRow.svelte';
|
import ListRow from '$lib/components/ui/media/ListRow.svelte';
|
||||||
import IconButton from '$lib/components/ui/primitives/IconButton.svelte';
|
import IconButton from '$lib/components/ui/primitives/IconButton.svelte';
|
||||||
@@ -43,8 +43,7 @@
|
|||||||
inert={!queuePanel.open}
|
inert={!queuePanel.open}
|
||||||
>
|
>
|
||||||
<aside
|
<aside
|
||||||
class="flex min-h-0 shrink-0 flex-col border-l border-hairline px-4 pt-5 pb-5"
|
class="flex min-h-0 w-(--mf-queue-w) shrink-0 flex-col border-l border-hairline px-4 pt-5 pb-5"
|
||||||
style="width:var(--mf-queue-w)"
|
|
||||||
>
|
>
|
||||||
<div class="mb-5 flex items-center justify-between">
|
<div class="mb-5 flex items-center justify-between">
|
||||||
<h2 class="font-display text-base font-medium tracking-tight text-fg">En cola</h2>
|
<h2 class="font-display text-base font-medium tracking-tight text-fg">En cola</h2>
|
||||||
|
|||||||
@@ -26,7 +26,7 @@
|
|||||||
aria-label="Aleatorio"
|
aria-label="Aleatorio"
|
||||||
class="transition-colors hover:text-fg {player.shuffle ? 'text-accent' : 'text-fg-2'}"
|
class="transition-colors hover:text-fg {player.shuffle ? 'text-accent' : 'text-fg-2'}"
|
||||||
>
|
>
|
||||||
<Shuffle class="size-4" />
|
<Shuffle class="size-icon-sm" />
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
@@ -58,7 +58,7 @@
|
|||||||
aria-label="Repetir"
|
aria-label="Repetir"
|
||||||
class="transition-colors hover:text-fg {player.repeat ? 'text-accent' : 'text-fg-2'}"
|
class="transition-colors hover:text-fg {player.repeat ? 'text-accent' : 'text-fg-2'}"
|
||||||
>
|
>
|
||||||
<Repeat class="size-4" />
|
<Repeat class="size-icon-sm" />
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,9 +2,9 @@
|
|||||||
import { untrack } from 'svelte';
|
import { untrack } from 'svelte';
|
||||||
import Disc3 from '@lucide/svelte/icons/disc-3';
|
import Disc3 from '@lucide/svelte/icons/disc-3';
|
||||||
import CoverForm from './CoverForm.svelte';
|
import CoverForm from './CoverForm.svelte';
|
||||||
import Field from '../primitives/Field.svelte';
|
import Field from '$lib/components/ui/primitives/Field.svelte';
|
||||||
import Input from '../primitives/Input.svelte';
|
import Input from '$lib/components/ui/primitives/Input.svelte';
|
||||||
import Textarea from '../primitives/Textarea.svelte';
|
import Textarea from '$lib/components/ui/primitives/Textarea.svelte';
|
||||||
import { ALBUM_EARLIEST_YEAR, LIMITS } from '$lib/validation';
|
import { ALBUM_EARLIEST_YEAR, LIMITS } from '$lib/validation';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
|||||||
@@ -3,9 +3,9 @@
|
|||||||
import { enhance } from '$app/forms';
|
import { enhance } from '$app/forms';
|
||||||
import type { LucideIcon } from '@lucide/svelte';
|
import type { LucideIcon } from '@lucide/svelte';
|
||||||
import ImageDropzone from './ImageDropzone.svelte';
|
import ImageDropzone from './ImageDropzone.svelte';
|
||||||
import Button from '../primitives/Button.svelte';
|
import Button from '$lib/components/ui/primitives/Button.svelte';
|
||||||
import Field from '../primitives/Field.svelte';
|
import Field from '$lib/components/ui/primitives/Field.svelte';
|
||||||
import Alert from '../primitives/Alert.svelte';
|
import Alert from '$lib/components/ui/primitives/Alert.svelte';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
action: string;
|
action: string;
|
||||||
|
|||||||
@@ -2,10 +2,10 @@
|
|||||||
import { untrack } from 'svelte';
|
import { untrack } from 'svelte';
|
||||||
import Music from '@lucide/svelte/icons/music';
|
import Music from '@lucide/svelte/icons/music';
|
||||||
import CoverForm from './CoverForm.svelte';
|
import CoverForm from './CoverForm.svelte';
|
||||||
import Field from '../primitives/Field.svelte';
|
import Field from '$lib/components/ui/primitives/Field.svelte';
|
||||||
import Input from '../primitives/Input.svelte';
|
import Input from '$lib/components/ui/primitives/Input.svelte';
|
||||||
import Textarea from '../primitives/Textarea.svelte';
|
import Textarea from '$lib/components/ui/primitives/Textarea.svelte';
|
||||||
import SegmentedControl from '../primitives/SegmentedControl.svelte';
|
import SegmentedControl from '$lib/components/ui/primitives/SegmentedControl.svelte';
|
||||||
|
|
||||||
import { LIMITS } from '$lib/validation';
|
import { LIMITS } from '$lib/validation';
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
interface Props {
|
interface Props {
|
||||||
name?: string | null;
|
name?: string | null;
|
||||||
ownerUserId?: string | number | null;
|
ownerUserId?: string | null;
|
||||||
class?: string;
|
class?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { Snippet } from 'svelte';
|
import type { Snippet } from 'svelte';
|
||||||
|
import { trackCover } from '$lib/utils/hrefs';
|
||||||
|
|
||||||
type Size = 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl' | 'hero' | 'fill';
|
type Size = 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl' | 'hero' | 'fill';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
src?: string | null;
|
src?: string | null;
|
||||||
trackIds?: (string | number)[];
|
trackIds?: string[];
|
||||||
size: Size;
|
size: Size;
|
||||||
shape?: 'auto' | 'round';
|
shape?: 'auto' | 'round';
|
||||||
imageSize?: 'small' | 'medium' | 'large';
|
imageSize?: 'small' | 'medium' | 'large';
|
||||||
@@ -61,22 +62,12 @@
|
|||||||
const resolvedImageSize = $derived(imageSize ?? AUTO_IMAGE_SIZE[size]);
|
const resolvedImageSize = $derived(imageSize ?? AUTO_IMAGE_SIZE[size]);
|
||||||
const radius = $derived(shape === 'round' ? 'rounded-full' : RADIUS[size]);
|
const radius = $derived(shape === 'round' ? 'rounded-full' : RADIUS[size]);
|
||||||
|
|
||||||
let srcFailed = $state(false);
|
let failedSrc = $state<string | null>(null);
|
||||||
$effect(() => {
|
let failedSingle = $state<string | null>(null);
|
||||||
src;
|
|
||||||
srcFailed = false;
|
|
||||||
});
|
|
||||||
|
|
||||||
let singleFailed = $state(false);
|
const showSrc = $derived(!!src && failedSrc !== src);
|
||||||
$effect(() => {
|
|
||||||
trackIds[0];
|
|
||||||
singleFailed = false;
|
|
||||||
});
|
|
||||||
|
|
||||||
const showSrc = $derived(!!src && !srcFailed);
|
|
||||||
const showMosaic = $derived(!showSrc && trackIds.length >= 4);
|
const showMosaic = $derived(!showSrc && trackIds.length >= 4);
|
||||||
const singleId = $derived(!showSrc && !showMosaic && trackIds.length > 0 ? trackIds[0] : null);
|
const singleId = $derived(!showSrc && !showMosaic && trackIds.length > 0 ? trackIds[0] : null);
|
||||||
const showSingle = $derived(singleId !== null && !singleFailed);
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="relative overflow-hidden {DIMENSION[size]} {radius} {klass}">
|
<div class="relative overflow-hidden {DIMENSION[size]} {radius} {klass}">
|
||||||
@@ -87,14 +78,14 @@
|
|||||||
loading="lazy"
|
loading="lazy"
|
||||||
decoding="async"
|
decoding="async"
|
||||||
fetchpriority="low"
|
fetchpriority="low"
|
||||||
onerror={() => (srcFailed = true)}
|
onerror={() => (failedSrc = src ?? null)}
|
||||||
class="h-full w-full object-cover"
|
class="h-full w-full object-cover"
|
||||||
/>
|
/>
|
||||||
{:else if showMosaic}
|
{:else if showMosaic}
|
||||||
<div class="grid h-full w-full grid-cols-2 grid-rows-2">
|
<div class="grid h-full w-full grid-cols-2 grid-rows-2">
|
||||||
{#each trackIds.slice(0, 4) as id (id)}
|
{#each trackIds.slice(0, 4) as id (id)}
|
||||||
<img
|
<img
|
||||||
src="/api/tracks/{id}/cover?size=small"
|
src={trackCover(id, 'small')}
|
||||||
alt=""
|
alt=""
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
decoding="async"
|
decoding="async"
|
||||||
@@ -104,14 +95,14 @@
|
|||||||
/>
|
/>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{:else if showSingle}
|
{:else if singleId !== null && failedSingle !== singleId}
|
||||||
<img
|
<img
|
||||||
src="/api/tracks/{singleId}/cover?size={resolvedImageSize}"
|
src={trackCover(singleId, resolvedImageSize)}
|
||||||
{alt}
|
{alt}
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
decoding="async"
|
decoding="async"
|
||||||
fetchpriority="low"
|
fetchpriority="low"
|
||||||
onerror={() => (singleFailed = true)}
|
onerror={() => (failedSingle = singleId)}
|
||||||
class="h-full w-full object-cover"
|
class="h-full w-full object-cover"
|
||||||
/>
|
/>
|
||||||
{:else}
|
{:else}
|
||||||
|
|||||||
@@ -33,11 +33,7 @@
|
|||||||
.join('')
|
.join('')
|
||||||
);
|
);
|
||||||
|
|
||||||
let failed = $state(false);
|
let failedSrc = $state<string | null>(null);
|
||||||
$effect(() => {
|
|
||||||
src;
|
|
||||||
failed = false;
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
@@ -45,13 +41,13 @@
|
|||||||
size
|
size
|
||||||
]} {klass}"
|
]} {klass}"
|
||||||
>
|
>
|
||||||
{#if src && !failed}
|
{#if src && failedSrc !== src}
|
||||||
<img
|
<img
|
||||||
{src}
|
{src}
|
||||||
alt=""
|
alt=""
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
decoding="async"
|
decoding="async"
|
||||||
onerror={() => (failed = true)}
|
onerror={() => (failedSrc = src ?? null)}
|
||||||
class="absolute inset-0 h-full w-full object-cover"
|
class="absolute inset-0 h-full w-full object-cover"
|
||||||
/>
|
/>
|
||||||
{:else}
|
{:else}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { untrack } from 'svelte';
|
import { untrack } from 'svelte';
|
||||||
import { enhance } from '$app/forms';
|
import { enhance } from '$app/forms';
|
||||||
import Button from '../primitives/Button.svelte';
|
import Button from '$lib/components/ui/primitives/Button.svelte';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
following: boolean;
|
following: boolean;
|
||||||
|
|||||||
@@ -12,10 +12,10 @@
|
|||||||
variant?: 'row' | 'card';
|
variant?: 'row' | 'card';
|
||||||
title: string;
|
title: string;
|
||||||
subtitle?: string | null;
|
subtitle?: string | null;
|
||||||
subtitleHref?: string | number | null;
|
subtitleHref?: string | null;
|
||||||
explicit?: boolean;
|
explicit?: boolean;
|
||||||
trackId?: string | number;
|
trackId?: string;
|
||||||
trackIds?: (string | number)[];
|
trackIds?: string[];
|
||||||
coverSrc?: string | null;
|
coverSrc?: string | null;
|
||||||
art?: Snippet;
|
art?: Snippet;
|
||||||
overlay?: Snippet;
|
overlay?: Snippet;
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
title: string;
|
title: string;
|
||||||
subtitle?: string | null;
|
subtitle?: string | null;
|
||||||
src?: string | null;
|
src?: string | null;
|
||||||
trackIds?: (string | number)[];
|
trackIds?: string[];
|
||||||
onPlay?: (event: MouseEvent) => void;
|
onPlay?: (event: MouseEvent) => void;
|
||||||
art?: Snippet;
|
art?: Snippet;
|
||||||
oncontextmenu?: (event: MouseEvent) => void;
|
oncontextmenu?: (event: MouseEvent) => void;
|
||||||
|
|||||||
@@ -5,11 +5,11 @@
|
|||||||
import ArtistLink from './ArtistLink.svelte';
|
import ArtistLink from './ArtistLink.svelte';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
trackId?: string | number;
|
trackId?: string;
|
||||||
trackIds?: (string | number)[];
|
trackIds?: string[];
|
||||||
title: string;
|
title: string;
|
||||||
artist?: string | null;
|
artist?: string | null;
|
||||||
ownerUserId?: string | number | null;
|
ownerUserId?: string | null;
|
||||||
coverSrc?: string | null;
|
coverSrc?: string | null;
|
||||||
size?: 'xs' | 'sm' | 'lg';
|
size?: 'xs' | 'sm' | 'lg';
|
||||||
explicit?: boolean;
|
explicit?: boolean;
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { player } from '$lib/player/player.svelte';
|
||||||
|
import { isQueueCurrent, playAllOrToggle } from '$lib/player/actions';
|
||||||
|
import type { Track } from '$lib/types';
|
||||||
|
import Button from '$lib/components/ui/primitives/Button.svelte';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
items: Track[];
|
||||||
|
load?: () => Promise<Track[]>;
|
||||||
|
variant?: 'primary' | 'secondary' | 'accent';
|
||||||
|
size?: 'sm' | 'md' | 'lg';
|
||||||
|
idleLabel?: string;
|
||||||
|
activeLabel?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
items,
|
||||||
|
load,
|
||||||
|
variant = 'primary',
|
||||||
|
size = 'sm',
|
||||||
|
idleLabel = 'Reproducir',
|
||||||
|
activeLabel = 'Pausar'
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
const active = $derived(isQueueCurrent(items) && player.playing);
|
||||||
|
|
||||||
|
async function play() {
|
||||||
|
if (load && !isQueueCurrent(items)) playAllOrToggle(await load());
|
||||||
|
else playAllOrToggle(items);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Button {variant} {size} onclick={play} disabled={items.length === 0 && !load}>
|
||||||
|
{active ? activeLabel : idleLabel}
|
||||||
|
</Button>
|
||||||
@@ -27,7 +27,7 @@
|
|||||||
import { pressable } from '$lib/actions/pressable';
|
import { pressable } from '$lib/actions/pressable';
|
||||||
import MediaIdentity from './MediaIdentity.svelte';
|
import MediaIdentity from './MediaIdentity.svelte';
|
||||||
import EqBars from './EqBars.svelte';
|
import EqBars from './EqBars.svelte';
|
||||||
import IconButton from '../primitives/IconButton.svelte';
|
import IconButton from '$lib/components/ui/primitives/IconButton.svelte';
|
||||||
|
|
||||||
interface RowAction {
|
interface RowAction {
|
||||||
action?: string;
|
action?: string;
|
||||||
@@ -86,7 +86,7 @@
|
|||||||
style="--mf-track-cols:{gridColumns}; --mf-track-cols-mobile:{gridColumnsMobile}"
|
style="--mf-track-cols:{gridColumns}; --mf-track-cols-mobile:{gridColumnsMobile}"
|
||||||
>
|
>
|
||||||
{#if index}
|
{#if index}
|
||||||
<Hash size={16} />
|
<Hash class="size-icon-sm" />
|
||||||
{/if}
|
{/if}
|
||||||
<span>Título</span>
|
<span>Título</span>
|
||||||
{#each columns as column (column)}
|
{#each columns as column (column)}
|
||||||
|
|||||||
@@ -10,16 +10,17 @@
|
|||||||
import GlassMenu from './GlassMenu.svelte';
|
import GlassMenu from './GlassMenu.svelte';
|
||||||
import { themeMode } from '$lib/theme/mode.svelte';
|
import { themeMode } from '$lib/theme/mode.svelte';
|
||||||
import { clickOutside } from '$lib/actions/clickOutside';
|
import { clickOutside } from '$lib/actions/clickOutside';
|
||||||
|
import { onEscape } from '$lib/actions/onEscape';
|
||||||
|
import Avatar from '$lib/components/ui/media/Avatar.svelte';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
user: SessionUser;
|
user: SessionUser;
|
||||||
accountUrl?: string | null;
|
accountUrl?: string | null;
|
||||||
width?: 'sm' | 'md';
|
width?: 'sm' | 'md';
|
||||||
extraItems?: Snippet<[{ close: () => void }]>;
|
extraItems?: Snippet<[{ close: () => void }]>;
|
||||||
trigger: Snippet<[{ toggle: () => void; open: boolean }]>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let { user, accountUrl, width = 'sm', extraItems, trigger }: Props = $props();
|
let { user, accountUrl, width = 'sm', extraItems }: Props = $props();
|
||||||
|
|
||||||
const WIDTH: Record<'sm' | 'md', string> = { sm: 'w-52', md: 'w-56' };
|
const WIDTH: Record<'sm' | 'md', string> = { sm: 'w-52', md: 'w-56' };
|
||||||
|
|
||||||
@@ -32,16 +33,23 @@
|
|||||||
function close() {
|
function close() {
|
||||||
open = false;
|
open = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
function onWindowKeydown(event: KeyboardEvent) {
|
|
||||||
if (event.key === 'Escape') close();
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:window onkeydown={onWindowKeydown} />
|
<div class="relative" use:clickOutside={close} use:onEscape={open ? close : undefined}>
|
||||||
|
<button
|
||||||
<div class="relative" use:clickOutside={close}>
|
type="button"
|
||||||
{@render trigger({ toggle, open })}
|
onclick={toggle}
|
||||||
|
aria-label="Tu cuenta"
|
||||||
|
aria-expanded={open}
|
||||||
|
class="block shrink-0 rounded-full transition active:scale-95"
|
||||||
|
>
|
||||||
|
<Avatar
|
||||||
|
name={user.name}
|
||||||
|
src={user.picture}
|
||||||
|
size="sm"
|
||||||
|
class="border border-line-strong opacity-90"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
|
||||||
{#if open}
|
{#if open}
|
||||||
<GlassMenu
|
<GlassMenu
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import ListEnd from '@lucide/svelte/icons/list-end';
|
||||||
|
import ListPlus from '@lucide/svelte/icons/list-plus';
|
||||||
|
import { player } from '$lib/player/player.svelte';
|
||||||
|
import { fetchAlbumTracks } from '$lib/data/albums';
|
||||||
|
import type { Menu } from '$lib/state/menu.svelte';
|
||||||
|
import type { Playlist } from '$lib/types';
|
||||||
|
import ContextMenu from './ContextMenu.svelte';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
menu: Menu<string>;
|
||||||
|
playlists: Playlist[];
|
||||||
|
}
|
||||||
|
|
||||||
|
let { menu, playlists }: Props = $props();
|
||||||
|
|
||||||
|
async function playNext(albumId: string) {
|
||||||
|
menu.close();
|
||||||
|
player.playNext(await fetchAlbumTracks(albumId));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addToQueue(albumId: string) {
|
||||||
|
menu.close();
|
||||||
|
player.appendToQueue(await fetchAlbumTracks(albumId));
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if menu.state}
|
||||||
|
{@const { target: albumId, x, y, openLeft } = menu.state}
|
||||||
|
<ContextMenu
|
||||||
|
{x}
|
||||||
|
{y}
|
||||||
|
{openLeft}
|
||||||
|
onClose={() => menu.close()}
|
||||||
|
items={[
|
||||||
|
{ icon: ListPlus, label: 'Reproducir a continuación', onclick: () => playNext(albumId) },
|
||||||
|
{ icon: ListEnd, label: 'Añadir a la cola', onclick: () => addToQueue(albumId) }
|
||||||
|
]}
|
||||||
|
playlistAction={{
|
||||||
|
action: '?/addAlbumToPlaylist',
|
||||||
|
fields: { albumId },
|
||||||
|
label: 'Añadir álbum a una playlist'
|
||||||
|
}}
|
||||||
|
{playlists}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Dialog from './Dialog.svelte';
|
import Dialog from './Dialog.svelte';
|
||||||
import Button from '../primitives/Button.svelte';
|
import Button from '$lib/components/ui/primitives/Button.svelte';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
|
|||||||
@@ -1,27 +1,10 @@
|
|||||||
<script lang="ts" module>
|
|
||||||
import { MENU_WIDTH, SUBMENU_WIDTH } from '$lib/config';
|
|
||||||
|
|
||||||
export interface MenuPosition {
|
|
||||||
x: number;
|
|
||||||
y: number;
|
|
||||||
openLeft: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function contextMenuPosition(event: MouseEvent): MenuPosition {
|
|
||||||
event.preventDefault();
|
|
||||||
return {
|
|
||||||
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">
|
<script lang="ts">
|
||||||
import { enhance } from '$app/forms';
|
import { enhance } from '$app/forms';
|
||||||
|
import { onEscape } from '$lib/actions/onEscape';
|
||||||
|
import { playlistCover } from '$lib/utils/hrefs';
|
||||||
import type { LucideIcon } from '@lucide/svelte';
|
import type { LucideIcon } from '@lucide/svelte';
|
||||||
import ChevronRight from '@lucide/svelte/icons/chevron-right';
|
import ChevronRight from '@lucide/svelte/icons/chevron-right';
|
||||||
import MediaIdentity from '../media/MediaIdentity.svelte';
|
import MediaIdentity from '$lib/components/ui/media/MediaIdentity.svelte';
|
||||||
import GlassMenu from './GlassMenu.svelte';
|
import GlassMenu from './GlassMenu.svelte';
|
||||||
import MenuItem from './MenuItem.svelte';
|
import MenuItem from './MenuItem.svelte';
|
||||||
|
|
||||||
@@ -32,21 +15,16 @@
|
|||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
items: { icon: LucideIcon; label: string; onclick: () => void }[];
|
items: { icon: LucideIcon; label: string; onclick: () => void }[];
|
||||||
playlistAction?: { action: string; fields: Record<string, string>; label: string };
|
playlistAction?: { action: string; fields: Record<string, string>; label: string };
|
||||||
playlists?: { id: string; name: string }[];
|
playlists?: { id: string; name: string; updatedAt: string }[];
|
||||||
}
|
}
|
||||||
|
|
||||||
let { x, y, openLeft, onClose, items, playlistAction, playlists = [] }: Props = $props();
|
let { x, y, openLeft, onClose, items, playlistAction, playlists = [] }: Props = $props();
|
||||||
|
|
||||||
function onWindowKeydown(event: KeyboardEvent) {
|
|
||||||
if (event.key === 'Escape') onClose();
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:window onkeydown={onWindowKeydown} />
|
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="fixed inset-0 z-(--z-backdrop)"
|
class="fixed inset-0 z-(--z-backdrop)"
|
||||||
role="presentation"
|
role="presentation"
|
||||||
|
use:onEscape={onClose}
|
||||||
onclick={onClose}
|
onclick={onClose}
|
||||||
oncontextmenu={(e) => {
|
oncontextmenu={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -97,7 +75,7 @@
|
|||||||
>
|
>
|
||||||
<MediaIdentity
|
<MediaIdentity
|
||||||
title={playlist.name}
|
title={playlist.name}
|
||||||
coverSrc="/api/playlists/{playlist.id}/cover?size=small"
|
coverSrc={playlistCover(playlist, 'small')}
|
||||||
size="xs"
|
size="xs"
|
||||||
titleClass="text-fg-2"
|
titleClass="text-fg-2"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Dialog from './Dialog.svelte';
|
import Dialog from './Dialog.svelte';
|
||||||
import Button from '../primitives/Button.svelte';
|
import Button from '$lib/components/ui/primitives/Button.svelte';
|
||||||
import { dialog, type DialogAction, type DialogOptions } from '$lib/state/dialog.svelte';
|
import { dialog, type DialogAction, type DialogOptions } from '$lib/state/dialog.svelte';
|
||||||
|
|
||||||
// Keep the last dialog's content so the exit transition doesn't render an empty panel.
|
// Keep the last dialog's content so the exit transition doesn't render an empty panel.
|
||||||
|
|||||||
@@ -28,21 +28,17 @@
|
|||||||
);
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if href}
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
<a
|
<svelte:element
|
||||||
|
this={href ? 'a' : 'button'}
|
||||||
{href}
|
{href}
|
||||||
{target}
|
{target}
|
||||||
rel={target === '_blank' ? 'noopener noreferrer' : undefined}
|
rel={href && target === '_blank' ? 'noopener noreferrer' : undefined}
|
||||||
data-sveltekit-reload={reload ? '' : undefined}
|
type={href ? undefined : type}
|
||||||
|
data-sveltekit-reload={href && reload ? '' : undefined}
|
||||||
{onclick}
|
{onclick}
|
||||||
class={classes}
|
class={classes}
|
||||||
>
|
>
|
||||||
<Icon class="size-icon-md shrink-0" />
|
<Icon class="size-icon-md shrink-0" />
|
||||||
{label}
|
{label}
|
||||||
</a>
|
</svelte:element>
|
||||||
{:else}
|
|
||||||
<button {type} {onclick} class={classes}>
|
|
||||||
<Icon class="size-icon-md shrink-0" />
|
|
||||||
{label}
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { Snippet } from 'svelte';
|
import type { Snippet } from 'svelte';
|
||||||
import X from '@lucide/svelte/icons/x';
|
import X from '@lucide/svelte/icons/x';
|
||||||
import IconButton from '../primitives/IconButton.svelte';
|
import IconButton from '$lib/components/ui/primitives/IconButton.svelte';
|
||||||
import { fade } from 'svelte/transition';
|
import { fade } from 'svelte/transition';
|
||||||
import { pop } from '$lib/utils/transitions';
|
import { pop } from '$lib/utils/transitions';
|
||||||
|
import { onEscape } from '$lib/actions/onEscape';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -48,12 +49,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function onWindowKeydown(event: KeyboardEvent) {
|
function onWindowKeydown(event: KeyboardEvent) {
|
||||||
if (!open) return;
|
if (!open || event.key !== 'Tab') return;
|
||||||
if (event.key === 'Escape') {
|
|
||||||
onClose();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (event.key !== 'Tab') return;
|
|
||||||
const elements = focusableElements();
|
const elements = focusableElements();
|
||||||
if (elements.length === 0) return;
|
if (elements.length === 0) return;
|
||||||
const first = elements[0];
|
const first = elements[0];
|
||||||
@@ -79,6 +75,7 @@
|
|||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
bind:this={panel}
|
bind:this={panel}
|
||||||
|
use:onEscape={onClose}
|
||||||
transition:pop={{ duration: 200 }}
|
transition:pop={{ duration: 200 }}
|
||||||
class="glass-panel max-h-[85dvh] w-full glow-drift overflow-y-auto {maxWidth} rounded-panel p-5 outline-none sm:p-6 {panelClass}"
|
class="glass-panel max-h-[85dvh] w-full glow-drift overflow-y-auto {maxWidth} rounded-panel p-5 outline-none sm:p-6 {panelClass}"
|
||||||
role="dialog"
|
role="dialog"
|
||||||
@@ -99,7 +96,7 @@
|
|||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
<IconButton label="Cerrar" onclick={onClose} class="shrink-0">
|
<IconButton label="Cerrar" onclick={onClose} class="shrink-0">
|
||||||
<X class="size-4" />
|
<X class="size-icon-sm" />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import ListPlus from '@lucide/svelte/icons/list-plus';
|
||||||
|
import { player } from '$lib/player/player.svelte';
|
||||||
|
import type { Menu } from '$lib/state/menu.svelte';
|
||||||
|
import type { Playlist, Track } from '$lib/types';
|
||||||
|
import ContextMenu from './ContextMenu.svelte';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
menu: Menu<Track>;
|
||||||
|
playlists: Playlist[];
|
||||||
|
}
|
||||||
|
|
||||||
|
let { menu, playlists }: Props = $props();
|
||||||
|
|
||||||
|
function playNext(track: Track) {
|
||||||
|
player.playNext([track]);
|
||||||
|
menu.close();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if menu.state}
|
||||||
|
{@const { target: track, x, y, openLeft } = menu.state}
|
||||||
|
<ContextMenu
|
||||||
|
{x}
|
||||||
|
{y}
|
||||||
|
{openLeft}
|
||||||
|
onClose={() => menu.close()}
|
||||||
|
items={[{ icon: ListPlus, label: 'Reproducir a continuación', onclick: () => playNext(track) }]}
|
||||||
|
playlistAction={{
|
||||||
|
action: '?/addTrack',
|
||||||
|
fields: { trackId: track.id },
|
||||||
|
label: 'Añadir a una playlist'
|
||||||
|
}}
|
||||||
|
{playlists}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import ArrowLeft from '@lucide/svelte/icons/arrow-left';
|
import ArrowLeft from '@lucide/svelte/icons/arrow-left';
|
||||||
import { previousPage } from '$lib/state/navigation.svelte';
|
import { previousPage } from '$lib/state/history.svelte';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
href: string;
|
href: string;
|
||||||
|
|||||||
@@ -28,13 +28,13 @@
|
|||||||
children
|
children
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
const sizes = {
|
const SIZE = {
|
||||||
sm: 'rounded-control px-4 py-2 text-xs',
|
sm: 'rounded-control px-4 py-2 text-xs',
|
||||||
md: 'rounded-btn-sm px-4.5 py-2.5 text-control-sm',
|
md: 'rounded-btn-sm px-4.5 py-2.5 text-control-sm',
|
||||||
lg: 'rounded-btn px-6 py-3.5 text-sm'
|
lg: 'rounded-btn px-6 py-3.5 text-sm'
|
||||||
};
|
};
|
||||||
|
|
||||||
const variants = {
|
const VARIANT = {
|
||||||
primary: 'bg-cta-strong text-ink hover:bg-cta-strong-hover',
|
primary: 'bg-cta-strong text-ink hover:bg-cta-strong-hover',
|
||||||
secondary: 'bg-btn text-fg hover:bg-btn-hover',
|
secondary: 'bg-btn text-fg hover:bg-btn-hover',
|
||||||
accent: 'surface-active text-accent-soft hover:surface-active-hover',
|
accent: 'surface-active text-accent-soft hover:surface-active-hover',
|
||||||
@@ -42,19 +42,22 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
const classes = $derived(
|
const classes = $derived(
|
||||||
`inline-flex items-center justify-center gap-2 font-medium btn-transition focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-bg focus-visible:outline-none disabled:opacity-40 ${sizes[size]} ${variants[variant]} ${klass}`
|
`inline-flex items-center justify-center gap-2 font-medium btn-transition focus-ring focus-visible:ring-offset-2 focus-visible:ring-offset-bg disabled:opacity-40 ${SIZE[size]} ${VARIANT[variant]} ${klass}`
|
||||||
);
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if href}
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
<a {href} data-sveltekit-reload={reload ? '' : undefined} class={classes}>
|
<svelte:element
|
||||||
{@render children()}
|
this={href ? 'a' : 'button'}
|
||||||
</a>
|
{href}
|
||||||
{:else}
|
type={href ? undefined : type}
|
||||||
<button {type} disabled={disabled || loading} {onclick} class={classes}>
|
disabled={href ? undefined : disabled || loading}
|
||||||
{#if loading}
|
data-sveltekit-reload={href && reload ? '' : undefined}
|
||||||
<LoaderCircle class="size-4 animate-spin" />
|
{onclick}
|
||||||
|
class={classes}
|
||||||
|
>
|
||||||
|
{#if loading && !href}
|
||||||
|
<LoaderCircle class="size-icon-sm animate-spin" />
|
||||||
{/if}
|
{/if}
|
||||||
{@render children()}
|
{@render children()}
|
||||||
</button>
|
</svelte:element>
|
||||||
{/if}
|
|
||||||
|
|||||||
@@ -21,7 +21,7 @@
|
|||||||
class="peer size-5 cursor-pointer appearance-none rounded-tag border border-line bg-surface transition checked:border-accent-soft checked:bg-accent-soft focus-visible:border-accent/60 focus-visible:outline-none"
|
class="peer size-5 cursor-pointer appearance-none rounded-tag border border-line bg-surface transition checked:border-accent-soft checked:bg-accent-soft focus-visible:border-accent/60 focus-visible:outline-none"
|
||||||
/>
|
/>
|
||||||
<Check
|
<Check
|
||||||
class="pointer-events-none absolute size-3.5 text-ink opacity-0 transition peer-checked:opacity-100"
|
class="pointer-events-none absolute size-icon-xs text-ink opacity-0 transition peer-checked:opacity-100"
|
||||||
/>
|
/>
|
||||||
</span>
|
</span>
|
||||||
<span class="cursor-pointer">{@render children()}</span>
|
<span class="cursor-pointer">{@render children()}</span>
|
||||||
|
|||||||
@@ -14,22 +14,24 @@
|
|||||||
|
|
||||||
const classes = $derived(
|
const classes = $derived(
|
||||||
href
|
href
|
||||||
? 'rounded-full border border-line-strong px-3.5 py-2 text-xs text-fg-2 transition-colors hover:bg-hover hover:text-fg'
|
? 'focus-ring rounded-full border border-line-strong px-3.5 py-2 text-xs text-fg-2 transition-colors hover:bg-hover hover:text-fg'
|
||||||
: `flex items-center gap-2 rounded-full px-3.5 py-2 text-xs font-medium transition ${
|
: `focus-ring flex items-center gap-2 rounded-full px-3.5 py-2 text-xs font-medium transition ${
|
||||||
selected ? 'bg-cta-strong text-ink' : 'bg-btn text-fg-2 hover:bg-btn-hover hover:text-fg'
|
selected ? 'bg-cta-strong text-ink' : 'bg-btn text-fg-2 hover:bg-btn-hover hover:text-fg'
|
||||||
} ${disabled ? 'cursor-not-allowed opacity-40' : ''}`
|
} ${disabled ? 'cursor-not-allowed opacity-40' : ''}`
|
||||||
);
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#snippet content()}
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
|
<svelte:element
|
||||||
|
this={href ? 'a' : 'button'}
|
||||||
|
{href}
|
||||||
|
type={href ? undefined : 'button'}
|
||||||
|
disabled={href ? undefined : disabled}
|
||||||
|
onclick={href ? undefined : onclick}
|
||||||
|
class={classes}
|
||||||
|
>
|
||||||
{@render children()}
|
{@render children()}
|
||||||
{#if count !== undefined}
|
{#if count !== undefined}
|
||||||
<span class="tabular-nums {selected ? 'opacity-55' : 'text-fg-2'}">{count}</span>
|
<span class="tabular-nums {selected ? 'opacity-55' : 'text-fg-2'}">{count}</span>
|
||||||
{/if}
|
{/if}
|
||||||
{/snippet}
|
</svelte:element>
|
||||||
|
|
||||||
{#if href}
|
|
||||||
<a {href} class={classes}>{@render content()}</a>
|
|
||||||
{:else}
|
|
||||||
<button type="button" {onclick} {disabled} class={classes}>{@render content()}</button>
|
|
||||||
{/if}
|
|
||||||
|
|||||||
@@ -45,24 +45,23 @@
|
|||||||
);
|
);
|
||||||
|
|
||||||
const classes = $derived(
|
const classes = $derived(
|
||||||
`grid place-items-center transition focus-visible:ring-2 focus-visible:ring-accent focus-visible:outline-none ${SIZE[size]} ${shape === 'round' ? 'rounded-full' : 'rounded-control'} ${pressed ? 'text-accent' : 'text-fg-2 hover:text-fg'} ${background} ${
|
`grid place-items-center transition focus-ring ${SIZE[size]} ${shape === 'round' ? 'rounded-full' : 'rounded-control'} ${pressed ? 'text-accent' : 'text-fg-2 hover:text-fg'} ${background} ${
|
||||||
revealOnHover ? 'sm:opacity-0 sm:group-hover:opacity-100 sm:focus-visible:opacity-100' : ''
|
revealOnHover ? 'sm:opacity-0 sm:group-hover:opacity-100 sm:focus-visible:opacity-100' : ''
|
||||||
} ${klass}`
|
} ${klass}`
|
||||||
);
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if href}
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
<a
|
<svelte:element
|
||||||
|
this={href ? 'a' : 'button'}
|
||||||
{href}
|
{href}
|
||||||
data-sveltekit-reload={reload ? '' : undefined}
|
type={href ? undefined : type}
|
||||||
|
data-sveltekit-reload={href && reload ? '' : undefined}
|
||||||
aria-label={label}
|
aria-label={label}
|
||||||
|
aria-pressed={href ? undefined : pressed}
|
||||||
title={label}
|
title={label}
|
||||||
|
{onclick}
|
||||||
class={classes}
|
class={classes}
|
||||||
>
|
>
|
||||||
{@render children()}
|
{@render children()}
|
||||||
</a>
|
</svelte:element>
|
||||||
{:else}
|
|
||||||
<button {type} {onclick} aria-label={label} title={label} aria-pressed={pressed} class={classes}>
|
|
||||||
{@render children()}
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
|
|||||||
@@ -1,15 +1,23 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import LoaderCircle from '@lucide/svelte/icons/loader-circle';
|
import LoaderCircle from '@lucide/svelte/icons/loader-circle';
|
||||||
|
import Button from './Button.svelte';
|
||||||
import { INFINITE_SCROLL_ROOT_MARGIN } from '$lib/config';
|
import { INFINITE_SCROLL_ROOT_MARGIN } from '$lib/config';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
onLoadMore: () => void;
|
onLoadMore: () => void;
|
||||||
hasMore: boolean;
|
hasMore: boolean;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
|
error?: boolean;
|
||||||
rootMargin?: string;
|
rootMargin?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { onLoadMore, hasMore, loading, rootMargin = INFINITE_SCROLL_ROOT_MARGIN }: Props = $props();
|
let {
|
||||||
|
onLoadMore,
|
||||||
|
hasMore,
|
||||||
|
loading,
|
||||||
|
error = false,
|
||||||
|
rootMargin = INFINITE_SCROLL_ROOT_MARGIN
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
let sentinel = $state<HTMLDivElement>();
|
let sentinel = $state<HTMLDivElement>();
|
||||||
let visible = $state(false);
|
let visible = $state(false);
|
||||||
@@ -28,12 +36,17 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (visible && hasMore && !loading) onLoadMore();
|
if (visible && hasMore && !loading && !error) onLoadMore();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div bind:this={sentinel} class="flex min-h-12 items-center justify-center py-8">
|
<div bind:this={sentinel} class="flex min-h-12 items-center justify-center py-8">
|
||||||
{#if loading}
|
{#if loading}
|
||||||
<LoaderCircle class="size-5 animate-spin text-fg-2" />
|
<LoaderCircle class="size-icon-lg animate-spin text-fg-2" />
|
||||||
|
{:else if error}
|
||||||
|
<div class="flex items-center gap-3 text-sm text-fg-2">
|
||||||
|
No se pudo cargar más.
|
||||||
|
<Button variant="secondary" size="sm" onclick={onLoadMore}>Reintentar</Button>
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -9,9 +9,7 @@ export const PLAYLIST_TRACKS_PAGE_SIZE = 200;
|
|||||||
export const LIBRARY_PICKER_PAGE_SIZE = 50;
|
export const LIBRARY_PICKER_PAGE_SIZE = 50;
|
||||||
export const LIBRARY_PAGE_SIZE = 50;
|
export const LIBRARY_PAGE_SIZE = 50;
|
||||||
export const PLAYLISTS_PAGE_SIZE = 50;
|
export const PLAYLISTS_PAGE_SIZE = 50;
|
||||||
export const PLAYLIST_SUMMARY_TRACKS_PAGE_SIZE = 500;
|
|
||||||
export const PROFILE_PLAYLISTS_PAGE_SIZE = 50;
|
export const PROFILE_PLAYLISTS_PAGE_SIZE = 50;
|
||||||
export const COUNT_ONLY_PAGE_SIZE = 1;
|
|
||||||
export const EXPLORE_ALBUMS_PAGE_SIZE = 10;
|
export const EXPLORE_ALBUMS_PAGE_SIZE = 10;
|
||||||
export const EXPLORE_ALBUMS_LIMIT = 18;
|
export const EXPLORE_ALBUMS_LIMIT = 18;
|
||||||
export const HOME_MIXES_LIMIT = 6;
|
export const HOME_MIXES_LIMIT = 6;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { findTopResult } from './search';
|
import { findTopResult, searchChips } from './search';
|
||||||
|
|
||||||
const empty = { tracks: [], albums: [], playlists: [], users: [] };
|
const empty = { tracks: [], albums: [], playlists: [], users: [] };
|
||||||
|
|
||||||
@@ -26,3 +26,17 @@ describe('findTopResult', () => {
|
|||||||
expect(findTopResult({ ...empty, query: 'x' })).toBeNull();
|
expect(findTopResult({ ...empty, query: 'x' })).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('searchChips', () => {
|
||||||
|
it('adds an "all" chip with the total', () => {
|
||||||
|
const chips = searchChips({ tracks: 3, albums: 2, playlists: 0, users: 1 });
|
||||||
|
expect(chips.map((chip) => [chip.filter, chip.count])).toEqual([
|
||||||
|
['all', 6],
|
||||||
|
['tracks', 3],
|
||||||
|
['albums', 2],
|
||||||
|
['playlists', 0],
|
||||||
|
['users', 1]
|
||||||
|
]);
|
||||||
|
expect(chips[0].label).toBe('Todo');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,38 +1,40 @@
|
|||||||
export const SEARCH_FILTERS = ['Todo', 'Canciones', 'Álbumes', 'Playlists', 'Usuarios'] as const;
|
export const SEARCH_KINDS = ['tracks', 'albums', 'playlists', 'users'] as const;
|
||||||
export type SearchFilter = (typeof SEARCH_FILTERS)[number];
|
export type SearchKind = (typeof SEARCH_KINDS)[number];
|
||||||
|
export type SearchFilter = 'all' | SearchKind;
|
||||||
export const SEARCH_GROUP_PREVIEW = 4;
|
export const SEARCH_GROUP_PREVIEW = 4;
|
||||||
|
|
||||||
export function showGroup(filter: SearchFilter, label: Exclude<SearchFilter, 'Todo'>) {
|
export const SEARCH_LABELS: Record<SearchFilter, string> = {
|
||||||
return filter === 'Todo' || filter === label;
|
all: 'Todo',
|
||||||
|
tracks: 'Canciones',
|
||||||
|
albums: 'Álbumes',
|
||||||
|
playlists: 'Playlists',
|
||||||
|
users: 'Usuarios'
|
||||||
|
};
|
||||||
|
|
||||||
|
export function showGroup(filter: SearchFilter, kind: SearchKind) {
|
||||||
|
return filter === 'all' || filter === kind;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function capped<T>(filter: SearchFilter, list: T[]) {
|
export function capped<T>(filter: SearchFilter, list: T[]) {
|
||||||
return filter === 'Todo' ? list.slice(0, SEARCH_GROUP_PREVIEW) : list;
|
return filter === 'all' ? list.slice(0, SEARCH_GROUP_PREVIEW) : list;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function matchPlaylists<T extends { name: string }>(playlists: T[], query: string): T[] {
|
export function matchPlaylists<T extends { name: string }>(playlists: T[], query: string): T[] {
|
||||||
return query ? playlists.filter((p) => p.name.toLowerCase().includes(query.toLowerCase())) : [];
|
return query ? playlists.filter((p) => p.name.toLowerCase().includes(query.toLowerCase())) : [];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function searchCounts(counts: {
|
export type SearchCounts = Record<SearchKind, number>;
|
||||||
tracks: number;
|
|
||||||
albums: number;
|
export function searchTotal(counts: SearchCounts): number {
|
||||||
playlists: number;
|
return SEARCH_KINDS.reduce((sum, kind) => sum + counts[kind], 0);
|
||||||
users: number;
|
|
||||||
}) {
|
|
||||||
return {
|
|
||||||
Canciones: counts.tracks,
|
|
||||||
Álbumes: counts.albums,
|
|
||||||
Playlists: counts.playlists,
|
|
||||||
Usuarios: counts.users
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function searchChips(counts: ReturnType<typeof searchCounts>) {
|
export function searchChips(counts: SearchCounts) {
|
||||||
const total = counts.Canciones + counts.Álbumes + counts.Playlists + counts.Usuarios;
|
const filters: SearchFilter[] = ['all', ...SEARCH_KINDS];
|
||||||
return SEARCH_FILTERS.map((label) => ({
|
return filters.map((filter) => ({
|
||||||
label,
|
filter,
|
||||||
count: label === 'Todo' ? total : counts[label]
|
label: SEARCH_LABELS[filter],
|
||||||
|
count: filter === 'all' ? searchTotal(counts) : counts[filter]
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { shuffle } from '$lib/utils/collections';
|
||||||
|
import type { Track } from '$lib/types';
|
||||||
|
import { player } from './player.svelte';
|
||||||
|
|
||||||
|
export function isQueueCurrent(items: { id: string }[]): boolean {
|
||||||
|
return items.some((item) => item.id === player.currentId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function playAllOrToggle(items: Track[]) {
|
||||||
|
if (items.length === 0) return;
|
||||||
|
if (isQueueCurrent(items)) player.toggle();
|
||||||
|
else player.playQueue(items, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function playShuffled(items: Track[]) {
|
||||||
|
if (items.length === 0) return;
|
||||||
|
player.playQueue(shuffle(items), 0);
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { browser } from '$app/environment';
|
||||||
|
import type { Track } from '$lib/types';
|
||||||
|
import { trackCover } from '$lib/utils/hrefs';
|
||||||
|
|
||||||
|
interface MediaSessionHandlers {
|
||||||
|
play: () => void;
|
||||||
|
pause: () => void;
|
||||||
|
previous: () => void;
|
||||||
|
next: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const supported = () => browser && 'mediaSession' in navigator;
|
||||||
|
|
||||||
|
export const mediaSession = {
|
||||||
|
setup(handlers: MediaSessionHandlers) {
|
||||||
|
if (!supported()) return;
|
||||||
|
navigator.mediaSession.setActionHandler('play', handlers.play);
|
||||||
|
navigator.mediaSession.setActionHandler('pause', handlers.pause);
|
||||||
|
navigator.mediaSession.setActionHandler('previoustrack', handlers.previous);
|
||||||
|
navigator.mediaSession.setActionHandler('nexttrack', handlers.next);
|
||||||
|
},
|
||||||
|
|
||||||
|
setPlaying(playing: boolean) {
|
||||||
|
if (!supported()) return;
|
||||||
|
navigator.mediaSession.playbackState = playing ? 'playing' : 'paused';
|
||||||
|
},
|
||||||
|
|
||||||
|
setTrack(track: Track) {
|
||||||
|
if (!supported()) return;
|
||||||
|
navigator.mediaSession.metadata = new MediaMetadata({
|
||||||
|
title: track.title,
|
||||||
|
artist: track.artist ?? '',
|
||||||
|
artwork: [{ src: trackCover(track.id, 'small'), sizes: '256x256' }]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -1,36 +1,14 @@
|
|||||||
import { browser } from '$app/environment';
|
import { browser } from '$app/environment';
|
||||||
import { DEFAULT_ACCENT } from '$lib/theme/color';
|
import { accent } from '$lib/theme/accent.svelte';
|
||||||
import { extractAccent } from '$lib/theme/palette';
|
|
||||||
import { shuffle } from '$lib/data/collections';
|
|
||||||
import { dialog } from '$lib/state/dialog.svelte';
|
import { dialog } from '$lib/state/dialog.svelte';
|
||||||
import { ListenTracker } from './listenTracker';
|
|
||||||
import { readStorage, writeStorage } from '$lib/utils/storage';
|
|
||||||
import type { Track } from '$lib/types';
|
import type { Track } from '$lib/types';
|
||||||
|
import { readStorage, writeStorage } from '$lib/utils/storage';
|
||||||
|
import { trackCover } from '$lib/utils/hrefs';
|
||||||
|
import { ListenTracker } from './listenTracker';
|
||||||
|
import { mediaSession } from './mediaSession';
|
||||||
|
import { ProgressClock } from './progressClock';
|
||||||
import { append, insertNext, isLastIndex, move, nextIndex, previousIndex } from './queue';
|
import { append, insertNext, isLastIndex, move, nextIndex, previousIndex } from './queue';
|
||||||
|
import { resolveStream } from './stream';
|
||||||
export function isQueueCurrent(items: { id: string }[]): boolean {
|
|
||||||
return items.some((item) => item.id === player.currentId);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function playAllOrToggle(items: Track[]) {
|
|
||||||
if (items.length === 0) return;
|
|
||||||
if (isQueueCurrent(items)) player.toggle();
|
|
||||||
else player.playQueue(items, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function playShuffled(items: Track[]) {
|
|
||||||
if (items.length === 0) return;
|
|
||||||
player.playQueue(shuffle(items), 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
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 DEFAULT_VOLUME = 100;
|
const DEFAULT_VOLUME = 100;
|
||||||
@@ -60,16 +38,15 @@ class PlayerState {
|
|||||||
shuffle = $state(false);
|
shuffle = $state(false);
|
||||||
repeat = $state(false);
|
repeat = $state(false);
|
||||||
|
|
||||||
accentColor = $state<string | null>(null);
|
|
||||||
|
|
||||||
#audio: HTMLAudioElement | null = null;
|
#audio: HTMLAudioElement | null = null;
|
||||||
#loadToken = 0;
|
#loadToken = 0;
|
||||||
#accentCache = new Map<string, string>();
|
|
||||||
#rafId: number | null = null;
|
|
||||||
#listen = new ListenTracker();
|
#listen = new ListenTracker();
|
||||||
|
#clock = new ProgressClock(
|
||||||
|
() => this.#audio,
|
||||||
|
(value) => (this.progress = value)
|
||||||
|
);
|
||||||
|
|
||||||
current = $derived(this.tracks.find((t) => t.id === this.currentId) ?? null);
|
current = $derived(this.tracks.find((t) => t.id === this.currentId) ?? null);
|
||||||
accent = $derived(this.accentColor ?? DEFAULT_ACCENT);
|
|
||||||
duration = $derived(this.current?.duration ?? 0);
|
duration = $derived(this.current?.duration ?? 0);
|
||||||
progressPercent = $derived(
|
progressPercent = $derived(
|
||||||
this.duration > 0 ? Math.min(100, (this.progress / this.duration) * 100) : 0
|
this.duration > 0 ? Math.min(100, (this.progress / this.duration) * 100) : 0
|
||||||
@@ -92,18 +69,18 @@ class PlayerState {
|
|||||||
audio.addEventListener('play', () => {
|
audio.addEventListener('play', () => {
|
||||||
this.#listen.resync(audio.currentTime);
|
this.#listen.resync(audio.currentTime);
|
||||||
this.playing = true;
|
this.playing = true;
|
||||||
this.#startProgressLoop();
|
this.#clock.start();
|
||||||
this.#syncMediaSessionPlaybackState();
|
mediaSession.setPlaying(true);
|
||||||
});
|
});
|
||||||
audio.addEventListener('pause', () => {
|
audio.addEventListener('pause', () => {
|
||||||
this.#listen.interrupt();
|
this.#listen.interrupt();
|
||||||
this.#listen.flush();
|
this.#listen.flush();
|
||||||
this.playing = false;
|
this.playing = false;
|
||||||
this.#stopProgressLoop();
|
this.#clock.stop();
|
||||||
this.#syncMediaSessionPlaybackState();
|
mediaSession.setPlaying(false);
|
||||||
});
|
});
|
||||||
audio.addEventListener('ended', () => {
|
audio.addEventListener('ended', () => {
|
||||||
this.#stopProgressLoop();
|
this.#clock.stop();
|
||||||
this.#listen.tick(audio.currentTime);
|
this.#listen.tick(audio.currentTime);
|
||||||
this.#listen.begin(null);
|
this.#listen.begin(null);
|
||||||
if (this.repeat) {
|
if (this.repeat) {
|
||||||
@@ -119,7 +96,7 @@ class PlayerState {
|
|||||||
this.next();
|
this.next();
|
||||||
});
|
});
|
||||||
audio.addEventListener('error', () => {
|
audio.addEventListener('error', () => {
|
||||||
this.#stopProgressLoop();
|
this.#clock.stop();
|
||||||
this.loading = false;
|
this.loading = false;
|
||||||
this.playing = false;
|
this.playing = false;
|
||||||
this.#fail();
|
this.#fail();
|
||||||
@@ -129,7 +106,12 @@ class PlayerState {
|
|||||||
if (document.visibilityState === 'hidden') this.#listen.flush();
|
if (document.visibilityState === 'hidden') this.#listen.flush();
|
||||||
});
|
});
|
||||||
window.addEventListener('pagehide', () => this.#listen.flush());
|
window.addEventListener('pagehide', () => this.#listen.flush());
|
||||||
this.#setupMediaSession(audio);
|
mediaSession.setup({
|
||||||
|
play: () => audio.play().catch(() => {}),
|
||||||
|
pause: () => audio.pause(),
|
||||||
|
previous: () => this.previous(),
|
||||||
|
next: () => this.next()
|
||||||
|
});
|
||||||
return audio;
|
return audio;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -137,56 +119,7 @@ class PlayerState {
|
|||||||
audio.currentTime = 0;
|
audio.currentTime = 0;
|
||||||
this.progress = 0;
|
this.progress = 0;
|
||||||
this.playing = false;
|
this.playing = false;
|
||||||
this.#syncMediaSessionPlaybackState();
|
mediaSession.setPlaying(false);
|
||||||
}
|
|
||||||
|
|
||||||
#setupMediaSession(audio: HTMLAudioElement) {
|
|
||||||
if (!('mediaSession' in navigator)) return;
|
|
||||||
navigator.mediaSession.setActionHandler('play', () => audio.play().catch(() => {}));
|
|
||||||
navigator.mediaSession.setActionHandler('pause', () => audio.pause());
|
|
||||||
navigator.mediaSession.setActionHandler('previoustrack', () => this.previous());
|
|
||||||
navigator.mediaSession.setActionHandler('nexttrack', () => this.next());
|
|
||||||
}
|
|
||||||
|
|
||||||
#syncMediaSessionPlaybackState() {
|
|
||||||
if (!browser || !('mediaSession' in navigator)) return;
|
|
||||||
navigator.mediaSession.playbackState = this.playing ? 'playing' : 'paused';
|
|
||||||
}
|
|
||||||
|
|
||||||
#syncMediaSessionMetadata(track: Track) {
|
|
||||||
if (!browser || !('mediaSession' in navigator)) return;
|
|
||||||
navigator.mediaSession.metadata = new MediaMetadata({
|
|
||||||
title: track.title,
|
|
||||||
artist: track.artist ?? '',
|
|
||||||
artwork: [{ src: `/api/tracks/${track.id}/cover?size=small`, sizes: '256x256' }]
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
#clockTime = 0;
|
|
||||||
#clockStamp = 0;
|
|
||||||
|
|
||||||
#tickProgress = (now: number) => {
|
|
||||||
const audio = this.#audio;
|
|
||||||
if (!audio) return;
|
|
||||||
if (audio.currentTime !== this.#clockTime) {
|
|
||||||
this.#clockTime = audio.currentTime;
|
|
||||||
this.#clockStamp = now;
|
|
||||||
}
|
|
||||||
const elapsed = Math.min(0.25, (now - this.#clockStamp) / 1000);
|
|
||||||
const smooth = this.#clockTime + (audio.paused ? 0 : elapsed * audio.playbackRate);
|
|
||||||
this.progress = Math.min(smooth, Number.isFinite(audio.duration) ? audio.duration : smooth);
|
|
||||||
this.#rafId = requestAnimationFrame(this.#tickProgress);
|
|
||||||
};
|
|
||||||
|
|
||||||
#startProgressLoop() {
|
|
||||||
if (this.#rafId !== null) return;
|
|
||||||
this.#rafId = requestAnimationFrame(this.#tickProgress);
|
|
||||||
}
|
|
||||||
|
|
||||||
#stopProgressLoop() {
|
|
||||||
if (this.#rafId === null) return;
|
|
||||||
cancelAnimationFrame(this.#rafId);
|
|
||||||
this.#rafId = null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#syncDuration(value: number) {
|
#syncDuration(value: number) {
|
||||||
@@ -206,20 +139,9 @@ class PlayerState {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async #applyAccent(id: string) {
|
#announce(track: Track) {
|
||||||
const cached = this.#accentCache.get(id);
|
accent.follow(trackCover(track.id, 'small'), () => this.currentId === track.id);
|
||||||
if (cached) {
|
mediaSession.setTrack(track);
|
||||||
this.accentColor = cached;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const result = await extractAccent(`/api/tracks/${id}/cover?size=small`);
|
|
||||||
if (this.currentId !== id) return;
|
|
||||||
if (result) {
|
|
||||||
this.#accentCache.set(id, result);
|
|
||||||
this.accentColor = result;
|
|
||||||
} else {
|
|
||||||
this.accentColor = null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async #loadCurrent() {
|
async #loadCurrent() {
|
||||||
@@ -227,14 +149,13 @@ class PlayerState {
|
|||||||
if (!audio || this.currentId === null) return;
|
if (!audio || this.currentId === null) return;
|
||||||
const id = this.currentId;
|
const id = this.currentId;
|
||||||
const track = this.tracks.find((t) => t.id === id);
|
const track = this.tracks.find((t) => t.id === id);
|
||||||
this.#applyAccent(id);
|
if (track) this.#announce(track);
|
||||||
if (track) this.#syncMediaSessionMetadata(track);
|
|
||||||
this.#listen.begin(null);
|
this.#listen.begin(null);
|
||||||
const token = ++this.#loadToken;
|
const token = ++this.#loadToken;
|
||||||
this.loading = true;
|
this.loading = true;
|
||||||
this.progress = 0;
|
this.progress = 0;
|
||||||
try {
|
try {
|
||||||
const { src, listenId } = await this.#resolveLocalSrc(String(id));
|
const { src, listenId } = await resolveStream(id);
|
||||||
if (token !== this.#loadToken) return;
|
if (token !== this.#loadToken) return;
|
||||||
audio.src = src;
|
audio.src = src;
|
||||||
audio.volume = this.volume / 100;
|
audio.volume = this.volume / 100;
|
||||||
@@ -260,42 +181,22 @@ class PlayerState {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async #resolveLocalSrc(id: string): Promise<{ src: string; listenId: string | null }> {
|
|
||||||
const res = await fetch(`/api/tracks/${id}/stream`);
|
|
||||||
if (!res.ok) throw new Error(await readErrorMessage(res));
|
|
||||||
const { manifestUrl, ticket, listenId } = (await res.json()) as {
|
|
||||||
manifestUrl: string;
|
|
||||||
ticket: string;
|
|
||||||
listenId?: string | null;
|
|
||||||
};
|
|
||||||
return { src: this.#withTicket(manifestUrl, ticket), listenId: listenId ?? null };
|
|
||||||
}
|
|
||||||
|
|
||||||
async #beginRepeatListen() {
|
async #beginRepeatListen() {
|
||||||
const id = this.currentId;
|
const id = this.currentId;
|
||||||
if (id === null) return;
|
if (id === null) return;
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/tracks/${id}/stream`);
|
const { listenId } = await resolveStream(id);
|
||||||
if (!res.ok) return;
|
if (this.currentId === id) this.#listen.begin(listenId);
|
||||||
const { listenId } = (await res.json()) as { listenId?: string | null };
|
|
||||||
if (this.currentId === id) this.#listen.begin(listenId ?? null);
|
|
||||||
} catch {
|
} catch {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#withTicket(url: string, ticket: string): string {
|
|
||||||
if (!ticket) return url;
|
|
||||||
const sep = url.includes('?') ? '&' : '?';
|
|
||||||
return `${url}${sep}t=${encodeURIComponent(ticket)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
hydrate(track: Track) {
|
hydrate(track: Track) {
|
||||||
if (this.currentId !== null || this.tracks.length > 0) return;
|
if (this.currentId !== null || this.tracks.length > 0) return;
|
||||||
this.tracks = [track];
|
this.tracks = [track];
|
||||||
this.currentId = track.id;
|
this.currentId = track.id;
|
||||||
this.#applyAccent(track.id);
|
this.#announce(track);
|
||||||
this.#syncMediaSessionMetadata(track);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
playQueue(list: Track[], startIndex = 0) {
|
playQueue(list: Track[], startIndex = 0) {
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
const MAX_INTERPOLATION_SECONDS = 0.25;
|
||||||
|
|
||||||
|
export class ProgressClock {
|
||||||
|
#rafId: number | null = null;
|
||||||
|
#time = 0;
|
||||||
|
#stamp = 0;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private getAudio: () => HTMLAudioElement | null,
|
||||||
|
private onProgress: (seconds: number) => void
|
||||||
|
) {}
|
||||||
|
|
||||||
|
#tick = (now: number) => {
|
||||||
|
const audio = this.getAudio();
|
||||||
|
if (!audio) return;
|
||||||
|
if (audio.currentTime !== this.#time) {
|
||||||
|
this.#time = audio.currentTime;
|
||||||
|
this.#stamp = now;
|
||||||
|
}
|
||||||
|
const elapsed = Math.min(MAX_INTERPOLATION_SECONDS, (now - this.#stamp) / 1000);
|
||||||
|
const smooth = this.#time + (audio.paused ? 0 : elapsed * audio.playbackRate);
|
||||||
|
this.onProgress(Math.min(smooth, Number.isFinite(audio.duration) ? audio.duration : smooth));
|
||||||
|
this.#rafId = requestAnimationFrame(this.#tick);
|
||||||
|
};
|
||||||
|
|
||||||
|
start() {
|
||||||
|
if (this.#rafId !== null) return;
|
||||||
|
this.#rafId = requestAnimationFrame(this.#tick);
|
||||||
|
}
|
||||||
|
|
||||||
|
stop() {
|
||||||
|
if (this.#rafId === null) return;
|
||||||
|
cancelAnimationFrame(this.#rafId);
|
||||||
|
this.#rafId = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest';
|
|||||||
import { append, insertNext, isLastIndex, move, nextIndex, previousIndex } from './queue';
|
import { append, insertNext, isLastIndex, move, nextIndex, previousIndex } from './queue';
|
||||||
|
|
||||||
const t = (id: string) => ({ id });
|
const t = (id: string) => ({ id });
|
||||||
const ids = (queue: { id: string | number }[]) => queue.map((item) => item.id);
|
const ids = (queue: { id: string }[]) => queue.map((item) => item.id);
|
||||||
|
|
||||||
describe('insertNext', () => {
|
describe('insertNext', () => {
|
||||||
it('inserts right after the current track without mutating', () => {
|
it('inserts right after the current track without mutating', () => {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
interface Identified {
|
interface Identified {
|
||||||
id: string | number;
|
id: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function insertNext<T extends Identified>(
|
export function insertNext<T extends Identified>(
|
||||||
|
|||||||
@@ -1,3 +0,0 @@
|
|||||||
import { createToggle } from '../state/toggle.svelte';
|
|
||||||
|
|
||||||
export const queuePanel = createToggle();
|
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
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}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function withTicket(url: string, ticket: string): string {
|
||||||
|
if (!ticket) return url;
|
||||||
|
const separator = url.includes('?') ? '&' : '?';
|
||||||
|
return `${url}${separator}t=${encodeURIComponent(ticket)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resolveStream(id: string): Promise<{ src: string; listenId: string | null }> {
|
||||||
|
const res = await fetch(`/api/tracks/${id}/stream`);
|
||||||
|
if (!res.ok) throw new Error(await readErrorMessage(res));
|
||||||
|
const { manifestUrl, ticket, listenId } = (await res.json()) as {
|
||||||
|
manifestUrl: string;
|
||||||
|
ticket: string;
|
||||||
|
listenId?: string | null;
|
||||||
|
};
|
||||||
|
return { src: withTicket(manifestUrl, ticket), listenId: listenId ?? null };
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import type { ApiClient } from '$lib/server/api';
|
||||||
|
import type { components } from '$lib/api/schema';
|
||||||
|
|
||||||
|
type GenreInfo = components['schemas']['GenreResponse'];
|
||||||
|
|
||||||
|
const CACHE_TTL_MS = 5 * 60_000;
|
||||||
|
|
||||||
|
let cached: { genres: GenreInfo[]; expiresAt: number } | null = null;
|
||||||
|
|
||||||
|
export async function getGenres(api: ApiClient): Promise<GenreInfo[]> {
|
||||||
|
if (cached && cached.expiresAt > Date.now()) return cached.genres;
|
||||||
|
|
||||||
|
const { data } = await api.GET('/genres');
|
||||||
|
if (!data) return cached?.genres ?? [];
|
||||||
|
|
||||||
|
cached = { genres: data, expiresAt: Date.now() + CACHE_TTL_MS };
|
||||||
|
return data;
|
||||||
|
}
|
||||||
@@ -1,15 +1,6 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import type { components } from '$lib/api/schema';
|
import type { components } from '$lib/api/schema';
|
||||||
import {
|
import { toAlbum, toDatedTrack, toLikedTrack, toMix, toPage, toPlaylist, toTrack } from './mappers';
|
||||||
toAlbum,
|
|
||||||
toDatedTrack,
|
|
||||||
toLikedTrack,
|
|
||||||
toMix,
|
|
||||||
toPage,
|
|
||||||
toPlaylist,
|
|
||||||
toPlaylistSummary,
|
|
||||||
toTrack
|
|
||||||
} from './mappers';
|
|
||||||
|
|
||||||
const trackDto: components['schemas']['TrackApplicationResponse'] = {
|
const trackDto: components['schemas']['TrackApplicationResponse'] = {
|
||||||
id: 't1',
|
id: 't1',
|
||||||
@@ -107,24 +98,25 @@ describe('toPlaylist', () => {
|
|||||||
largeImageKeyName: null,
|
largeImageKeyName: null,
|
||||||
ownerUserId: '42',
|
ownerUserId: '42',
|
||||||
visibility: 'Public',
|
visibility: 'Public',
|
||||||
|
trackCount: '7',
|
||||||
|
durationSeconds: '90.5',
|
||||||
createdAt: '',
|
createdAt: '',
|
||||||
updatedAt: 'u',
|
updatedAt: 'u',
|
||||||
coverTrackIds: []
|
coverTrackIds: []
|
||||||
};
|
};
|
||||||
it('drops storage keys', () => {
|
it('drops storage keys and normalizes totals', () => {
|
||||||
expect(toPlaylist(dto)).toEqual({
|
expect(toPlaylist(dto)).toEqual({
|
||||||
id: 'p1',
|
id: 'p1',
|
||||||
name: 'List',
|
name: 'List',
|
||||||
description: 'd',
|
description: 'd',
|
||||||
visibility: 'Public',
|
visibility: 'Public',
|
||||||
ownerUserId: '42',
|
ownerUserId: '42',
|
||||||
|
trackCount: 7,
|
||||||
|
durationSeconds: 90.5,
|
||||||
coverTrackIds: [],
|
coverTrackIds: [],
|
||||||
updatedAt: 'u'
|
updatedAt: 'u'
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
it('adds the track count in summaries', () => {
|
|
||||||
expect(toPlaylistSummary(dto, 7).trackCount).toBe(7);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('toPage', () => {
|
describe('toPage', () => {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { components } from '$lib/api/schema';
|
import type { components } from '$lib/api/schema';
|
||||||
import type { Album, LikedTrack, Mix, Paged, Playlist, PlaylistSummary, Track } from '$lib/types';
|
import type { Album, LikedTrack, Mix, Paged, Playlist, Track } from '$lib/types';
|
||||||
|
|
||||||
type TrackDto = components['schemas']['TrackApplicationResponse'];
|
type TrackDto = components['schemas']['TrackApplicationResponse'];
|
||||||
type AlbumDto = components['schemas']['AlbumApplicationResponse'];
|
type AlbumDto = components['schemas']['AlbumApplicationResponse'];
|
||||||
@@ -75,15 +75,13 @@ export function toPlaylist(dto: PlaylistDto): Playlist {
|
|||||||
description: dto.description,
|
description: dto.description,
|
||||||
visibility: dto.visibility,
|
visibility: dto.visibility,
|
||||||
ownerUserId: dto.ownerUserId,
|
ownerUserId: dto.ownerUserId,
|
||||||
|
trackCount: Number(dto.trackCount) || 0,
|
||||||
|
durationSeconds: Number(dto.durationSeconds) || 0,
|
||||||
coverTrackIds: dto.coverTrackIds,
|
coverTrackIds: dto.coverTrackIds,
|
||||||
updatedAt: dto.updatedAt
|
updatedAt: dto.updatedAt
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function toPlaylistSummary(dto: PlaylistDto, trackCount: number): PlaylistSummary {
|
|
||||||
return { ...toPlaylist(dto), trackCount };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function toPage<D, T>(dto: PageDto<D>, map: (item: D) => T): Paged<T> {
|
export function toPage<D, T>(dto: PageDto<D>, map: (item: D) => T): Paged<T> {
|
||||||
return {
|
return {
|
||||||
items: dto.items.map(map),
|
items: dto.items.map(map),
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { fail } from '@sveltejs/kit';
|
import { fail } from '@sveltejs/kit';
|
||||||
import { authedAction, formString } from '$lib/server/api';
|
import { authedAction, formString } from '$lib/server/api';
|
||||||
import { ALBUM_TRACKS_PAGE_SIZE } from '$lib/config';
|
|
||||||
|
|
||||||
export const addTrackAction = authedAction(async ({ api, form, params }) => {
|
export const addTrackAction = authedAction(async ({ api, form, params }) => {
|
||||||
const playlistId = formString(form, 'playlistId') || (params.id ?? '');
|
const playlistId = formString(form, 'playlistId') || (params.id ?? '');
|
||||||
@@ -33,22 +32,14 @@ export const addAlbumToPlaylistAction = authedAction(async ({ api, form, params
|
|||||||
return fail(400, { message: 'Faltan datos del álbum.' });
|
return fail(400, { message: 'Faltan datos del álbum.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const { data: tracks, error: tracksError } = await api.GET('/albums/{albumId}/tracks', {
|
const { data, error } = await api.POST('/playlists/{playlistId}/albums/{albumId}', {
|
||||||
params: { path: { albumId }, query: { pageNumber: 1, pageSize: ALBUM_TRACKS_PAGE_SIZE } }
|
params: { path: { playlistId, albumId } }
|
||||||
});
|
});
|
||||||
|
|
||||||
if (tracksError || !tracks) {
|
if (error || !data) {
|
||||||
return fail(502, { message: 'No se pudo cargar el álbum.', albumId });
|
return fail(502, { message: 'No se pudo añadir el álbum.', albumId });
|
||||||
}
|
|
||||||
|
|
||||||
let added = 0;
|
|
||||||
for (const track of tracks.items) {
|
|
||||||
const { error } = await api.POST('/playlists/{playlistId}/tracks', {
|
|
||||||
params: { path: { playlistId } },
|
|
||||||
body: { trackId: track.id }
|
|
||||||
});
|
|
||||||
if (!error) added++;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const added = Number(data.addedCount);
|
||||||
return { addedAlbum: true, albumId, playlistId, added };
|
return { addedAlbum: true, albumId, playlistId, added };
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { afterNavigate } from '$app/navigation';
|
||||||
|
|
||||||
|
const HISTORY_INDEX = 'sveltekit:history';
|
||||||
|
|
||||||
|
const LABELS: [RegExp, string][] = [
|
||||||
|
[/^\/$/, 'Volver al inicio'],
|
||||||
|
[/^\/explore\/?$/, 'Volver a explorar'],
|
||||||
|
[/^\/playlists\/[^/]+/, 'Volver a la playlist'],
|
||||||
|
[/^\/playlists\/?$/, 'Volver a tus playlists'],
|
||||||
|
[/^\/albums\/[^/]+/, 'Volver al álbum'],
|
||||||
|
[/^\/albums\/?$/, 'Volver a tus álbumes'],
|
||||||
|
[/^\/library\/?$/, 'Volver a tus canciones'],
|
||||||
|
[/^\/upload\/?$/, 'Volver a subir música'],
|
||||||
|
[/^\/mixes\/[^/]+/, 'Volver a la mezcla']
|
||||||
|
];
|
||||||
|
|
||||||
|
export interface PageRef {
|
||||||
|
url: string;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const visited = new Map<number, PageRef>();
|
||||||
|
let index = $state(-1);
|
||||||
|
|
||||||
|
function labelFor(pathname: string): string | null {
|
||||||
|
for (const [pattern, label] of LABELS) {
|
||||||
|
if (pattern.test(pathname)) return label;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function historyIndex(): number | null {
|
||||||
|
const value = history.state?.[HISTORY_INDEX];
|
||||||
|
return typeof value === 'number' ? value : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function trackNavigation() {
|
||||||
|
afterNavigate((nav) => {
|
||||||
|
const at = historyIndex();
|
||||||
|
if (at === null) return;
|
||||||
|
const url = nav.to?.url;
|
||||||
|
const label = url ? labelFor(url.pathname) : null;
|
||||||
|
if (url && label) visited.set(at, { url: url.pathname + url.search, label });
|
||||||
|
else visited.delete(at);
|
||||||
|
index = at;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function previousPage(): PageRef | null {
|
||||||
|
return visited.get(index - 1) ?? null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { contextMenuPosition, type MenuPosition } from '$lib/utils/menuPosition';
|
||||||
|
|
||||||
|
export type MenuState<T> = { target: T } & MenuPosition;
|
||||||
|
|
||||||
|
export function createMenu<T>() {
|
||||||
|
let state = $state<MenuState<T> | null>(null);
|
||||||
|
|
||||||
|
return {
|
||||||
|
get state() {
|
||||||
|
return state;
|
||||||
|
},
|
||||||
|
open(event: MouseEvent, target: T) {
|
||||||
|
event.preventDefault();
|
||||||
|
state = { target, ...contextMenuPosition(event) };
|
||||||
|
},
|
||||||
|
close() {
|
||||||
|
state = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Menu<T> = ReturnType<typeof createMenu<T>>;
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
import {
|
|
||||||
contextMenuPosition,
|
|
||||||
type MenuPosition
|
|
||||||
} from '$lib/components/ui/overlay/ContextMenu.svelte';
|
|
||||||
import { player } from '$lib/player/player.svelte';
|
|
||||||
import type { Track } from '$lib/types';
|
|
||||||
|
|
||||||
export type TrackMenuState = { track: Track } & MenuPosition;
|
|
||||||
|
|
||||||
export function createTrackMenu() {
|
|
||||||
let state = $state<TrackMenuState | null>(null);
|
|
||||||
|
|
||||||
return {
|
|
||||||
get state() {
|
|
||||||
return state;
|
|
||||||
},
|
|
||||||
open(event: MouseEvent, track: Track) {
|
|
||||||
state = { track, ...contextMenuPosition(event) };
|
|
||||||
},
|
|
||||||
close() {
|
|
||||||
state = null;
|
|
||||||
},
|
|
||||||
playNext() {
|
|
||||||
if (!state) return;
|
|
||||||
player.playNext([state.track]);
|
|
||||||
state = null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,125 +0,0 @@
|
|||||||
import { afterNavigate, beforeNavigate } from '$app/navigation';
|
|
||||||
import type { LucideIcon } from '@lucide/svelte';
|
|
||||||
import Home from '@lucide/svelte/icons/house';
|
|
||||||
import Compass from '@lucide/svelte/icons/compass';
|
|
||||||
import ListMusic from '@lucide/svelte/icons/list-music';
|
|
||||||
import Heart from '@lucide/svelte/icons/heart';
|
|
||||||
import Disc from '@lucide/svelte/icons/disc-2';
|
|
||||||
import Folder from '@lucide/svelte/icons/folder';
|
|
||||||
import Upload from '@lucide/svelte/icons/upload';
|
|
||||||
import type { SessionUser } from '../types';
|
|
||||||
|
|
||||||
const HISTORY_INDEX = 'sveltekit:history';
|
|
||||||
|
|
||||||
const LABELS: [RegExp, string][] = [
|
|
||||||
[/^\/$/, 'Volver al inicio'],
|
|
||||||
[/^\/explore\/?$/, 'Volver a explorar'],
|
|
||||||
[/^\/playlists\/[^/]+/, 'Volver a la lista'],
|
|
||||||
[/^\/playlists\/?$/, 'Volver a tus listas'],
|
|
||||||
[/^\/albums\/[^/]+/, 'Volver al álbum'],
|
|
||||||
[/^\/albums\/?$/, 'Volver a tus álbumes'],
|
|
||||||
[/^\/library\/?$/, 'Volver a tus canciones'],
|
|
||||||
[/^\/upload\/?$/, 'Volver a subir música'],
|
|
||||||
[/^\/mixes\/[^/]+/, 'Volver a la mezcla']
|
|
||||||
];
|
|
||||||
|
|
||||||
export interface PageRef {
|
|
||||||
url: string;
|
|
||||||
label: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const visited = new Map<number, PageRef>();
|
|
||||||
let index = $state(-1);
|
|
||||||
|
|
||||||
function labelFor(pathname: string): string | null {
|
|
||||||
for (const [pattern, label] of LABELS) {
|
|
||||||
if (pattern.test(pathname)) return label;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function historyIndex(): number | null {
|
|
||||||
const value = history.state?.[HISTORY_INDEX];
|
|
||||||
return typeof value === 'number' ? value : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function trackNavigation() {
|
|
||||||
afterNavigate((nav) => {
|
|
||||||
const at = historyIndex();
|
|
||||||
if (at === null) return;
|
|
||||||
const url = nav.to?.url;
|
|
||||||
const label = url ? labelFor(url.pathname) : null;
|
|
||||||
if (url && label) visited.set(at, { url: url.pathname + url.search, label });
|
|
||||||
else visited.delete(at);
|
|
||||||
index = at;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const scrollPositions = new Map<number, number>();
|
|
||||||
|
|
||||||
export function restoreScroll(getScroller: () => HTMLElement | undefined) {
|
|
||||||
let current: number | null = null;
|
|
||||||
|
|
||||||
beforeNavigate(() => {
|
|
||||||
const scroller = getScroller();
|
|
||||||
if (current !== null && scroller) scrollPositions.set(current, scroller.scrollTop);
|
|
||||||
});
|
|
||||||
|
|
||||||
afterNavigate((nav) => {
|
|
||||||
current = historyIndex();
|
|
||||||
const scroller = getScroller();
|
|
||||||
if (!scroller || nav.type === 'enter' || nav.to?.url.hash) return;
|
|
||||||
const saved =
|
|
||||||
nav.type === 'popstate' && current !== null ? scrollPositions.get(current) : undefined;
|
|
||||||
scroller.scrollTop = saved ?? 0;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function previousPage(): PageRef | null {
|
|
||||||
return visited.get(index - 1) ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isSectionActive(href: string, pathname: string, section?: string | null): boolean {
|
|
||||||
if (section !== undefined) return section === href;
|
|
||||||
return href === '/' ? pathname === '/' : pathname.startsWith(href);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function searchHref(query: string): string {
|
|
||||||
return query ? `/explore?q=${encodeURIComponent(query)}` : '/explore';
|
|
||||||
}
|
|
||||||
|
|
||||||
export function genreHref(genre: string): string {
|
|
||||||
return `/explore?genre=${encodeURIComponent(genre)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AppNavLink {
|
|
||||||
href: string;
|
|
||||||
label: string;
|
|
||||||
icon: LucideIcon;
|
|
||||||
count?: number;
|
|
||||||
primary: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AppNavCounts {
|
|
||||||
playlists?: number;
|
|
||||||
liked?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function appNavLinks(user: SessionUser | null, counts: AppNavCounts = {}): AppNavLink[] {
|
|
||||||
if (!user) return [{ href: '/explore', label: 'Descubrir', icon: Compass, primary: true }];
|
|
||||||
return [
|
|
||||||
{ href: '/', label: 'Inicio', icon: Home, primary: true },
|
|
||||||
{ href: '/explore', label: 'Descubrir', icon: Compass, primary: true },
|
|
||||||
{
|
|
||||||
href: '/playlists',
|
|
||||||
label: 'Playlists',
|
|
||||||
icon: ListMusic,
|
|
||||||
count: counts.playlists,
|
|
||||||
primary: true
|
|
||||||
},
|
|
||||||
{ href: '/liked', label: 'Me gusta', icon: Heart, count: counts.liked, primary: true },
|
|
||||||
{ href: '/albums', label: 'Mis álbumes', icon: Disc, primary: false },
|
|
||||||
{ href: '/library', label: 'Canciones subidas', icon: Folder, primary: false },
|
|
||||||
{ href: '/upload', label: 'Subir música', icon: Upload, primary: false }
|
|
||||||
];
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import type { Paged } from '$lib/types';
|
||||||
|
|
||||||
|
export function createPagedList<T>(
|
||||||
|
fetchPage: (pageNumber: number) => Promise<Paged<T>>,
|
||||||
|
keyOf: (item: T) => string | number
|
||||||
|
) {
|
||||||
|
let items = $state<T[]>([]);
|
||||||
|
let pageNumber = 1;
|
||||||
|
let hasMore = $state(false);
|
||||||
|
let loading = $state(false);
|
||||||
|
let error = $state(false);
|
||||||
|
|
||||||
|
return {
|
||||||
|
get items() {
|
||||||
|
return items;
|
||||||
|
},
|
||||||
|
get hasMore() {
|
||||||
|
return hasMore;
|
||||||
|
},
|
||||||
|
get loading() {
|
||||||
|
return loading;
|
||||||
|
},
|
||||||
|
get error() {
|
||||||
|
return error;
|
||||||
|
},
|
||||||
|
reset(initial: Paged<T> | null) {
|
||||||
|
items = initial?.items ?? [];
|
||||||
|
pageNumber = initial?.pageNumber ?? 1;
|
||||||
|
hasMore = initial?.hasNextPage ?? false;
|
||||||
|
loading = false;
|
||||||
|
error = false;
|
||||||
|
},
|
||||||
|
async loadMore() {
|
||||||
|
if (loading) return;
|
||||||
|
loading = true;
|
||||||
|
error = false;
|
||||||
|
try {
|
||||||
|
const next = await fetchPage(pageNumber + 1);
|
||||||
|
const seen = new Set(items.map(keyOf));
|
||||||
|
items = [...items, ...next.items.filter((item) => !seen.has(keyOf(item)))];
|
||||||
|
pageNumber = next.pageNumber;
|
||||||
|
hasMore = next.hasNextPage;
|
||||||
|
} catch {
|
||||||
|
error = true;
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
+1
@@ -1,3 +1,4 @@
|
|||||||
import { createToggle } from './toggle.svelte';
|
import { createToggle } from './toggle.svelte';
|
||||||
|
|
||||||
|
export const queuePanel = createToggle();
|
||||||
export const createPlaylistModal = createToggle();
|
export const createPlaylistModal = createToggle();
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { afterNavigate, beforeNavigate } from '$app/navigation';
|
||||||
|
import { historyIndex } from './history.svelte';
|
||||||
|
|
||||||
|
const scrollPositions = new Map<number, number>();
|
||||||
|
|
||||||
|
export function restoreScroll(getScroller: () => HTMLElement | undefined) {
|
||||||
|
let current: number | null = null;
|
||||||
|
|
||||||
|
beforeNavigate(() => {
|
||||||
|
const scroller = getScroller();
|
||||||
|
if (current !== null && scroller) scrollPositions.set(current, scroller.scrollTop);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterNavigate((nav) => {
|
||||||
|
current = historyIndex();
|
||||||
|
const scroller = getScroller();
|
||||||
|
if (!scroller || nav.type === 'enter' || nav.to?.url.hash) return;
|
||||||
|
const saved =
|
||||||
|
nav.type === 'popstate' && current !== null ? scrollPositions.get(current) : undefined;
|
||||||
|
scroller.scrollTop = saved ?? 0;
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { browser } from '$app/environment';
|
||||||
|
import { buildThemeTokens, applyThemeTokens } from './tokens';
|
||||||
|
import { extractAccentHue } from './palette';
|
||||||
|
import { ACCENT_HUE } from './color';
|
||||||
|
|
||||||
|
const FADE_MIN_MS = 2000;
|
||||||
|
const FADE_EXTRA_MS = 2000;
|
||||||
|
|
||||||
|
class AccentState {
|
||||||
|
hue = $state<number>(ACCENT_HUE);
|
||||||
|
#cache = new Map<string, number>();
|
||||||
|
|
||||||
|
async follow(coverUrl: string, isStillCurrent: () => boolean) {
|
||||||
|
const cached = this.#cache.get(coverUrl);
|
||||||
|
if (cached !== undefined) {
|
||||||
|
this.hue = cached;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const extracted = await extractAccentHue(coverUrl);
|
||||||
|
if (!isStillCurrent()) return;
|
||||||
|
if (extracted !== null) this.#cache.set(coverUrl, extracted);
|
||||||
|
this.hue = extracted ?? ACCENT_HUE;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const accent = new AccentState();
|
||||||
|
|
||||||
|
let displayedHue: number = ACCENT_HUE;
|
||||||
|
|
||||||
|
export function animateThemeHue(targetHue: number, mode: 'dark' | 'light'): () => void {
|
||||||
|
if (!browser) return () => {};
|
||||||
|
const fromHue = displayedHue;
|
||||||
|
let delta = targetHue - fromHue;
|
||||||
|
if (delta > 180) delta -= 360;
|
||||||
|
else if (delta < -180) delta += 360;
|
||||||
|
const start = performance.now();
|
||||||
|
const duration = FADE_MIN_MS + Math.random() * FADE_EXTRA_MS;
|
||||||
|
let rafId = 0;
|
||||||
|
const tick = (now: number) => {
|
||||||
|
const t = Math.min(1, (now - start) / duration);
|
||||||
|
const eased = 1 - Math.pow(1 - t, 3);
|
||||||
|
const hue = (((fromHue + delta * eased) % 360) + 360) % 360;
|
||||||
|
displayedHue = hue;
|
||||||
|
applyThemeTokens(buildThemeTokens(hue, mode));
|
||||||
|
if (t < 1) rafId = requestAnimationFrame(tick);
|
||||||
|
};
|
||||||
|
rafId = requestAnimationFrame(tick);
|
||||||
|
return () => cancelAnimationFrame(rafId);
|
||||||
|
}
|
||||||
@@ -1,5 +1 @@
|
|||||||
export const ACCENT_LIGHTNESS = 74;
|
|
||||||
export const ACCENT_CHROMA = 0.06;
|
|
||||||
export const ACCENT_HUE = 30;
|
export const ACCENT_HUE = 30;
|
||||||
|
|
||||||
export const DEFAULT_ACCENT = `oklch(${ACCENT_LIGHTNESS}% ${ACCENT_CHROMA} ${ACCENT_HUE})`;
|
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { browser } from '$app/environment';
|
import { browser } from '$app/environment';
|
||||||
import { ACCENT_LIGHTNESS } from './color';
|
|
||||||
|
|
||||||
function srgbToLinear(channel: number): number {
|
function srgbToLinear(channel: number): number {
|
||||||
const c = channel / 255;
|
const c = channel / 255;
|
||||||
@@ -40,7 +39,7 @@ function loadImage(url: string): Promise<HTMLImageElement> {
|
|||||||
|
|
||||||
const BUCKETS = 36;
|
const BUCKETS = 36;
|
||||||
|
|
||||||
export async function extractAccent(url: string): Promise<string | null> {
|
export async function extractAccentHue(url: string): Promise<number | null> {
|
||||||
if (!browser) return null;
|
if (!browser) return null;
|
||||||
|
|
||||||
let img: HTMLImageElement;
|
let img: HTMLImageElement;
|
||||||
@@ -68,7 +67,6 @@ export async function extractAccent(url: string): Promise<string | null> {
|
|||||||
const weight = new Array(BUCKETS).fill(0);
|
const weight = new Array(BUCKETS).fill(0);
|
||||||
const hueSin = new Array(BUCKETS).fill(0);
|
const hueSin = new Array(BUCKETS).fill(0);
|
||||||
const hueCos = new Array(BUCKETS).fill(0);
|
const hueCos = new Array(BUCKETS).fill(0);
|
||||||
const chromaSum = new Array(BUCKETS).fill(0);
|
|
||||||
|
|
||||||
for (let i = 0; i < pixels.length; i += 4) {
|
for (let i = 0; i < pixels.length; i += 4) {
|
||||||
if (pixels[i + 3] < 128) continue;
|
if (pixels[i + 3] < 128) continue;
|
||||||
@@ -81,7 +79,6 @@ export async function extractAccent(url: string): Promise<string | null> {
|
|||||||
weight[bucket] += w;
|
weight[bucket] += w;
|
||||||
hueSin[bucket] += Math.sin(rad) * w;
|
hueSin[bucket] += Math.sin(rad) * w;
|
||||||
hueCos[bucket] += Math.cos(rad) * w;
|
hueCos[bucket] += Math.cos(rad) * w;
|
||||||
chromaSum[bucket] += C * w;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let best = -1;
|
let best = -1;
|
||||||
@@ -94,11 +91,5 @@ export async function extractAccent(url: string): Promise<string | null> {
|
|||||||
}
|
}
|
||||||
if (best < 0) return null;
|
if (best < 0) return null;
|
||||||
|
|
||||||
const hue = ((Math.atan2(hueSin[best], hueCos[best]) * 180) / Math.PI + 360) % 360;
|
return ((Math.atan2(hueSin[best], hueCos[best]) * 180) / Math.PI + 360) % 360;
|
||||||
const chroma = Math.min(0.11, Math.max(0.045, chromaSum[best] / weight[best]));
|
|
||||||
|
|
||||||
const h = hue.toFixed(1);
|
|
||||||
const c = chroma.toFixed(3);
|
|
||||||
|
|
||||||
return `oklch(${ACCENT_LIGHTNESS}% ${c} ${h})`;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,14 +38,12 @@ export interface Playlist {
|
|||||||
description: string | null;
|
description: string | null;
|
||||||
visibility: 'Public' | 'Private';
|
visibility: 'Public' | 'Private';
|
||||||
ownerUserId: string;
|
ownerUserId: string;
|
||||||
|
trackCount: number;
|
||||||
|
durationSeconds: number;
|
||||||
coverTrackIds: string[];
|
coverTrackIds: string[];
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PlaylistSummary extends Playlist {
|
|
||||||
trackCount: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Mix {
|
export interface Mix {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { albumCover, genreHref, playlistCover, searchHref, trackCover } from './hrefs';
|
||||||
|
|
||||||
|
describe('hrefs', () => {
|
||||||
|
it('builds search and genre links', () => {
|
||||||
|
expect(searchHref('')).toBe('/explore');
|
||||||
|
expect(searchHref('a b')).toBe('/explore?q=a%20b');
|
||||||
|
expect(genreHref('hip hop')).toBe('/explore?genre=hip%20hop');
|
||||||
|
});
|
||||||
|
it('builds cover urls', () => {
|
||||||
|
expect(trackCover('t', 'small')).toBe('/api/tracks/t/cover?size=small');
|
||||||
|
expect(albumCover('a', 'large')).toBe('/api/albums/a/cover?size=large');
|
||||||
|
expect(playlistCover({ id: 'p', updatedAt: '2026-01-01T00:00:00Z' }, 'medium')).toBe(
|
||||||
|
'/api/playlists/p/cover?size=medium&v=2026-01-01T00%3A00%3A00Z'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
type CoverSize = 'small' | 'medium' | 'large';
|
||||||
|
|
||||||
|
export function searchHref(query: string): string {
|
||||||
|
return query ? `/explore?q=${encodeURIComponent(query)}` : '/explore';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function genreHref(genre: string): string {
|
||||||
|
return `/explore?genre=${encodeURIComponent(genre)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function trackCover(id: string, size: CoverSize): string {
|
||||||
|
return `/api/tracks/${id}/cover?size=${size}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function albumCover(id: string, size: CoverSize): string {
|
||||||
|
return `/api/albums/${id}/cover?size=${size}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function playlistCover(
|
||||||
|
playlist: { id: string; updatedAt: string },
|
||||||
|
size: CoverSize
|
||||||
|
): string {
|
||||||
|
return `/api/playlists/${playlist.id}/cover?size=${size}&v=${encodeURIComponent(playlist.updatedAt)}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { MENU_WIDTH, SUBMENU_WIDTH } from '$lib/config';
|
||||||
|
|
||||||
|
export interface MenuPosition {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
openLeft: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function contextMenuPosition(event: MouseEvent): MenuPosition {
|
||||||
|
return {
|
||||||
|
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
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { goto } from '$app/navigation';
|
import { playlistCover } from '$lib/utils/hrefs';
|
||||||
import History from '@lucide/svelte/icons/history';
|
import History from '@lucide/svelte/icons/history';
|
||||||
import Shuffle from '@lucide/svelte/icons/shuffle';
|
import Shuffle from '@lucide/svelte/icons/shuffle';
|
||||||
import ListMusic from '@lucide/svelte/icons/list-music';
|
import ListMusic from '@lucide/svelte/icons/list-music';
|
||||||
import { player, isQueueCurrent, playAllOrToggle, playShuffled } from '$lib/player/player.svelte';
|
import { player } from '$lib/player/player.svelte';
|
||||||
|
import { playShuffled } from '$lib/player/actions';
|
||||||
import type { Track } from '$lib/types';
|
import type { Track } from '$lib/types';
|
||||||
import { mergeRecentlyPlayed } from '$lib/data/recentlyPlayed';
|
import { mergeRecentlyPlayed } from '$lib/data/recentlyPlayed';
|
||||||
import { fetchPlaylistTracks } from '$lib/data/albums';
|
import { fetchPlaylistTracks } from '$lib/data/albums';
|
||||||
@@ -15,43 +16,20 @@
|
|||||||
import SectionHeading from '$lib/components/ui/layout/SectionHeading.svelte';
|
import SectionHeading from '$lib/components/ui/layout/SectionHeading.svelte';
|
||||||
import EmptyState from '$lib/components/ui/primitives/EmptyState.svelte';
|
import EmptyState from '$lib/components/ui/primitives/EmptyState.svelte';
|
||||||
import Button from '$lib/components/ui/primitives/Button.svelte';
|
import Button from '$lib/components/ui/primitives/Button.svelte';
|
||||||
import ContextMenu from '$lib/components/ui/overlay/ContextMenu.svelte';
|
import PlayAllButton from '$lib/components/ui/media/PlayAllButton.svelte';
|
||||||
import ListPlus from '@lucide/svelte/icons/list-plus';
|
import TrackContextMenu from '$lib/components/ui/overlay/TrackContextMenu.svelte';
|
||||||
import { createTrackMenu } from '$lib/state/menus.svelte';
|
import { createMenu } from '$lib/state/menu.svelte';
|
||||||
import { HOME_CONTINUE_LIMIT } from '$lib/config';
|
import { HOME_CONTINUE_LIMIT } from '$lib/config';
|
||||||
|
|
||||||
let { data } = $props();
|
let { data } = $props();
|
||||||
|
|
||||||
const trackMenu = createTrackMenu();
|
const trackMenu = createMenu<Track>();
|
||||||
|
|
||||||
const mixes = $derived(data.mixes);
|
|
||||||
const playlists = $derived(data.playlists);
|
|
||||||
const spotlight = $derived(data.spotlightPlaylist);
|
const spotlight = $derived(data.spotlightPlaylist);
|
||||||
|
|
||||||
const continueItems = $derived(mergeRecentlyPlayed(data.recentlyPlayed, HOME_CONTINUE_LIMIT));
|
const continueItems = $derived(mergeRecentlyPlayed(data.recentlyPlayed, HOME_CONTINUE_LIMIT));
|
||||||
|
|
||||||
function playContinue(index: number) {
|
|
||||||
player.playOrToggle(continueItems, index);
|
|
||||||
}
|
|
||||||
function playSpotlightTrack(items: Track[], index: number) {
|
|
||||||
player.playOrToggle(items, index);
|
|
||||||
}
|
|
||||||
async function playSpotlight(previewItems: Track[]) {
|
|
||||||
if (!spotlight) return;
|
|
||||||
if (isQueueCurrent(previewItems)) {
|
|
||||||
player.toggle();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
playAllOrToggle(await fetchPlaylistTracks(spotlight.id));
|
|
||||||
}
|
|
||||||
async function shuffleSpotlight() {
|
async function shuffleSpotlight() {
|
||||||
if (!spotlight) return;
|
if (data.spotlightPlaylist) playShuffled(await fetchPlaylistTracks(data.spotlightPlaylist.id));
|
||||||
playShuffled(await fetchPlaylistTracks(spotlight.id));
|
|
||||||
}
|
|
||||||
|
|
||||||
function playMix(mix: (typeof mixes)[number], event: MouseEvent) {
|
|
||||||
event.preventDefault();
|
|
||||||
player.playQueue(mix.tracks, 0);
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -88,11 +66,14 @@
|
|||||||
</p>
|
</p>
|
||||||
<div class="mt-6.5 flex flex-wrap items-center gap-2.5">
|
<div class="mt-6.5 flex flex-wrap items-center gap-2.5">
|
||||||
{#if spotlight}
|
{#if spotlight}
|
||||||
{#await data.spotlightTracks then { items: previewItems }}
|
{#await data.spotlightTracks then { items }}
|
||||||
<Button variant="primary" onclick={() => playSpotlight(previewItems)}>
|
<PlayAllButton
|
||||||
{isQueueCurrent(previewItems) && player.playing ? 'Pausar' : 'Reanudar'}
|
{items}
|
||||||
{spotlight.name}
|
load={() => fetchPlaylistTracks(spotlight.id)}
|
||||||
</Button>
|
size="md"
|
||||||
|
idleLabel="Reanudar {spotlight.name}"
|
||||||
|
activeLabel="Pausar {spotlight.name}"
|
||||||
|
/>
|
||||||
{/await}
|
{/await}
|
||||||
{/if}
|
{/if}
|
||||||
<Button href="/explore" variant="secondary">Explorar música</Button>
|
<Button href="/explore" variant="secondary">Explorar música</Button>
|
||||||
@@ -128,7 +109,7 @@
|
|||||||
{#each continueItems as item, i (item.id)}
|
{#each continueItems as item, i (item.id)}
|
||||||
{@const isCurrent = player.currentId === item.id}
|
{@const isCurrent = player.currentId === item.id}
|
||||||
<ListRow
|
<ListRow
|
||||||
onclick={() => playContinue(i)}
|
onclick={() => player.playOrToggle(continueItems, i)}
|
||||||
oncontextmenu={(e) => trackMenu.open(e, item)}
|
oncontextmenu={(e) => trackMenu.open(e, item)}
|
||||||
active={isCurrent}
|
active={isCurrent}
|
||||||
size="lg"
|
size="lg"
|
||||||
@@ -155,7 +136,7 @@
|
|||||||
{@const meta =
|
{@const meta =
|
||||||
totalCount === 0
|
totalCount === 0
|
||||||
? visibilityLabel
|
? visibilityLabel
|
||||||
: `${plural(totalCount, 'canción', 'canciones')} · ${visibilityLabel}`}
|
: `${plural(totalCount, 'canción', 'canciones')} · ${fmtDurationLong(spotlight.durationSeconds)} · ${visibilityLabel}`}
|
||||||
<div
|
<div
|
||||||
class="grid spotlight-surface grid-cols-1 overflow-hidden rounded-panel-lg lg:grid-cols-2"
|
class="grid spotlight-surface grid-cols-1 overflow-hidden rounded-panel-lg lg:grid-cols-2"
|
||||||
>
|
>
|
||||||
@@ -165,9 +146,7 @@
|
|||||||
class="flex w-fit items-center gap-5 self-start sm:gap-6"
|
class="flex w-fit items-center gap-5 self-start sm:gap-6"
|
||||||
>
|
>
|
||||||
<Artwork
|
<Artwork
|
||||||
src="/api/playlists/{spotlight.id}/cover?size=large&v={encodeURIComponent(
|
src={playlistCover(spotlight, 'large')}
|
||||||
spotlight.updatedAt
|
|
||||||
)}"
|
|
||||||
trackIds={spotlight.coverTrackIds}
|
trackIds={spotlight.coverTrackIds}
|
||||||
size="2xl"
|
size="2xl"
|
||||||
alt={spotlight.name}
|
alt={spotlight.name}
|
||||||
@@ -185,16 +164,19 @@
|
|||||||
{spotlight.description || 'Tu colección, siempre a mano.'}
|
{spotlight.description || 'Tu colección, siempre a mano.'}
|
||||||
</p>
|
</p>
|
||||||
<div class="mt-5 flex gap-2.5">
|
<div class="mt-5 flex gap-2.5">
|
||||||
<Button variant="accent" onclick={() => playSpotlight(previewItems)}
|
<PlayAllButton
|
||||||
>Reproducir</Button
|
items={previewItems}
|
||||||
>
|
load={() => fetchPlaylistTracks(spotlight.id)}
|
||||||
|
variant="accent"
|
||||||
|
size="md"
|
||||||
|
/>
|
||||||
<Button variant="secondary" onclick={shuffleSpotlight}>Aleatorio</Button>
|
<Button variant="secondary" onclick={shuffleSpotlight}>Aleatorio</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-col gap-2 p-5.5 sm:p-6">
|
<div class="flex flex-col gap-2 p-5.5 sm:p-6">
|
||||||
{#each previewItems as track, i (track.id)}
|
{#each previewItems as track, i (track.id)}
|
||||||
<ListRow
|
<ListRow
|
||||||
onclick={() => playSpotlightTrack(previewItems, i)}
|
onclick={() => player.playOrToggle(previewItems, i)}
|
||||||
active={player.currentId === track.id}
|
active={player.currentId === track.id}
|
||||||
size="sm"
|
size="sm"
|
||||||
title={track.title}
|
title={track.title}
|
||||||
@@ -223,16 +205,19 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
<SectionHeading title="Mixes" subtitle="Generados a partir de lo que más repites" />
|
<SectionHeading title="Mezclas" subtitle="Generados a partir de lo que más repites" />
|
||||||
{#if mixes.length > 0}
|
{#if data.mixes.length > 0}
|
||||||
<div class="grid-tiles">
|
<div class="grid-tiles">
|
||||||
{#each mixes as mix, i (mix.id)}
|
{#each data.mixes as mix, i (mix.id)}
|
||||||
<MediaCard
|
<MediaCard
|
||||||
href="/mixes/{mix.id}"
|
href="/mixes/{mix.id}"
|
||||||
title={mix.title}
|
title={mix.title}
|
||||||
subtitle={mix.subtitle}
|
subtitle={mix.subtitle}
|
||||||
trackIds={mix.tracks.map((track) => track.id)}
|
trackIds={mix.tracks.map((track) => track.id)}
|
||||||
onPlay={(event) => playMix(mix, event)}
|
onPlay={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
player.playQueue(mix.tracks, 0);
|
||||||
|
}}
|
||||||
index={i}
|
index={i}
|
||||||
/>
|
/>
|
||||||
{/each}
|
{/each}
|
||||||
@@ -252,18 +237,16 @@
|
|||||||
subtitle="Creadas y guardadas por ti"
|
subtitle="Creadas y guardadas por ti"
|
||||||
href={data.playlistsHasMore ? '/playlists' : undefined}
|
href={data.playlistsHasMore ? '/playlists' : undefined}
|
||||||
/>
|
/>
|
||||||
{#if playlists.length > 0}
|
{#if data.playlists.length > 0}
|
||||||
<div class="grid-wide">
|
<div class="grid-wide">
|
||||||
{#each playlists as playlist, i (playlist.id)}
|
{#each data.playlists as playlist, i (playlist.id)}
|
||||||
<ListRow
|
<ListRow
|
||||||
onclick={() => goto(`/playlists/${playlist.id}`)}
|
href="/playlists/{playlist.id}"
|
||||||
size="lg"
|
size="lg"
|
||||||
variant="card"
|
variant="card"
|
||||||
title={playlist.name}
|
title={playlist.name}
|
||||||
subtitle={playlist.description}
|
subtitle={playlist.description}
|
||||||
coverSrc="/api/playlists/{playlist.id}/cover?size=medium&v={encodeURIComponent(
|
coverSrc={playlistCover(playlist, 'medium')}
|
||||||
playlist.updatedAt
|
|
||||||
)}"
|
|
||||||
trackIds={playlist.coverTrackIds}
|
trackIds={playlist.coverTrackIds}
|
||||||
style="--i:{i}"
|
style="--i:{i}"
|
||||||
/>
|
/>
|
||||||
@@ -272,11 +255,11 @@
|
|||||||
{:else}
|
{:else}
|
||||||
<EmptyState
|
<EmptyState
|
||||||
icon={ListMusic}
|
icon={ListMusic}
|
||||||
title="Todavía no tienes listas"
|
title="Todavía no tienes playlists"
|
||||||
description="Crea tu primera lista y añádele canciones de tu biblioteca."
|
description="Crea tu primera playlist y añádele canciones de tu biblioteca."
|
||||||
>
|
>
|
||||||
{#snippet actions()}
|
{#snippet actions()}
|
||||||
<Button href="/playlists">Crear lista</Button>
|
<Button href="/playlists">Crear playlist</Button>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</EmptyState>
|
</EmptyState>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -284,20 +267,4 @@
|
|||||||
</div>
|
</div>
|
||||||
</Page>
|
</Page>
|
||||||
|
|
||||||
{#if trackMenu.state}
|
<TrackContextMenu menu={trackMenu} playlists={data.userPlaylists} />
|
||||||
<ContextMenu
|
|
||||||
x={trackMenu.state.x}
|
|
||||||
y={trackMenu.state.y}
|
|
||||||
openLeft={trackMenu.state.openLeft}
|
|
||||||
onClose={() => trackMenu.close()}
|
|
||||||
items={[
|
|
||||||
{ icon: ListPlus, label: 'Reproducir a continuación', onclick: () => trackMenu.playNext() }
|
|
||||||
]}
|
|
||||||
playlistAction={{
|
|
||||||
action: '?/addTrack',
|
|
||||||
fields: { trackId: trackMenu.state.track.id },
|
|
||||||
label: 'Añadir a una playlist'
|
|
||||||
}}
|
|
||||||
playlists={data.userPlaylists}
|
|
||||||
/>
|
|
||||||
{/if}
|
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { albumCover } from '$lib/utils/hrefs';
|
||||||
import Plus from '@lucide/svelte/icons/plus';
|
import Plus from '@lucide/svelte/icons/plus';
|
||||||
import Disc from '@lucide/svelte/icons/disc-3';
|
import Disc from '@lucide/svelte/icons/disc-3';
|
||||||
import Page from '$lib/components/ui/layout/Page.svelte';
|
import Page from '$lib/components/ui/layout/Page.svelte';
|
||||||
@@ -27,7 +28,7 @@
|
|||||||
{#snippet actions()}
|
{#snippet actions()}
|
||||||
{#if items.length > 0}
|
{#if items.length > 0}
|
||||||
<Button onclick={() => (creating = true)}>
|
<Button onclick={() => (creating = true)}>
|
||||||
<Plus class="size-4" />
|
<Plus class="size-icon-sm" />
|
||||||
Nuevo álbum
|
Nuevo álbum
|
||||||
</Button>
|
</Button>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -42,7 +43,7 @@
|
|||||||
title={album.title}
|
title={album.title}
|
||||||
subtitle={albumMeta(album.releaseYear, album.trackCount)}
|
subtitle={albumMeta(album.releaseYear, album.trackCount)}
|
||||||
trackIds={album.coverTrackIds}
|
trackIds={album.coverTrackIds}
|
||||||
src="/api/albums/{album.id}/cover?size=large"
|
src={albumCover(album.id, 'large')}
|
||||||
index={i}
|
index={i}
|
||||||
/>
|
/>
|
||||||
{/each}
|
{/each}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { player, isQueueCurrent, playAllOrToggle } from '$lib/player/player.svelte';
|
import { albumCover } from '$lib/utils/hrefs';
|
||||||
|
import { player } from '$lib/player/player.svelte';
|
||||||
|
import PlayAllButton from '$lib/components/ui/media/PlayAllButton.svelte';
|
||||||
import X from '@lucide/svelte/icons/x';
|
import X from '@lucide/svelte/icons/x';
|
||||||
import Plus from '@lucide/svelte/icons/plus';
|
import Plus from '@lucide/svelte/icons/plus';
|
||||||
import Page from '$lib/components/ui/layout/Page.svelte';
|
import Page from '$lib/components/ui/layout/Page.svelte';
|
||||||
@@ -25,12 +27,6 @@
|
|||||||
let editing = $state(false);
|
let editing = $state(false);
|
||||||
let confirmingDelete = $state(false);
|
let confirmingDelete = $state(false);
|
||||||
|
|
||||||
const isCurrentQueue = $derived(isQueueCurrent(tracks));
|
|
||||||
|
|
||||||
function playAll() {
|
|
||||||
playAllOrToggle(tracks);
|
|
||||||
}
|
|
||||||
|
|
||||||
function playTrackFrom(index: number) {
|
function playTrackFrom(index: number) {
|
||||||
player.playOrToggle(tracks, index);
|
player.playOrToggle(tracks, index);
|
||||||
}
|
}
|
||||||
@@ -59,7 +55,7 @@
|
|||||||
>
|
>
|
||||||
{#snippet cover()}
|
{#snippet cover()}
|
||||||
<Artwork
|
<Artwork
|
||||||
src="/api/albums/{album.id}/cover?size=large"
|
src={albumCover(album.id, 'large')}
|
||||||
trackIds={tracks.map((track) => track.id)}
|
trackIds={tracks.map((track) => track.id)}
|
||||||
size="hero"
|
size="hero"
|
||||||
alt={album.title}
|
alt={album.title}
|
||||||
@@ -67,13 +63,7 @@
|
|||||||
/>
|
/>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
{#snippet actions()}
|
{#snippet actions()}
|
||||||
<Button size="sm" onclick={playAll} disabled={tracks.length === 0}>
|
<PlayAllButton items={tracks} />
|
||||||
{#if isCurrentQueue && player.playing}
|
|
||||||
Pausar
|
|
||||||
{:else}
|
|
||||||
Reproducir
|
|
||||||
{/if}
|
|
||||||
</Button>
|
|
||||||
{#if isOwner}
|
{#if isOwner}
|
||||||
<Button size="sm" variant="secondary" onclick={() => (editing = true)}>Editar</Button>
|
<Button size="sm" variant="secondary" onclick={() => (editing = true)}>Editar</Button>
|
||||||
<Button size="sm" variant="secondary" onclick={() => (confirmingDelete = true)}>
|
<Button size="sm" variant="secondary" onclick={() => (confirmingDelete = true)}>
|
||||||
@@ -116,7 +106,7 @@
|
|||||||
initialTitle={album.title}
|
initialTitle={album.title}
|
||||||
initialDescription={album.description ?? ''}
|
initialDescription={album.description ?? ''}
|
||||||
initialReleaseYear={album.releaseYear ?? undefined}
|
initialReleaseYear={album.releaseYear ?? undefined}
|
||||||
coverFallbackUrl="/api/albums/{album.id}/cover?size=medium"
|
coverFallbackUrl={albumCover(album.id, 'medium')}
|
||||||
formMessage={form?.message}
|
formMessage={form?.message}
|
||||||
submitLabel="Guardar"
|
submitLabel="Guardar"
|
||||||
submittingLabel="Guardando…"
|
submittingLabel="Guardando…"
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { PageServerLoad, Actions } from './$types';
|
import type { PageServerLoad, Actions } from './$types';
|
||||||
import { apiFor, unwrapOrError } from '$lib/server/api';
|
import { apiFor, unwrapOrError } from '$lib/server/api';
|
||||||
|
import { getGenres } from '$lib/server/genres';
|
||||||
import { toAlbum, toPage, toTrack } from '$lib/server/mappers';
|
import { toAlbum, toPage, toTrack } from '$lib/server/mappers';
|
||||||
import { addAlbumToPlaylistAction, addTrackAction } from '$lib/server/playlistActions';
|
import { addAlbumToPlaylistAction, addTrackAction } from '$lib/server/playlistActions';
|
||||||
import { EXPLORE_ALBUMS_PAGE_SIZE, EXPLORE_PAGE_SIZE, EXPLORE_USERS_LIMIT } from '$lib/config';
|
import { EXPLORE_ALBUMS_PAGE_SIZE, EXPLORE_PAGE_SIZE, EXPLORE_USERS_LIMIT } from '$lib/config';
|
||||||
@@ -12,11 +13,8 @@ export const load: PageServerLoad = async ({ url, locals, fetch }) => {
|
|||||||
const user = locals.user;
|
const user = locals.user;
|
||||||
const api = apiFor({ fetch, locals });
|
const api = apiFor({ fetch, locals });
|
||||||
|
|
||||||
const genresRes = await api.GET('/genres');
|
const tracksFor = (genre: string | null) =>
|
||||||
const genres = genresRes.data ?? [];
|
api.GET('/tracks', {
|
||||||
const genre = genres.find((item) => item.genre === genreParam)?.genre ?? null;
|
|
||||||
|
|
||||||
const tracksPromise = api.GET('/tracks', {
|
|
||||||
params: {
|
params: {
|
||||||
query: {
|
query: {
|
||||||
name: query || undefined,
|
name: query || undefined,
|
||||||
@@ -39,11 +37,14 @@ export const load: PageServerLoad = async ({ url, locals, fetch }) => {
|
|||||||
})
|
})
|
||||||
: Promise.resolve(null);
|
: Promise.resolve(null);
|
||||||
|
|
||||||
const [tracksRes, albumsRes, usersRes] = await Promise.all([
|
const [genres, firstTracksRes, albumsRes, usersRes] = await Promise.all([
|
||||||
tracksPromise,
|
getGenres(api),
|
||||||
|
tracksFor(genreParam),
|
||||||
albumsPromise,
|
albumsPromise,
|
||||||
usersPromise
|
usersPromise
|
||||||
]);
|
]);
|
||||||
|
const genre = genres.find((item) => item.genre === genreParam)?.genre ?? null;
|
||||||
|
const tracksRes = genre === genreParam ? firstTracksRes : await tracksFor(genre);
|
||||||
|
|
||||||
const tracks = toPage(unwrapOrError(tracksRes, 'No se pudieron cargar las canciones.'), (item) =>
|
const tracks = toPage(unwrapOrError(tracksRes, 'No se pudieron cargar las canciones.'), (item) =>
|
||||||
toTrack(item.track)
|
toTrack(item.track)
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Music from '@lucide/svelte/icons/music';
|
import Music from '@lucide/svelte/icons/music';
|
||||||
import { player } from '$lib/player/player.svelte';
|
import { player } from '$lib/player/player.svelte';
|
||||||
import { fetchAlbumTracks } from '$lib/data/albums';
|
import type { Paged, Track } from '$lib/types';
|
||||||
import type { Track } from '$lib/types';
|
|
||||||
import { fmtTime, fmtPlays, plural } from '$lib/utils/format';
|
import { fmtTime, fmtPlays, plural } from '$lib/utils/format';
|
||||||
import Page from '$lib/components/ui/layout/Page.svelte';
|
import Page from '$lib/components/ui/layout/Page.svelte';
|
||||||
import PageHeader from '$lib/components/ui/layout/PageHeader.svelte';
|
import PageHeader from '$lib/components/ui/layout/PageHeader.svelte';
|
||||||
@@ -18,25 +17,23 @@
|
|||||||
import Chip from '$lib/components/ui/primitives/Chip.svelte';
|
import Chip from '$lib/components/ui/primitives/Chip.svelte';
|
||||||
import SectionHeading from '$lib/components/ui/layout/SectionHeading.svelte';
|
import SectionHeading from '$lib/components/ui/layout/SectionHeading.svelte';
|
||||||
import { EXPLORE_PAGE_SIZE } from '$lib/config';
|
import { EXPLORE_PAGE_SIZE } from '$lib/config';
|
||||||
import { genreHref } from '$lib/state/navigation.svelte';
|
import { genreHref, playlistCover } from '$lib/utils/hrefs';
|
||||||
import { createTrackMenu } from '$lib/state/menus.svelte';
|
import { createMenu } from '$lib/state/menu.svelte';
|
||||||
import ContextMenu, { contextMenuPosition } from '$lib/components/ui/overlay/ContextMenu.svelte';
|
import { createPagedList } from '$lib/state/pagedList.svelte';
|
||||||
import ListPlus from '@lucide/svelte/icons/list-plus';
|
import TrackContextMenu from '$lib/components/ui/overlay/TrackContextMenu.svelte';
|
||||||
import ListEnd from '@lucide/svelte/icons/list-end';
|
import AlbumContextMenu from '$lib/components/ui/overlay/AlbumContextMenu.svelte';
|
||||||
import { appendUnique } from '$lib/data/collections';
|
|
||||||
import { genreInfo, genreTiles } from '$lib/data/genres';
|
import { genreInfo, genreTiles } from '$lib/data/genres';
|
||||||
import {
|
import {
|
||||||
type SearchFilter,
|
type SearchFilter,
|
||||||
showGroup,
|
showGroup,
|
||||||
capped,
|
capped,
|
||||||
matchPlaylists,
|
matchPlaylists,
|
||||||
searchCounts,
|
SEARCH_LABELS,
|
||||||
|
searchTotal,
|
||||||
searchChips,
|
searchChips,
|
||||||
findTopResult
|
findTopResult
|
||||||
} from '$lib/data/search';
|
} from '$lib/data/search';
|
||||||
|
|
||||||
type AlbumMenuState = { x: number; y: number; openLeft: boolean; albumId: string };
|
|
||||||
|
|
||||||
const TOP_RESULT_KIND_LABEL: Record<TopResult['kind'], string> = {
|
const TOP_RESULT_KIND_LABEL: Record<TopResult['kind'], string> = {
|
||||||
track: 'Canción',
|
track: 'Canción',
|
||||||
album: 'Álbum',
|
album: 'Álbum',
|
||||||
@@ -46,25 +43,33 @@
|
|||||||
|
|
||||||
let { data, form } = $props();
|
let { data, form } = $props();
|
||||||
|
|
||||||
const trackMenu = createTrackMenu();
|
const trackMenu = createMenu<Track>();
|
||||||
let albumMenu = $state<AlbumMenuState | null>(null);
|
const albumMenu = createMenu<string>();
|
||||||
let sfilter = $state<SearchFilter>('Todo');
|
let sfilter = $state<SearchFilter>('all');
|
||||||
|
|
||||||
let localItems = $state<Track[]>([]);
|
const tracksList = createPagedList<Track>(
|
||||||
let localPage = $state(1);
|
async (pageNumber) => {
|
||||||
let hasMore = $state(false);
|
const params = new URLSearchParams({
|
||||||
let loadingMore = $state(false);
|
pageNumber: String(pageNumber),
|
||||||
|
pageSize: String(EXPLORE_PAGE_SIZE)
|
||||||
|
});
|
||||||
|
if (data.query) params.set('name', data.query);
|
||||||
|
if (data.genre) params.set('genre', data.genre);
|
||||||
|
const res = await fetch(`/api/tracks?${params}`);
|
||||||
|
if (!res.ok) throw new Error(String(res.status));
|
||||||
|
return (await res.json()) as Paged<Track>;
|
||||||
|
},
|
||||||
|
(track) => track.id
|
||||||
|
);
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
trackMenu.close();
|
trackMenu.close();
|
||||||
albumMenu = null;
|
albumMenu.close();
|
||||||
sfilter = data.genre ? 'Canciones' : 'Todo';
|
sfilter = data.genre ? 'tracks' : 'all';
|
||||||
localItems = data.tracks.items;
|
tracksList.reset(data.tracks);
|
||||||
localPage = data.tracks.pageNumber;
|
|
||||||
hasMore = data.tracks.hasNextPage;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const items = $derived(localItems);
|
const items = $derived(tracksList.items);
|
||||||
|
|
||||||
const tiles = $derived(genreTiles(data.genres));
|
const tiles = $derived(genreTiles(data.genres));
|
||||||
const activeGenre = $derived(data.genre ? genreInfo(data.genre) : null);
|
const activeGenre = $derived(data.genre ? genreInfo(data.genre) : null);
|
||||||
@@ -82,18 +87,14 @@
|
|||||||
!!data.query && items.length === 0 && !hasAlbums && !hasUsers && !hasPlaylists
|
!!data.query && items.length === 0 && !hasAlbums && !hasUsers && !hasPlaylists
|
||||||
);
|
);
|
||||||
|
|
||||||
const counts = $derived(
|
const counts = $derived({
|
||||||
searchCounts({
|
|
||||||
tracks: data.tracks.totalItemCount,
|
tracks: data.tracks.totalItemCount,
|
||||||
albums: data.albumsTotal,
|
albums: data.albumsTotal,
|
||||||
playlists: playlistMatches.length,
|
playlists: playlistMatches.length,
|
||||||
users: data.usersTotal
|
users: data.usersTotal
|
||||||
})
|
});
|
||||||
);
|
|
||||||
const chips = $derived(searchChips(counts));
|
const chips = $derived(searchChips(counts));
|
||||||
const totalHits = $derived(
|
const totalHits = $derived(searchTotal(counts));
|
||||||
counts.Canciones + counts.Álbumes + counts.Playlists + counts.Usuarios
|
|
||||||
);
|
|
||||||
|
|
||||||
type TopResult = NonNullable<
|
type TopResult = NonNullable<
|
||||||
ReturnType<
|
ReturnType<
|
||||||
@@ -107,7 +108,7 @@
|
|||||||
>;
|
>;
|
||||||
|
|
||||||
const topResult = $derived<TopResult | null>(
|
const topResult = $derived<TopResult | null>(
|
||||||
sfilter === 'Todo' && data.query
|
sfilter === 'all' && data.query
|
||||||
? findTopResult({
|
? findTopResult({
|
||||||
query: data.query,
|
query: data.query,
|
||||||
tracks: items,
|
tracks: items,
|
||||||
@@ -129,51 +130,6 @@
|
|||||||
if (topResult.kind === 'playlist') return `/playlists/${topResult.playlist.id}`;
|
if (topResult.kind === 'playlist') return `/playlists/${topResult.playlist.id}`;
|
||||||
return `/user/${topResult.user.id}`;
|
return `/user/${topResult.user.id}`;
|
||||||
});
|
});
|
||||||
|
|
||||||
function togglePlay(index: number) {
|
|
||||||
player.playOrToggle(items, index);
|
|
||||||
}
|
|
||||||
|
|
||||||
function openAlbumMenu(event: MouseEvent, albumId: string) {
|
|
||||||
albumMenu = { ...contextMenuPosition(event), albumId };
|
|
||||||
}
|
|
||||||
|
|
||||||
async function albumPlayNext() {
|
|
||||||
if (!albumMenu) return;
|
|
||||||
const { albumId } = albumMenu;
|
|
||||||
albumMenu = null;
|
|
||||||
player.playNext(await fetchAlbumTracks(albumId));
|
|
||||||
}
|
|
||||||
|
|
||||||
async function albumAddToQueue() {
|
|
||||||
if (!albumMenu) return;
|
|
||||||
const { albumId } = albumMenu;
|
|
||||||
albumMenu = null;
|
|
||||||
player.appendToQueue(await fetchAlbumTracks(albumId));
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadMore() {
|
|
||||||
if (loadingMore) return;
|
|
||||||
loadingMore = true;
|
|
||||||
try {
|
|
||||||
const params = new URLSearchParams({
|
|
||||||
pageNumber: String(localPage + 1),
|
|
||||||
pageSize: String(EXPLORE_PAGE_SIZE)
|
|
||||||
});
|
|
||||||
if (data.query) params.set('name', data.query);
|
|
||||||
if (data.genre) params.set('genre', data.genre);
|
|
||||||
const res = await fetch(`/api/tracks?${params}`);
|
|
||||||
if (!res.ok) throw new Error(String(res.status));
|
|
||||||
const next = (await res.json()) as typeof data.tracks;
|
|
||||||
localItems = appendUnique(localItems, next.items, (track) => track.id);
|
|
||||||
localPage = next.pageNumber;
|
|
||||||
hasMore = next.hasNextPage;
|
|
||||||
} catch {
|
|
||||||
hasMore = false;
|
|
||||||
} finally {
|
|
||||||
loadingMore = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
@@ -206,11 +162,11 @@
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<div class="mb-7 flex flex-wrap gap-2 sm:mb-8">
|
<div class="mb-7 flex flex-wrap gap-2 sm:mb-8">
|
||||||
{#each chips as chip (chip.label)}
|
{#each chips as chip (chip.filter)}
|
||||||
<Chip
|
<Chip
|
||||||
selected={sfilter === chip.label}
|
selected={sfilter === chip.filter}
|
||||||
count={chip.count}
|
count={chip.count}
|
||||||
onclick={() => (sfilter = chip.label)}
|
onclick={() => (sfilter = chip.filter)}
|
||||||
>
|
>
|
||||||
{chip.label}
|
{chip.label}
|
||||||
</Chip>
|
</Chip>
|
||||||
@@ -230,9 +186,7 @@
|
|||||||
<Artwork trackIds={topResult.album.coverTrackIds} size="xl" class="shrink-0" />
|
<Artwork trackIds={topResult.album.coverTrackIds} size="xl" class="shrink-0" />
|
||||||
{:else if topResult.kind === 'playlist'}
|
{:else if topResult.kind === 'playlist'}
|
||||||
<Artwork
|
<Artwork
|
||||||
src="/api/playlists/{topResult.playlist.id}/cover?size=large&v={encodeURIComponent(
|
src={playlistCover(topResult.playlist, 'large')}
|
||||||
topResult.playlist.updatedAt
|
|
||||||
)}"
|
|
||||||
trackIds={topResult.playlist.coverTrackIds}
|
trackIds={topResult.playlist.coverTrackIds}
|
||||||
size="xl"
|
size="xl"
|
||||||
class="shrink-0"
|
class="shrink-0"
|
||||||
@@ -322,10 +276,10 @@
|
|||||||
|
|
||||||
{#if (data.query || data.genre) && !nothingFound}
|
{#if (data.query || data.genre) && !nothingFound}
|
||||||
<div class="mt-9 flex flex-col gap-9">
|
<div class="mt-9 flex flex-col gap-9">
|
||||||
{#if showGroup(sfilter, 'Canciones')}
|
{#if showGroup(sfilter, 'tracks')}
|
||||||
<div>
|
<div>
|
||||||
{#if items.length > 0}
|
{#if items.length > 0}
|
||||||
<SectionHeading title="Canciones" count={counts.Canciones} />
|
<SectionHeading title={SEARCH_LABELS.tracks} count={counts.tracks} />
|
||||||
<ul class="flex flex-col gap-1">
|
<ul class="flex flex-col gap-1">
|
||||||
{#each capped(sfilter, items) as track, i (track.id)}
|
{#each capped(sfilter, items) as track, i (track.id)}
|
||||||
<li>
|
<li>
|
||||||
@@ -337,7 +291,7 @@
|
|||||||
active={player.currentId === track.id}
|
active={player.currentId === track.id}
|
||||||
size="lg"
|
size="lg"
|
||||||
trackId={track.id}
|
trackId={track.id}
|
||||||
onclick={() => togglePlay(i)}
|
onclick={() => player.playOrToggle(items, i)}
|
||||||
oncontextmenu={(e) => trackMenu.open(e, track)}
|
oncontextmenu={(e) => trackMenu.open(e, track)}
|
||||||
>
|
>
|
||||||
{#snippet overlay()}
|
{#snippet overlay()}
|
||||||
@@ -355,8 +309,13 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
{#if sfilter === 'Canciones' && hasMore}
|
{#if sfilter === 'tracks' && tracksList.hasMore}
|
||||||
<InfiniteScroll onLoadMore={loadMore} {hasMore} loading={loadingMore} />
|
<InfiniteScroll
|
||||||
|
onLoadMore={() => tracksList.loadMore()}
|
||||||
|
hasMore={tracksList.hasMore}
|
||||||
|
loading={tracksList.loading}
|
||||||
|
error={tracksList.error}
|
||||||
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
{:else}
|
{:else}
|
||||||
<EmptyState
|
<EmptyState
|
||||||
@@ -369,9 +328,9 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if hasAlbums && showGroup(sfilter, 'Álbumes')}
|
{#if hasAlbums && showGroup(sfilter, 'albums')}
|
||||||
<div>
|
<div>
|
||||||
<SectionHeading title="Álbumes" count={counts.Álbumes} />
|
<SectionHeading title={SEARCH_LABELS.albums} count={counts.albums} />
|
||||||
<ul class="flex flex-col gap-1">
|
<ul class="flex flex-col gap-1">
|
||||||
{#each capped(sfilter, albums) as album (album.id)}
|
{#each capped(sfilter, albums) as album (album.id)}
|
||||||
<li>
|
<li>
|
||||||
@@ -380,7 +339,7 @@
|
|||||||
href="/albums/{album.id}"
|
href="/albums/{album.id}"
|
||||||
size="lg"
|
size="lg"
|
||||||
trackIds={album.coverTrackIds}
|
trackIds={album.coverTrackIds}
|
||||||
oncontextmenu={(e) => openAlbumMenu(e, album.id)}
|
oncontextmenu={(e) => albumMenu.open(e, album.id)}
|
||||||
>
|
>
|
||||||
{#snippet trailing()}
|
{#snippet trailing()}
|
||||||
<span class="hidden shrink-0 text-xs text-fg-2 tabular-nums sm:block">
|
<span class="hidden shrink-0 text-xs text-fg-2 tabular-nums sm:block">
|
||||||
@@ -394,9 +353,9 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if hasUsers && showGroup(sfilter, 'Usuarios')}
|
{#if hasUsers && showGroup(sfilter, 'users')}
|
||||||
<div>
|
<div>
|
||||||
<SectionHeading title="Usuarios" count={counts.Usuarios} />
|
<SectionHeading title={SEARCH_LABELS.users} count={counts.users} />
|
||||||
<ul class="flex flex-col gap-1">
|
<ul class="flex flex-col gap-1">
|
||||||
{#each capped(sfilter, users) as u (u.id)}
|
{#each capped(sfilter, users) as u (u.id)}
|
||||||
<li>
|
<li>
|
||||||
@@ -407,9 +366,9 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if hasPlaylists && showGroup(sfilter, 'Playlists')}
|
{#if hasPlaylists && showGroup(sfilter, 'playlists')}
|
||||||
<div>
|
<div>
|
||||||
<SectionHeading title="Playlists" count={counts.Playlists} />
|
<SectionHeading title={SEARCH_LABELS.playlists} count={counts.playlists} />
|
||||||
<ul class="flex flex-col gap-1">
|
<ul class="flex flex-col gap-1">
|
||||||
{#each capped(sfilter, playlistMatches) as playlist (playlist.id)}
|
{#each capped(sfilter, playlistMatches) as playlist (playlist.id)}
|
||||||
<li>
|
<li>
|
||||||
@@ -418,9 +377,7 @@
|
|||||||
subtitle={playlist.description}
|
subtitle={playlist.description}
|
||||||
href="/playlists/{playlist.id}"
|
href="/playlists/{playlist.id}"
|
||||||
size="lg"
|
size="lg"
|
||||||
coverSrc="/api/playlists/{playlist.id}/cover?size=medium&v={encodeURIComponent(
|
coverSrc={playlistCover(playlist, 'medium')}
|
||||||
playlist.updatedAt
|
|
||||||
)}"
|
|
||||||
trackIds={playlist.coverTrackIds}
|
trackIds={playlist.coverTrackIds}
|
||||||
/>
|
/>
|
||||||
</li>
|
</li>
|
||||||
@@ -432,39 +389,5 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</Page>
|
</Page>
|
||||||
|
|
||||||
{#if trackMenu.state}
|
<TrackContextMenu menu={trackMenu} playlists={data.userPlaylists} />
|
||||||
<ContextMenu
|
<AlbumContextMenu menu={albumMenu} playlists={data.userPlaylists} />
|
||||||
x={trackMenu.state.x}
|
|
||||||
y={trackMenu.state.y}
|
|
||||||
openLeft={trackMenu.state.openLeft}
|
|
||||||
onClose={() => trackMenu.close()}
|
|
||||||
items={[
|
|
||||||
{ icon: ListPlus, label: 'Reproducir a continuación', onclick: () => trackMenu.playNext() }
|
|
||||||
]}
|
|
||||||
playlistAction={{
|
|
||||||
action: '?/addTrack',
|
|
||||||
fields: { trackId: trackMenu.state.track.id },
|
|
||||||
label: 'Añadir a una playlist'
|
|
||||||
}}
|
|
||||||
playlists={data.userPlaylists}
|
|
||||||
/>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if albumMenu}
|
|
||||||
<ContextMenu
|
|
||||||
x={albumMenu.x}
|
|
||||||
y={albumMenu.y}
|
|
||||||
openLeft={albumMenu.openLeft}
|
|
||||||
onClose={() => (albumMenu = null)}
|
|
||||||
items={[
|
|
||||||
{ icon: ListPlus, label: 'Reproducir a continuación', onclick: albumPlayNext },
|
|
||||||
{ icon: ListEnd, label: 'Añadir a la cola', onclick: albumAddToQueue }
|
|
||||||
]}
|
|
||||||
playlistAction={{
|
|
||||||
action: '?/addAlbumToPlaylist',
|
|
||||||
fields: { albumId: albumMenu.albumId },
|
|
||||||
label: 'Añadir álbum a una playlist'
|
|
||||||
}}
|
|
||||||
playlists={data.userPlaylists}
|
|
||||||
/>
|
|
||||||
{/if}
|
|
||||||
|
|||||||
@@ -36,7 +36,7 @@
|
|||||||
{#snippet actions()}
|
{#snippet actions()}
|
||||||
{#if items.length > 0}
|
{#if items.length > 0}
|
||||||
<Button href="/upload">
|
<Button href="/upload">
|
||||||
<Upload class="size-4" />
|
<Upload class="size-icon-sm" />
|
||||||
Subir canción
|
Subir canción
|
||||||
</Button>
|
</Button>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -62,7 +62,7 @@
|
|||||||
>
|
>
|
||||||
{#snippet actions()}
|
{#snippet actions()}
|
||||||
<Button href="/upload">
|
<Button href="/upload">
|
||||||
<Upload class="size-4" />
|
<Upload class="size-icon-sm" />
|
||||||
Sube la primera
|
Sube la primera
|
||||||
</Button>
|
</Button>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Heart from '@lucide/svelte/icons/heart';
|
import Heart from '@lucide/svelte/icons/heart';
|
||||||
import X from '@lucide/svelte/icons/x';
|
import X from '@lucide/svelte/icons/x';
|
||||||
import { player, isQueueCurrent, playAllOrToggle } from '$lib/player/player.svelte';
|
import { player } from '$lib/player/player.svelte';
|
||||||
|
import PlayAllButton from '$lib/components/ui/media/PlayAllButton.svelte';
|
||||||
import { liked } from '$lib/player/liked.svelte';
|
import { liked } from '$lib/player/liked.svelte';
|
||||||
import { plural } from '$lib/utils/format';
|
import { plural } from '$lib/utils/format';
|
||||||
import Page from '$lib/components/ui/layout/Page.svelte';
|
import Page from '$lib/components/ui/layout/Page.svelte';
|
||||||
@@ -12,15 +13,10 @@
|
|||||||
|
|
||||||
const tracks = $derived(liked.list);
|
const tracks = $derived(liked.list);
|
||||||
const listTracks = $derived(tracks.map((track) => ({ ...track, date: track.likedAt })));
|
const listTracks = $derived(tracks.map((track) => ({ ...track, date: track.likedAt })));
|
||||||
const isCurrentQueue = $derived(isQueueCurrent(tracks));
|
|
||||||
|
|
||||||
function playFrom(index: number) {
|
function playFrom(index: number) {
|
||||||
player.playOrToggle(tracks, index);
|
player.playOrToggle(tracks, index);
|
||||||
}
|
}
|
||||||
|
|
||||||
function playAll() {
|
|
||||||
playAllOrToggle(tracks);
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
@@ -43,9 +39,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
{#snippet actions()}
|
{#snippet actions()}
|
||||||
<Button size="sm" onclick={playAll} disabled={tracks.length === 0}>
|
<PlayAllButton items={tracks} />
|
||||||
{isCurrentQueue && player.playing ? 'Pausar' : 'Reproducir'}
|
|
||||||
</Button>
|
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Shuffle from '@lucide/svelte/icons/shuffle';
|
import Shuffle from '@lucide/svelte/icons/shuffle';
|
||||||
import { player, isQueueCurrent, playAllOrToggle, playShuffled } from '$lib/player/player.svelte';
|
import { player } from '$lib/player/player.svelte';
|
||||||
|
import { playShuffled } from '$lib/player/actions';
|
||||||
import { fmtTime, plural } from '$lib/utils/format';
|
import { fmtTime, plural } from '$lib/utils/format';
|
||||||
import Page from '$lib/components/ui/layout/Page.svelte';
|
import Page from '$lib/components/ui/layout/Page.svelte';
|
||||||
import BackLink from '$lib/components/ui/primitives/BackLink.svelte';
|
import BackLink from '$lib/components/ui/primitives/BackLink.svelte';
|
||||||
@@ -9,9 +10,10 @@
|
|||||||
import Artwork from '$lib/components/ui/media/Artwork.svelte';
|
import Artwork from '$lib/components/ui/media/Artwork.svelte';
|
||||||
import PageHeader from '$lib/components/ui/layout/PageHeader.svelte';
|
import PageHeader from '$lib/components/ui/layout/PageHeader.svelte';
|
||||||
import TrackList from '$lib/components/ui/media/TrackList.svelte';
|
import TrackList from '$lib/components/ui/media/TrackList.svelte';
|
||||||
import ContextMenu from '$lib/components/ui/overlay/ContextMenu.svelte';
|
import TrackContextMenu from '$lib/components/ui/overlay/TrackContextMenu.svelte';
|
||||||
import ListPlus from '@lucide/svelte/icons/list-plus';
|
import { createMenu } from '$lib/state/menu.svelte';
|
||||||
import { createTrackMenu } from '$lib/state/menus.svelte';
|
import type { Track } from '$lib/types';
|
||||||
|
import PlayAllButton from '$lib/components/ui/media/PlayAllButton.svelte';
|
||||||
|
|
||||||
let { data, form } = $props();
|
let { data, form } = $props();
|
||||||
|
|
||||||
@@ -19,9 +21,8 @@
|
|||||||
const tracks = $derived(mix.tracks);
|
const tracks = $derived(mix.tracks);
|
||||||
|
|
||||||
const totalSeconds = $derived(tracks.reduce((total, track) => total + track.duration, 0));
|
const totalSeconds = $derived(tracks.reduce((total, track) => total + track.duration, 0));
|
||||||
const isCurrentQueue = $derived(isQueueCurrent(tracks));
|
|
||||||
|
|
||||||
const trackMenu = createTrackMenu();
|
const trackMenu = createMenu<Track>();
|
||||||
|
|
||||||
function playFrom(index: number) {
|
function playFrom(index: number) {
|
||||||
player.playOrToggle(tracks, index);
|
player.playOrToggle(tracks, index);
|
||||||
@@ -51,20 +52,14 @@
|
|||||||
/>
|
/>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
{#snippet actions()}
|
{#snippet actions()}
|
||||||
<Button size="sm" onclick={() => playAllOrToggle(tracks)} disabled={tracks.length === 0}>
|
<PlayAllButton items={tracks} />
|
||||||
{#if isCurrentQueue && player.playing}
|
|
||||||
Pausar
|
|
||||||
{:else}
|
|
||||||
Reproducir
|
|
||||||
{/if}
|
|
||||||
</Button>
|
|
||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
size="sm"
|
size="sm"
|
||||||
onclick={() => playShuffled(tracks)}
|
onclick={() => playShuffled(tracks)}
|
||||||
disabled={tracks.length === 0}
|
disabled={tracks.length === 0}
|
||||||
>
|
>
|
||||||
<Shuffle class="size-4" />
|
<Shuffle class="size-icon-sm" />
|
||||||
Aleatorio
|
Aleatorio
|
||||||
</Button>
|
</Button>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
@@ -78,24 +73,8 @@
|
|||||||
{tracks}
|
{tracks}
|
||||||
columns={['plays']}
|
columns={['plays']}
|
||||||
onPlay={playFrom}
|
onPlay={playFrom}
|
||||||
oncontextmenu={(e, _track, i) => trackMenu.open(e, tracks[i])}
|
oncontextmenu={(e, track) => trackMenu.open(e, track)}
|
||||||
/>
|
/>
|
||||||
</Page>
|
</Page>
|
||||||
|
|
||||||
{#if trackMenu.state}
|
<TrackContextMenu menu={trackMenu} playlists={data.userPlaylists} />
|
||||||
<ContextMenu
|
|
||||||
x={trackMenu.state.x}
|
|
||||||
y={trackMenu.state.y}
|
|
||||||
openLeft={trackMenu.state.openLeft}
|
|
||||||
onClose={() => trackMenu.close()}
|
|
||||||
items={[
|
|
||||||
{ icon: ListPlus, label: 'Reproducir a continuación', onclick: () => trackMenu.playNext() }
|
|
||||||
]}
|
|
||||||
playlistAction={{
|
|
||||||
action: '?/addTrack',
|
|
||||||
fields: { trackId: trackMenu.state.track.id },
|
|
||||||
label: 'Añadir a una playlist'
|
|
||||||
}}
|
|
||||||
playlists={data.userPlaylists}
|
|
||||||
/>
|
|
||||||
{/if}
|
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import type { PageServerLoad, Actions } from './$types';
|
import type { PageServerLoad, Actions } from './$types';
|
||||||
import { fail, redirect } from '@sveltejs/kit';
|
import { fail, redirect } from '@sveltejs/kit';
|
||||||
import { apiFor, authedAction, requireUser, unwrapOrError } from '$lib/server/api';
|
import { apiFor, authedAction, requireUser, unwrapOrError } from '$lib/server/api';
|
||||||
import { PLAYLISTS_PAGE_SIZE, PLAYLIST_SUMMARY_TRACKS_PAGE_SIZE } from '$lib/config';
|
import { PLAYLISTS_PAGE_SIZE } from '$lib/config';
|
||||||
import { toPlaylistSummary, toTrack } from '$lib/server/mappers';
|
import { toPlaylist } from '$lib/server/mappers';
|
||||||
import { parsePlaylistForm } from '$lib/server/forms/playlistForm';
|
import { parsePlaylistForm } from '$lib/server/forms/playlistForm';
|
||||||
import { uploadOptionalCover } from '$lib/server/upload';
|
import { uploadOptionalCover } from '$lib/server/upload';
|
||||||
|
|
||||||
@@ -13,32 +13,17 @@ export const load: PageServerLoad = async ({ locals, url, fetch }) => {
|
|||||||
params: { path: { userId: user.sub }, query: { pageNumber: 1, pageSize: PLAYLISTS_PAGE_SIZE } }
|
params: { path: { userId: user.sub }, query: { pageNumber: 1, pageSize: PLAYLISTS_PAGE_SIZE } }
|
||||||
});
|
});
|
||||||
|
|
||||||
const data = unwrapOrError(result, 'No se pudieron cargar tus playlists.');
|
const playlists = unwrapOrError(result, 'No se pudieron cargar tus playlists.').items.map(
|
||||||
|
toPlaylist
|
||||||
const tracksByPlaylist = await Promise.all(
|
|
||||||
data.items.map((playlist) =>
|
|
||||||
api
|
|
||||||
.GET('/playlists/{playlistId}/tracks', {
|
|
||||||
params: {
|
|
||||||
path: { playlistId: playlist.id },
|
|
||||||
query: { pageNumber: 1, pageSize: PLAYLIST_SUMMARY_TRACKS_PAGE_SIZE }
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.then((res) => (res.data?.items ?? []).map(toTrack))
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
const items = data.items.map((playlist, i) =>
|
|
||||||
toPlaylistSummary(playlist, tracksByPlaylist[i].length)
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const totals = {
|
const totals = {
|
||||||
playlistCount: items.length,
|
playlistCount: playlists.length,
|
||||||
trackCount: items.reduce((sum, playlist) => sum + playlist.trackCount, 0),
|
trackCount: playlists.reduce((sum, playlist) => sum + playlist.trackCount, 0),
|
||||||
durationSeconds: tracksByPlaylist.flat().reduce((sum, track) => sum + track.duration, 0)
|
durationSeconds: playlists.reduce((sum, playlist) => sum + playlist.durationSeconds, 0)
|
||||||
};
|
};
|
||||||
|
|
||||||
return { playlists: items, totals };
|
return { playlists, totals };
|
||||||
};
|
};
|
||||||
|
|
||||||
export const actions: Actions = {
|
export const actions: Actions = {
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { playlistCover } from '$lib/utils/hrefs';
|
||||||
import ListMusic from '@lucide/svelte/icons/list-music';
|
import ListMusic from '@lucide/svelte/icons/list-music';
|
||||||
import Page from '$lib/components/ui/layout/Page.svelte';
|
import Page from '$lib/components/ui/layout/Page.svelte';
|
||||||
import PageHeader from '$lib/components/ui/layout/PageHeader.svelte';
|
import PageHeader from '$lib/components/ui/layout/PageHeader.svelte';
|
||||||
import MediaCard from '$lib/components/ui/media/MediaCard.svelte';
|
import MediaCard from '$lib/components/ui/media/MediaCard.svelte';
|
||||||
import EmptyState from '$lib/components/ui/primitives/EmptyState.svelte';
|
import EmptyState from '$lib/components/ui/primitives/EmptyState.svelte';
|
||||||
import Button from '$lib/components/ui/primitives/Button.svelte';
|
import Button from '$lib/components/ui/primitives/Button.svelte';
|
||||||
import { createPlaylistModal } from '$lib/state/playlists.svelte';
|
import { createPlaylistModal } from '$lib/state/panels.svelte';
|
||||||
import { fmtDurationLong, plural, playlistMeta } from '$lib/utils/format';
|
import { fmtDurationLong, plural, playlistMeta } from '$lib/utils/format';
|
||||||
|
|
||||||
let { data } = $props();
|
let { data } = $props();
|
||||||
@@ -18,18 +19,18 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<title>Mis listas</title>
|
<title>Mis playlists</title>
|
||||||
<meta name="description" content="Mis listas en Musify." />
|
<meta name="description" content="Mis playlists en Musify." />
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<Page>
|
<Page>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Mis listas"
|
title="Mis playlists"
|
||||||
description={items.length > 0 ? summary : 'Crea y organiza tus colecciones.'}
|
description={items.length > 0 ? summary : 'Crea y organiza tus colecciones.'}
|
||||||
>
|
>
|
||||||
{#snippet actions()}
|
{#snippet actions()}
|
||||||
{#if items.length > 0}
|
{#if items.length > 0}
|
||||||
<Button onclick={() => createPlaylistModal.show()}>Crear nueva lista</Button>
|
<Button onclick={() => createPlaylistModal.show()}>Crear nueva playlist</Button>
|
||||||
{/if}
|
{/if}
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
@@ -42,9 +43,7 @@
|
|||||||
title={playlist.name}
|
title={playlist.name}
|
||||||
subtitle={playlistMeta(playlist.trackCount)}
|
subtitle={playlistMeta(playlist.trackCount)}
|
||||||
trackIds={playlist.coverTrackIds}
|
trackIds={playlist.coverTrackIds}
|
||||||
src="/api/playlists/{playlist.id}/cover?size=large&v={encodeURIComponent(
|
src={playlistCover(playlist, 'large')}
|
||||||
playlist.updatedAt
|
|
||||||
)}"
|
|
||||||
index={i}
|
index={i}
|
||||||
/>
|
/>
|
||||||
{/each}
|
{/each}
|
||||||
@@ -52,11 +51,11 @@
|
|||||||
{:else}
|
{:else}
|
||||||
<EmptyState
|
<EmptyState
|
||||||
icon={ListMusic}
|
icon={ListMusic}
|
||||||
title="Todavía no tienes listas"
|
title="Todavía no tienes playlists"
|
||||||
description="Crea la primera y añádele canciones de tu biblioteca."
|
description="Crea la primera y añádele canciones de tu biblioteca."
|
||||||
>
|
>
|
||||||
{#snippet actions()}
|
{#snippet actions()}
|
||||||
<Button onclick={() => createPlaylistModal.show()}>Crear nueva lista</Button>
|
<Button onclick={() => createPlaylistModal.show()}>Crear nueva playlist</Button>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</EmptyState>
|
</EmptyState>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { playlistCover } from '$lib/utils/hrefs';
|
||||||
import ListMusic from '@lucide/svelte/icons/list-music';
|
import ListMusic from '@lucide/svelte/icons/list-music';
|
||||||
import X from '@lucide/svelte/icons/x';
|
import X from '@lucide/svelte/icons/x';
|
||||||
import { player, isQueueCurrent, playAllOrToggle } from '$lib/player/player.svelte';
|
import { player } from '$lib/player/player.svelte';
|
||||||
|
import PlayAllButton from '$lib/components/ui/media/PlayAllButton.svelte';
|
||||||
import Page from '$lib/components/ui/layout/Page.svelte';
|
import Page from '$lib/components/ui/layout/Page.svelte';
|
||||||
import PageHeader from '$lib/components/ui/layout/PageHeader.svelte';
|
import PageHeader from '$lib/components/ui/layout/PageHeader.svelte';
|
||||||
import Modal from '$lib/components/ui/overlay/Modal.svelte';
|
import Modal from '$lib/components/ui/overlay/Modal.svelte';
|
||||||
@@ -23,12 +25,6 @@
|
|||||||
let editing = $state(false);
|
let editing = $state(false);
|
||||||
let confirmingDelete = $state(false);
|
let confirmingDelete = $state(false);
|
||||||
|
|
||||||
const isCurrentQueue = $derived(isQueueCurrent(tracks));
|
|
||||||
|
|
||||||
function playAll() {
|
|
||||||
playAllOrToggle(tracks);
|
|
||||||
}
|
|
||||||
|
|
||||||
function playFrom(index: number) {
|
function playFrom(index: number) {
|
||||||
player.playOrToggle(tracks, index);
|
player.playOrToggle(tracks, index);
|
||||||
}
|
}
|
||||||
@@ -56,9 +52,7 @@
|
|||||||
>
|
>
|
||||||
{#snippet cover()}
|
{#snippet cover()}
|
||||||
<Artwork
|
<Artwork
|
||||||
src="/api/playlists/{playlist.id}/cover?size=large&v={encodeURIComponent(
|
src={playlistCover(playlist, 'large')}
|
||||||
playlist.updatedAt
|
|
||||||
)}"
|
|
||||||
trackIds={tracks.map((t) => t.id)}
|
trackIds={tracks.map((t) => t.id)}
|
||||||
size="hero"
|
size="hero"
|
||||||
alt={playlist.name}
|
alt={playlist.name}
|
||||||
@@ -66,13 +60,7 @@
|
|||||||
/>
|
/>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
{#snippet actions()}
|
{#snippet actions()}
|
||||||
<Button size="sm" onclick={playAll} disabled={tracks.length === 0}>
|
<PlayAllButton items={tracks} />
|
||||||
{#if isCurrentQueue && player.playing}
|
|
||||||
Pausar
|
|
||||||
{:else}
|
|
||||||
Reproducir
|
|
||||||
{/if}
|
|
||||||
</Button>
|
|
||||||
{#if isOwner}
|
{#if isOwner}
|
||||||
<Button size="sm" variant="secondary" onclick={() => (editing = true)}>Editar</Button>
|
<Button size="sm" variant="secondary" onclick={() => (editing = true)}>Editar</Button>
|
||||||
<Button size="sm" variant="secondary" onclick={() => (confirmingDelete = true)}>
|
<Button size="sm" variant="secondary" onclick={() => (confirmingDelete = true)}>
|
||||||
@@ -110,9 +98,7 @@
|
|||||||
initialName={playlist.name}
|
initialName={playlist.name}
|
||||||
initialDescription={playlist.description ?? ''}
|
initialDescription={playlist.description ?? ''}
|
||||||
initialVisibility={playlist.visibility === 'Public' ? 'public' : 'private'}
|
initialVisibility={playlist.visibility === 'Public' ? 'public' : 'private'}
|
||||||
coverFallbackUrl="/api/playlists/{playlist.id}/cover?size=medium&v={encodeURIComponent(
|
coverFallbackUrl={playlistCover(playlist, 'medium')}
|
||||||
playlist.updatedAt
|
|
||||||
)}"
|
|
||||||
formMessage={form?.message}
|
formMessage={form?.message}
|
||||||
submitLabel="Guardar"
|
submitLabel="Guardar"
|
||||||
submittingLabel="Guardando…"
|
submittingLabel="Guardando…"
|
||||||
|
|||||||
@@ -41,8 +41,7 @@
|
|||||||
|
|
||||||
const steps = [
|
const steps = [
|
||||||
{ key: 'upload', label: 'Subir', hint: 'Portada y audio.' },
|
{ key: 'upload', label: 'Subir', hint: 'Portada y audio.' },
|
||||||
{ key: 'process', label: 'Procesar', hint: 'Transcodificado para streaming.' },
|
{ key: 'done', label: 'Listo', hint: 'Se procesa y aparece en tu biblioteca.' }
|
||||||
{ key: 'publish', label: 'Publicar', hint: 'Disponible en el catálogo.' }
|
|
||||||
];
|
];
|
||||||
|
|
||||||
const options = $derived(genreOptions(data.genres));
|
const options = $derived(genreOptions(data.genres));
|
||||||
@@ -61,9 +60,9 @@
|
|||||||
);
|
);
|
||||||
|
|
||||||
const stepStates = $derived.by<StepState[]>(() => {
|
const stepStates = $derived.by<StepState[]>(() => {
|
||||||
if (status === 'done') return ['done', 'done', 'done'];
|
if (status === 'done') return ['done', 'done'];
|
||||||
if (status === 'uploading') return ['active', 'idle', 'idle'];
|
if (status === 'uploading') return ['active', 'idle'];
|
||||||
return ['idle', 'idle', 'idle'];
|
return ['idle', 'idle'];
|
||||||
});
|
});
|
||||||
|
|
||||||
function stepClass(s: StepState) {
|
function stepClass(s: StepState) {
|
||||||
@@ -122,7 +121,7 @@
|
|||||||
description="Añade una canción con su portada y título. Nosotros la procesamos para streaming."
|
description="Añade una canción con su portada y título. Nosotros la procesamos para streaming."
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ol class="grid gap-3 sm:grid-cols-3 sm:gap-4">
|
<ol class="grid gap-3 sm:grid-cols-2 sm:gap-4">
|
||||||
{#each steps as step, i (step.key)}
|
{#each steps as step, i (step.key)}
|
||||||
<li>
|
<li>
|
||||||
<Surface padding="sm" class={stepClass(stepStates[i])}>
|
<Surface padding="sm" class={stepClass(stepStates[i])}>
|
||||||
@@ -134,7 +133,7 @@
|
|||||||
: 'bg-accent-btn text-accent-soft'}"
|
: 'bg-accent-btn text-accent-soft'}"
|
||||||
>
|
>
|
||||||
{#if stepStates[i] === 'done'}
|
{#if stepStates[i] === 'done'}
|
||||||
<Check class="size-3.5" />
|
<Check class="size-icon-xs" />
|
||||||
{:else}
|
{:else}
|
||||||
{i + 1}
|
{i + 1}
|
||||||
{/if}
|
{/if}
|
||||||
@@ -150,7 +149,7 @@
|
|||||||
{#if status === 'done'}
|
{#if status === 'done'}
|
||||||
<Surface padding="lg" class="mt-10 bg-accent-tint text-center">
|
<Surface padding="lg" class="mt-10 bg-accent-tint text-center">
|
||||||
<span class="mx-auto grid size-14 place-items-center rounded-full bg-accent-soft text-ink">
|
<span class="mx-auto grid size-14 place-items-center rounded-full bg-accent-soft text-ink">
|
||||||
<Check class="size-7" />
|
<Check class="size-icon-2xl" />
|
||||||
</span>
|
</span>
|
||||||
<h2 class="mt-6 font-display text-2xl font-semibold tracking-display text-fg">¡Subida!</h2>
|
<h2 class="mt-6 font-display text-2xl font-semibold tracking-display text-fg">¡Subida!</h2>
|
||||||
<p class="mt-3 text-sm text-fg-2">
|
<p class="mt-3 text-sm text-fg-2">
|
||||||
@@ -211,9 +210,9 @@
|
|||||||
aria-label="Seleccionar archivo de audio"
|
aria-label="Seleccionar archivo de audio"
|
||||||
/>
|
/>
|
||||||
{#if audioName === ''}
|
{#if audioName === ''}
|
||||||
<Headphones class="size-9 text-fg-2 sm:size-10" />
|
<Headphones class="size-icon-xl text-fg-2 sm:size-icon-3xl" />
|
||||||
{:else}
|
{:else}
|
||||||
<Music class="size-9 text-accent-soft sm:size-10" />
|
<Music class="size-icon-xl text-accent-soft sm:size-icon-3xl" />
|
||||||
{/if}
|
{/if}
|
||||||
{#if audioName === ''}
|
{#if audioName === ''}
|
||||||
<p class="mt-4 text-sm font-medium text-fg">
|
<p class="mt-4 text-sm font-medium text-fg">
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { PageServerLoad, Actions } from './$types';
|
import type { PageServerLoad, Actions } from './$types';
|
||||||
import { apiFor, optionalUser, unwrapOrError } from '$lib/server/api';
|
import { apiFor, optionalUser, unwrapOrError } from '$lib/server/api';
|
||||||
import { toPlaylistSummary } from '$lib/server/mappers';
|
import { toPlaylist } from '$lib/server/mappers';
|
||||||
import { COUNT_ONLY_PAGE_SIZE, PROFILE_PLAYLISTS_PAGE_SIZE } from '$lib/config';
|
import { PROFILE_PLAYLISTS_PAGE_SIZE } from '$lib/config';
|
||||||
import { followUserAction, unfollowUserAction } from '$lib/server/followActions';
|
import { followUserAction, unfollowUserAction } from '$lib/server/followActions';
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ params, locals, url, fetch, parent }) => {
|
export const load: PageServerLoad = async ({ params, locals, url, fetch, parent }) => {
|
||||||
@@ -20,24 +20,7 @@ export const load: PageServerLoad = async ({ params, locals, url, fetch, parent
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
const profile = unwrapOrError(profileRes, 'Usuario no encontrado.', 404);
|
const profile = unwrapOrError(profileRes, 'Usuario no encontrado.', 404);
|
||||||
const playlistItems = playlistsRes.data?.items ?? [];
|
const playlists = (playlistsRes.data?.items ?? []).map(toPlaylist);
|
||||||
|
|
||||||
const tracksByPlaylist = await Promise.all(
|
|
||||||
playlistItems.map((playlist) =>
|
|
||||||
api
|
|
||||||
.GET('/playlists/{playlistId}/tracks', {
|
|
||||||
params: {
|
|
||||||
path: { playlistId: playlist.id },
|
|
||||||
query: { pageNumber: 1, pageSize: COUNT_ONLY_PAGE_SIZE }
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.then((res) => Number(res.data?.totalItemCount ?? 0))
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
const playlists = playlistItems.map((playlist, i) =>
|
|
||||||
toPlaylistSummary(playlist, tracksByPlaylist[i])
|
|
||||||
);
|
|
||||||
|
|
||||||
const isOwnProfile = viewer !== null && viewer.sub === params.id;
|
const isOwnProfile = viewer !== null && viewer.sub === params.id;
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { playlistCover } from '$lib/utils/hrefs';
|
||||||
import ListMusic from '@lucide/svelte/icons/list-music';
|
import ListMusic from '@lucide/svelte/icons/list-music';
|
||||||
import Page from '$lib/components/ui/layout/Page.svelte';
|
import Page from '$lib/components/ui/layout/Page.svelte';
|
||||||
import MediaCard from '$lib/components/ui/media/MediaCard.svelte';
|
import MediaCard from '$lib/components/ui/media/MediaCard.svelte';
|
||||||
@@ -62,9 +63,7 @@
|
|||||||
title={playlist.name}
|
title={playlist.name}
|
||||||
subtitle={playlistMeta(playlist.trackCount)}
|
subtitle={playlistMeta(playlist.trackCount)}
|
||||||
trackIds={playlist.coverTrackIds}
|
trackIds={playlist.coverTrackIds}
|
||||||
src="/api/playlists/{playlist.id}/cover?size=large&v={encodeURIComponent(
|
src={playlistCover(playlist, 'large')}
|
||||||
playlist.updatedAt
|
|
||||||
)}"
|
|
||||||
index={i}
|
index={i}
|
||||||
/>
|
/>
|
||||||
{/each}
|
{/each}
|
||||||
|
|||||||
@@ -6,44 +6,29 @@
|
|||||||
import EmptyState from '$lib/components/ui/primitives/EmptyState.svelte';
|
import EmptyState from '$lib/components/ui/primitives/EmptyState.svelte';
|
||||||
import InfiniteScroll from '$lib/components/ui/primitives/InfiniteScroll.svelte';
|
import InfiniteScroll from '$lib/components/ui/primitives/InfiniteScroll.svelte';
|
||||||
import UserRow from '$lib/components/ui/media/UserRow.svelte';
|
import UserRow from '$lib/components/ui/media/UserRow.svelte';
|
||||||
import { appendUnique } from '$lib/data/collections';
|
import { createPagedList } from '$lib/state/pagedList.svelte';
|
||||||
import { plural } from '$lib/utils/format';
|
import { plural } from '$lib/utils/format';
|
||||||
|
|
||||||
let { data } = $props();
|
let { data } = $props();
|
||||||
|
|
||||||
type ListUser = NonNullable<typeof data.users>['items'][number];
|
type ListUser = NonNullable<typeof data.users>['items'][number];
|
||||||
|
|
||||||
let items = $state<ListUser[]>([]);
|
const list = createPagedList<ListUser>(
|
||||||
let page = $state(1);
|
async (pageNumber) => {
|
||||||
let hasMore = $state(false);
|
const res = await fetch(
|
||||||
let loadingMore = $state(false);
|
`/api/users/${data.profile.id}/${data.list}?pageNumber=${pageNumber}`
|
||||||
|
);
|
||||||
|
if (!res.ok) throw new Error(String(res.status));
|
||||||
|
return (await res.json()) as NonNullable<typeof data.users>;
|
||||||
|
},
|
||||||
|
(user) => user.id
|
||||||
|
);
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => list.reset(data.users));
|
||||||
items = data.users?.items ?? [];
|
|
||||||
page = data.users?.pageNumber ?? 1;
|
|
||||||
hasMore = data.users?.hasNextPage ?? false;
|
|
||||||
});
|
|
||||||
|
|
||||||
const isFollowers = $derived(data.list === 'followers');
|
const isFollowers = $derived(data.list === 'followers');
|
||||||
const title = $derived(isFollowers ? 'Seguidores' : 'Siguiendo');
|
const title = $derived(isFollowers ? 'Seguidores' : 'Siguiendo');
|
||||||
const total = $derived(data.users?.totalItemCount ?? 0);
|
const total = $derived(data.users?.totalItemCount ?? 0);
|
||||||
|
|
||||||
async function loadMore() {
|
|
||||||
if (loadingMore) return;
|
|
||||||
loadingMore = true;
|
|
||||||
try {
|
|
||||||
const res = await fetch(`/api/users/${data.profile.id}/${data.list}?pageNumber=${page + 1}`);
|
|
||||||
if (!res.ok) throw new Error(String(res.status));
|
|
||||||
const next = (await res.json()) as NonNullable<typeof data.users>;
|
|
||||||
items = appendUnique(items, next.items, (u) => u.id);
|
|
||||||
page = next.pageNumber;
|
|
||||||
hasMore = next.hasNextPage;
|
|
||||||
} catch {
|
|
||||||
hasMore = false;
|
|
||||||
} finally {
|
|
||||||
loadingMore = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
@@ -67,7 +52,7 @@
|
|||||||
title="Seguidores privados"
|
title="Seguidores privados"
|
||||||
description="Solo puedes ver los seguidores de {data.profile.name} si os seguís mutuamente."
|
description="Solo puedes ver los seguidores de {data.profile.name} si os seguís mutuamente."
|
||||||
/>
|
/>
|
||||||
{:else if items.length === 0}
|
{:else if list.items.length === 0}
|
||||||
<EmptyState
|
<EmptyState
|
||||||
icon={Users}
|
icon={Users}
|
||||||
title={isFollowers ? 'Sin seguidores' : 'No sigue a nadie'}
|
title={isFollowers ? 'Sin seguidores' : 'No sigue a nadie'}
|
||||||
@@ -77,12 +62,17 @@
|
|||||||
/>
|
/>
|
||||||
{:else}
|
{:else}
|
||||||
<ul class="flex flex-col gap-1">
|
<ul class="flex flex-col gap-1">
|
||||||
{#each items as user (user.id)}
|
{#each list.items as user (user.id)}
|
||||||
<li>
|
<li>
|
||||||
<UserRow {user} />
|
<UserRow {user} />
|
||||||
</li>
|
</li>
|
||||||
{/each}
|
{/each}
|
||||||
</ul>
|
</ul>
|
||||||
<InfiniteScroll onLoadMore={loadMore} {hasMore} loading={loadingMore} />
|
<InfiniteScroll
|
||||||
|
onLoadMore={() => list.loadMore()}
|
||||||
|
hasMore={list.hasMore}
|
||||||
|
loading={list.loading}
|
||||||
|
error={list.error}
|
||||||
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
</Page>
|
</Page>
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import type { Snippet } from 'svelte';
|
||||||
|
import type { LayoutData } from './$types';
|
||||||
import '@fontsource-variable/sora/index.css';
|
import '@fontsource-variable/sora/index.css';
|
||||||
import '@fontsource-variable/plus-jakarta-sans/index.css';
|
import '@fontsource-variable/plus-jakarta-sans/index.css';
|
||||||
import '$lib/theme/theme.css';
|
import '$lib/theme/theme.css';
|
||||||
@@ -13,16 +15,23 @@
|
|||||||
import DialogHost from '$lib/components/ui/overlay/DialogHost.svelte';
|
import DialogHost from '$lib/components/ui/overlay/DialogHost.svelte';
|
||||||
import PlaylistForm from '$lib/components/ui/forms/PlaylistForm.svelte';
|
import PlaylistForm from '$lib/components/ui/forms/PlaylistForm.svelte';
|
||||||
import { player } from '$lib/player/player.svelte';
|
import { player } from '$lib/player/player.svelte';
|
||||||
import { queuePanel } from '$lib/player/queuePanel.svelte';
|
import { queuePanel } from '$lib/state/panels.svelte';
|
||||||
import { liked } from '$lib/player/liked.svelte';
|
import { liked } from '$lib/player/liked.svelte';
|
||||||
import { createPlaylistModal } from '$lib/state/playlists.svelte';
|
import { createPlaylistModal } from '$lib/state/panels.svelte';
|
||||||
import { ACCENT_HUE } from '$lib/theme/color';
|
import { ACCENT_HUE } from '$lib/theme/color';
|
||||||
import { buildThemeTokens, applyThemeTokens, tokensToCss } from '$lib/theme/tokens';
|
import { buildThemeTokens, tokensToCss } from '$lib/theme/tokens';
|
||||||
|
import { accent, animateThemeHue } from '$lib/theme/accent.svelte';
|
||||||
import { themeMode } from '$lib/theme/mode.svelte';
|
import { themeMode } from '$lib/theme/mode.svelte';
|
||||||
import { trackNavigation, restoreScroll } from '$lib/state/navigation.svelte';
|
import { trackNavigation } from '$lib/state/history.svelte';
|
||||||
|
import { restoreScroll } from '$lib/state/scroll';
|
||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
|
|
||||||
let { children, data } = $props();
|
interface Props {
|
||||||
|
children: Snippet;
|
||||||
|
data: LayoutData;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { children, data }: Props = $props();
|
||||||
|
|
||||||
let scroller = $state<HTMLElement>();
|
let scroller = $state<HTMLElement>();
|
||||||
|
|
||||||
@@ -60,35 +69,7 @@
|
|||||||
player.toggle();
|
player.toggle();
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseHue(value: string): number | null {
|
$effect(() => animateThemeHue(accent.hue, themeMode.current));
|
||||||
const m = /oklch\(\s*[\d.]+%?\s+[\d.]+\s+([\d.]+)/.exec(value);
|
|
||||||
return m ? parseFloat(m[1]) : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
let displayedHue = ACCENT_HUE;
|
|
||||||
let rafId = 0;
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
const targetHue = parseHue(player.accent) ?? ACCENT_HUE;
|
|
||||||
const mode = themeMode.current;
|
|
||||||
cancelAnimationFrame(rafId);
|
|
||||||
const fromHue = displayedHue;
|
|
||||||
let dh = targetHue - fromHue;
|
|
||||||
if (dh > 180) dh -= 360;
|
|
||||||
else if (dh < -180) dh += 360;
|
|
||||||
const start = performance.now();
|
|
||||||
const duration = 2000 + Math.random() * 2000;
|
|
||||||
const tick = (now: number) => {
|
|
||||||
const t = Math.min(1, (now - start) / duration);
|
|
||||||
const eased = 1 - Math.pow(1 - t, 3);
|
|
||||||
const hue = (((fromHue + dh * eased) % 360) + 360) % 360;
|
|
||||||
displayedHue = hue;
|
|
||||||
applyThemeTokens(buildThemeTokens(hue, mode));
|
|
||||||
if (t < 1) rafId = requestAnimationFrame(tick);
|
|
||||||
};
|
|
||||||
rafId = requestAnimationFrame(tick);
|
|
||||||
return () => cancelAnimationFrame(rafId);
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:window onkeydown={onWindowKeydown} />
|
<svelte:window onkeydown={onWindowKeydown} />
|
||||||
|
|||||||
@@ -57,6 +57,8 @@
|
|||||||
--spacing-icon-md: 1.125rem;
|
--spacing-icon-md: 1.125rem;
|
||||||
--spacing-icon-lg: 1.25rem;
|
--spacing-icon-lg: 1.25rem;
|
||||||
--spacing-icon-xl: 2.25rem;
|
--spacing-icon-xl: 2.25rem;
|
||||||
|
--spacing-icon-2xl: 1.75rem;
|
||||||
|
--spacing-icon-3xl: 2.5rem;
|
||||||
--spacing-section: 2.875rem;
|
--spacing-section: 2.875rem;
|
||||||
|
|
||||||
--container-prose-sm: 26rem;
|
--container-prose-sm: 26rem;
|
||||||
@@ -82,6 +84,10 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@utility focus-ring {
|
||||||
|
@apply focus-visible:ring-2 focus-visible:ring-accent focus-visible:outline-none;
|
||||||
|
}
|
||||||
|
|
||||||
@utility track-grid {
|
@utility track-grid {
|
||||||
grid-template-columns: var(--mf-track-cols-mobile);
|
grid-template-columns: var(--mf-track-cols-mobile);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user