10 Commits
105 changed files with 2029 additions and 1341 deletions
@@ -21,6 +21,8 @@ namespace Musify.Application.PlayLists
.Select(p => new .Select(p => new
{ {
PlayList = p, PlayList = p,
TrackCount = p.PlayListTracks.Count,
DurationSeconds = p.PlayListTracks.Sum(plt => (double?)plt.Track.DurationSeconds) ?? 0,
CoverTrackIds = p.PlayListTracks CoverTrackIds = p.PlayListTracks
.OrderBy(plt => plt.Position) .OrderBy(plt => plt.Position)
.Take(PlayListApplicationResponse.CoverTrackCount) .Take(PlayListApplicationResponse.CoverTrackCount)
@@ -36,7 +38,7 @@ namespace Musify.Application.PlayLists
&& entry.PlayList.UserId != request.RequestingUserId) && entry.PlayList.UserId != request.RequestingUserId)
return Error.NotFound(); return Error.NotFound();
return PlayListApplicationResponse.FromEntity(entry.PlayList, entry.CoverTrackIds); return PlayListApplicationResponse.FromEntity(entry.PlayList, entry.TrackCount, entry.DurationSeconds, entry.CoverTrackIds);
} }
} }
} }
@@ -25,6 +25,8 @@ namespace Musify.Application.PlayLists
.Select(p => new .Select(p => new
{ {
PlayList = p, PlayList = p,
TrackCount = p.PlayListTracks.Count,
DurationSeconds = p.PlayListTracks.Sum(plt => (double?)plt.Track.DurationSeconds) ?? 0,
CoverTrackIds = p.PlayListTracks CoverTrackIds = p.PlayListTracks
.OrderBy(plt => plt.Position) .OrderBy(plt => plt.Position)
.Take(PlayListApplicationResponse.CoverTrackCount) .Take(PlayListApplicationResponse.CoverTrackCount)
@@ -34,7 +36,7 @@ namespace Musify.Application.PlayLists
.ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken); .ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken);
var pagedPlayLists = new StaticPagedList<PlayListApplicationResponse>( var pagedPlayLists = new StaticPagedList<PlayListApplicationResponse>(
pagedEntities.Select(entry => PlayListApplicationResponse.FromEntity(entry.PlayList, entry.CoverTrackIds)), pagedEntities.Select(entry => PlayListApplicationResponse.FromEntity(entry.PlayList, entry.TrackCount, entry.DurationSeconds, entry.CoverTrackIds)),
pagedEntities.PageNumber, pagedEntities.PageSize, pagedEntities.TotalItemCount); pagedEntities.PageNumber, pagedEntities.PageSize, pagedEntities.TotalItemCount);
return PaginatedResponse<PlayListApplicationResponse>.FromPagedList(pagedPlayLists); return PaginatedResponse<PlayListApplicationResponse>.FromPagedList(pagedPlayLists);
@@ -46,6 +46,8 @@ namespace Musify.Application.PlayLists
.Select(p => new .Select(p => new
{ {
PlayList = p, PlayList = p,
TrackCount = p.PlayListTracks.Count,
DurationSeconds = p.PlayListTracks.Sum(plt => (double?)plt.Track.DurationSeconds) ?? 0,
CoverTrackIds = p.PlayListTracks CoverTrackIds = p.PlayListTracks
.OrderBy(plt => plt.Position) .OrderBy(plt => plt.Position)
.Take(PlayListApplicationResponse.CoverTrackCount) .Take(PlayListApplicationResponse.CoverTrackCount)
@@ -55,7 +57,7 @@ namespace Musify.Application.PlayLists
.ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken); .ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken);
var pagedPlayLists = new StaticPagedList<PlayListApplicationResponse>( var pagedPlayLists = new StaticPagedList<PlayListApplicationResponse>(
pagedEntities.Select(entry => PlayListApplicationResponse.FromEntity(entry.PlayList, entry.CoverTrackIds)), pagedEntities.Select(entry => PlayListApplicationResponse.FromEntity(entry.PlayList, entry.TrackCount, entry.DurationSeconds, entry.CoverTrackIds)),
pagedEntities.PageNumber, pagedEntities.PageSize, pagedEntities.TotalItemCount); pagedEntities.PageNumber, pagedEntities.PageSize, pagedEntities.TotalItemCount);
return PaginatedResponse<PlayListApplicationResponse>.FromPagedList(pagedPlayLists); return PaginatedResponse<PlayListApplicationResponse>.FromPagedList(pagedPlayLists);
@@ -1,3 +1,5 @@
using System.Text.Json.Serialization;
using Musify.Application.Serialization;
using Musify.Domain.Entities; using Musify.Domain.Entities;
using Musify.Domain.ValueObjects; using Musify.Domain.ValueObjects;
@@ -10,7 +12,10 @@ namespace Musify.Application.PlayLists.Responses
string? SmallImageKeyName, string? SmallImageKeyName,
string? MediumImageKeyName, string? MediumImageKeyName,
string? LargeImageKeyName, string? LargeImageKeyName,
[property: JsonConverter(typeof(LongAsStringConverter))] long OwnerUserId,
PlaylistVisibility Visibility, PlaylistVisibility Visibility,
int TrackCount,
double DurationSeconds,
DateTime CreatedAt, DateTime CreatedAt,
DateTime UpdatedAt, DateTime UpdatedAt,
IReadOnlyList<Guid> CoverTrackIds) IReadOnlyList<Guid> CoverTrackIds)
@@ -19,6 +24,8 @@ namespace Musify.Application.PlayLists.Responses
public static PlayListApplicationResponse FromEntity( public static PlayListApplicationResponse FromEntity(
PlayList playList, PlayList playList,
int trackCount = 0,
double durationSeconds = 0,
IReadOnlyList<Guid>? coverTrackIds = null) IReadOnlyList<Guid>? coverTrackIds = null)
{ {
return new PlayListApplicationResponse( return new PlayListApplicationResponse(
@@ -28,7 +35,10 @@ namespace Musify.Application.PlayLists.Responses
playList.Pictures?.SmallName, playList.Pictures?.SmallName,
playList.Pictures?.MediumName, playList.Pictures?.MediumName,
playList.Pictures?.LargeName, playList.Pictures?.LargeName,
playList.UserId,
playList.Visibility, playList.Visibility,
trackCount,
durationSeconds,
playList.CreatedAt, playList.CreatedAt,
playList.UpdatedAt, playList.UpdatedAt,
coverTrackIds ?? []); coverTrackIds ?? []);
@@ -112,7 +112,22 @@ namespace Musify.Application.PlayLists
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
logger.LogInformation("Updated playlist {PlayListId}", playListEntity.Id); logger.LogInformation("Updated playlist {PlayListId}", playListEntity.Id);
return PlayListApplicationResponse.FromEntity(playListEntity, coverTrackIds); var totals = await database.PlayListHasTracks
.AsNoTracking()
.Where(plt => plt.PlayListId == playListEntity.Id)
.GroupBy(plt => plt.PlayListId)
.Select(group => new
{
TrackCount = group.Count(),
DurationSeconds = group.Sum(plt => plt.Track.DurationSeconds)
})
.SingleOrDefaultAsync(cancellationToken);
return PlayListApplicationResponse.FromEntity(
playListEntity,
totals?.TrackCount ?? 0,
totals?.DurationSeconds ?? 0,
coverTrackIds);
} }
private async Task<ErrorOr<Success>> PublishUpdatePlayListPictureSourceEventAsync( private async Task<ErrorOr<Success>> PublishUpdatePlayListPictureSourceEventAsync(
+3 -2
View File
@@ -11,8 +11,9 @@ ZITADEL_CLIENT_SECRET=your-web-app-client-secret
AUTH_REDIRECT_URI=http://localhost:5173/auth/callback AUTH_REDIRECT_URI=http://localhost:5173/auth/callback
AUTH_POST_LOGOUT_URI=http://localhost:5173/ AUTH_POST_LOGOUT_URI=http://localhost:5173/
# Secret used to derive the session cookie encryption key (any long random string) # Secret used to derive the session cookie encryption key. Required, at least
SESSION_SECRET=change-me-to-a-long-random-string # 32 characters (e.g. `openssl rand -base64 48`).
SESSION_SECRET=change-me-to-a-long-random-string-of-32-chars-or-more
# Backend API base URL # Backend API base URL
API_BASE_URL=http://localhost:5111 API_BASE_URL=http://localhost:5111
+1 -1
View File
@@ -50,7 +50,7 @@ The design system lives in `src/routes/layout.css` (`@theme` tokens plus `@utili
- **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`; covers: `size-cover-xs` through `size-cover-hero`).
- **Typography:** use the recipes `text-display-1/2/3/4`, `text-eyebrow`, `text-body`, `text-count`, 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.
- **Inline `style="…"` and arbitrary `-[…]` values:** only the ones whitelisted in `scripts/check-tokens.mjs` (the stagger variable `--i`, tile hue, menu position, progress width, `text-[clamp(…)]`, `leading-[…]` on login's display type, `grid-cols-[auto_1fr]`, `max-h-[85dvh]`, `my-`/`py-[Ndvh]` for vertical centering, `transition-[filter]` on genre tiles, and `bg-[image:var(--mf-*)]`). Everything else should become a token. - **Inline `style="…"` and arbitrary `-[…]` values:** only the ones whitelisted in `scripts/check-tokens.mjs` (the stagger variable `--i`, tile hue, menu position, progress width, `text-[clamp(…)]`, `leading-[…]` on login's display type, `grid-cols-[auto_1fr]`, `max-h-[85dvh]`, `my-`/`py-[Ndvh]` for vertical centering, `transition-[filter]` on genre tiles, and `bg-[image:var(--mf-*)]`). Everything else should become a token.
+289 -65
View File
@@ -36,41 +36,8 @@
"tailwindcss": "^4.3.0", "tailwindcss": "^4.3.0",
"typescript": "^6.0.3", "typescript": "^6.0.3",
"typescript-eslint": "^8.64.0", "typescript-eslint": "^8.64.0",
"vite": "^8.0.16" "vite": "^8.0.16",
} "vitest": "^5.0.1"
},
"node_modules/@emnapi/core": {
"version": "1.11.3",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.3.tgz",
"integrity": "sha512-zLpS5asjEb7lq8jYLq37N6XKaE41DIexlY1rF/z4/tIl3wo13Sqm28fRyfIsKZD+NZ8mM5RoKkpW/rBcuoSZSg==",
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"@emnapi/wasi-threads": "1.2.3",
"tslib": "^2.4.0"
}
},
"node_modules/@emnapi/core/node_modules/@emnapi/wasi-threads": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.3.tgz",
"integrity": "sha512-ELEBe8PsLvvJ6QMr0zLt8ffvOHW/dc1m3CEzNMg7aJUv3bMaoDtw2TXyDAwkYBuroxxuHEwhRTLJSe5sya547g==",
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@emnapi/runtime": {
"version": "1.11.3",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz",
"integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==",
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"tslib": "^2.4.0"
} }
}, },
"node_modules/@emnapi/wasi-threads": { "node_modules/@emnapi/wasi-threads": {
@@ -343,9 +310,9 @@
} }
}, },
"node_modules/@jridgewell/sourcemap-codec": { "node_modules/@jridgewell/sourcemap-codec": {
"version": "1.5.5", "version": "1.6.0",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz",
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@jridgewell/trace-mapping": { "node_modules/@jridgewell/trace-mapping": {
@@ -1152,6 +1119,7 @@
"resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.69.1.tgz", "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.69.1.tgz",
"integrity": "sha512-+nz8Fx/cElzb2ZPHC+6Ll3y3NEVIe4Na75PeplLlyTmd1dBXAjz2KR14y1ZgNjb2ThfAYzulu+PFy1UE3RCUzA==", "integrity": "sha512-+nz8Fx/cElzb2ZPHC+6Ll3y3NEVIe4Na75PeplLlyTmd1dBXAjz2KR14y1ZgNjb2ThfAYzulu+PFy1UE3RCUzA==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@standard-schema/spec": "^1.0.0", "@standard-schema/spec": "^1.0.0",
"@sveltejs/acorn-typescript": "^1.0.9", "@sveltejs/acorn-typescript": "^1.0.9",
@@ -1203,6 +1171,7 @@
"resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-7.1.2.tgz", "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-7.1.2.tgz",
"integrity": "sha512-DrUBA2UXRfDmUX/ZTiEopd3X40yavsJF1FX2RygcuIScHL7o5YX1fMvoYnDhjeJQC4weCOklirpNWlcb2NiSeA==", "integrity": "sha512-DrUBA2UXRfDmUX/ZTiEopd3X40yavsJF1FX2RygcuIScHL7o5YX1fMvoYnDhjeJQC4weCOklirpNWlcb2NiSeA==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"deepmerge": "^4.3.1", "deepmerge": "^4.3.1",
"magic-string": "^0.30.21", "magic-string": "^0.30.21",
@@ -1440,27 +1409,6 @@
"node": ">=14.0.0" "node": ">=14.0.0"
} }
}, },
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": {
"version": "1.11.1",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/wasi-threads": "1.2.2",
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": {
"version": "1.11.1",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": { "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": {
"version": "1.2.2", "version": "1.2.2",
"dev": true, "dev": true,
@@ -1565,12 +1513,30 @@
"tslib": "^2.4.0" "tslib": "^2.4.0"
} }
}, },
"node_modules/@types/chai": {
"version": "5.2.3",
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
"integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/deep-eql": "*",
"assertion-error": "^2.0.1"
}
},
"node_modules/@types/cookie": { "node_modules/@types/cookie": {
"version": "0.6.0", "version": "0.6.0",
"resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz", "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz",
"integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==", "integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/deep-eql": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
"integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/esrecurse": { "node_modules/@types/esrecurse": {
"version": "4.3.1", "version": "4.3.1",
"resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz",
@@ -1597,6 +1563,7 @@
"integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==", "integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==",
"devOptional": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"undici-types": "~6.21.0" "undici-types": "~6.21.0"
} }
@@ -1658,6 +1625,7 @@
"integrity": "sha512-KA0OshtlcCCXmbfqyZkM5pV3/WNraJf7DkJRLpyrmwPtud57H5BDX7C3k0LPSPxpprfRL+cJDGabF10mvNCoCw==", "integrity": "sha512-KA0OshtlcCCXmbfqyZkM5pV3/WNraJf7DkJRLpyrmwPtud57H5BDX7C3k0LPSPxpprfRL+cJDGabF10mvNCoCw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@typescript-eslint/scope-manager": "8.64.0", "@typescript-eslint/scope-manager": "8.64.0",
"@typescript-eslint/types": "8.64.0", "@typescript-eslint/types": "8.64.0",
@@ -1765,6 +1733,7 @@
"integrity": "sha512-qjhfuTfLXjA4IOzXvz0rTjT01BqEiIgPoUeMwiEjnaHKJMTNo8rH5pYW1a2L/0Dnux2fPC85AeyJoWaGa8WxTA==", "integrity": "sha512-qjhfuTfLXjA4IOzXvz0rTjT01BqEiIgPoUeMwiEjnaHKJMTNo8rH5pYW1a2L/0Dnux2fPC85AeyJoWaGa8WxTA==",
"devOptional": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0" "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
}, },
@@ -1843,11 +1812,70 @@
"url": "https://opencollective.com/typescript-eslint" "url": "https://opencollective.com/typescript-eslint"
} }
}, },
"node_modules/@vitest/mocker": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.0.1.tgz",
"integrity": "sha512-6K1DoBNAPGvuOcSsGA4D6x+5zEEff/KmOOP3uetT2TrGpVfI+HRHRnJJfKi5ib/g1vx8IYHQD8s0pbJz8WQI7Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/trace-mapping": "0.3.31",
"@vitest/spy": "5.0.1",
"estree-walker": "^3.0.3",
"magic-string": "^1.2.3"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"msw": "^2.4.9",
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
},
"peerDependenciesMeta": {
"msw": {
"optional": true
},
"vite": {
"optional": true
}
}
},
"node_modules/@vitest/mocker/node_modules/estree-walker": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
"integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/estree": "^1.0.0"
}
},
"node_modules/@vitest/mocker/node_modules/magic-string": {
"version": "1.4.2",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.2.tgz",
"integrity": "sha512-vG+rjFRj1PqdIBozIxAGMjPlOhaVe+GXpbttY/iSK7rGcJRMlwNJO7dcUwmUqkymsFLJiNGI06t4D7Fr7yRC9g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.6.0"
}
},
"node_modules/@vitest/spy": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.0.1.tgz",
"integrity": "sha512-rbto/mF/SGERxEgYOek7Xm6B9b+y+mVoo+f4b2LymYO8zM1b7uB5nHuhVMTP2hxdzgxvGiZYGxGIaMvL5y180Q==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/acorn": { "node_modules/acorn": {
"version": "8.17.0", "version": "8.17.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz",
"integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==",
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"acorn": "bin/acorn" "acorn": "bin/acorn"
}, },
@@ -1891,6 +1919,16 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/assertion-error": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
"integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
}
},
"node_modules/axobject-query": { "node_modules/axobject-query": {
"version": "4.1.0", "version": "4.1.0",
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz",
@@ -1923,6 +1961,16 @@
"node": "18 || 20 || >=22" "node": "18 || 20 || >=22"
} }
}, },
"node_modules/chai": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
"integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/chokidar": { "node_modules/chokidar": {
"version": "4.0.3", "version": "4.0.3",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
@@ -2063,6 +2111,13 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/es-module-lexer": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz",
"integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==",
"dev": true,
"license": "MIT"
},
"node_modules/escape-string-regexp": { "node_modules/escape-string-regexp": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
@@ -2082,6 +2137,7 @@
"integrity": "sha512-GVTD7s1vdIl6UYvAfriOPeY1Df8LIZjfofLvHwde+erDHGGuHyuM6xoxRxmHiebhYuD2p1vN4wWh0XzPARSGDQ==", "integrity": "sha512-GVTD7s1vdIl6UYvAfriOPeY1Df8LIZjfofLvHwde+erDHGGuHyuM6xoxRxmHiebhYuD2p1vN4wWh0XzPARSGDQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"workspaces": [ "workspaces": [
"packages/*" "packages/*"
], ],
@@ -2323,6 +2379,16 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/expect-type": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz",
"integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/fast-deep-equal": { "node_modules/fast-deep-equal": {
"version": "3.1.3", "version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
@@ -3007,9 +3073,9 @@
} }
}, },
"node_modules/obug": { "node_modules/obug": {
"version": "2.1.3", "version": "2.2.1",
"resolved": "https://registry.npmjs.org/obug/-/obug-2.1.3.tgz", "resolved": "https://registry.npmjs.org/obug/-/obug-2.2.1.tgz",
"integrity": "sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==", "integrity": "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==",
"funding": [ "funding": [
"https://github.com/sponsors/sxzz", "https://github.com/sponsors/sxzz",
"https://opencollective.com/debug" "https://opencollective.com/debug"
@@ -3130,9 +3196,9 @@
"license": "ISC" "license": "ISC"
}, },
"node_modules/picomatch": { "node_modules/picomatch": {
"version": "4.0.5", "version": "4.0.7",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz",
"integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==",
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=12" "node": ">=12"
@@ -3160,6 +3226,7 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"nanoid": "^3.3.12", "nanoid": "^3.3.12",
"picocolors": "^1.1.1", "picocolors": "^1.1.1",
@@ -3293,6 +3360,7 @@
"integrity": "sha512-/FVl766LpUfB5vXgCYOYa0MeV/441Ia99AeICQIQFTY/Nw0roZwULcXpku5i1/m5kt/baz+s4Zogspd839HSMg==", "integrity": "sha512-/FVl766LpUfB5vXgCYOYa0MeV/441Ia99AeICQIQFTY/Nw0roZwULcXpku5i1/m5kt/baz+s4Zogspd839HSMg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"prettier": "bin/prettier.cjs" "prettier": "bin/prettier.cjs"
}, },
@@ -3309,6 +3377,7 @@
"integrity": "sha512-wXvbXMjSvb4C9ENWTHXyd+ihakKCsJ6rJhLP6/8HFNj4GkZr48jqL9PoKsl2sk7SyCZRTnJ7O2TTowUpOxP/KA==", "integrity": "sha512-wXvbXMjSvb4C9ENWTHXyd+ihakKCsJ6rJhLP6/8HFNj4GkZr48jqL9PoKsl2sk7SyCZRTnJ7O2TTowUpOxP/KA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=20" "node": ">=20"
}, },
@@ -3479,6 +3548,7 @@
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz",
"integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==", "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@types/estree": "1.0.9" "@types/estree": "1.0.9"
}, },
@@ -3573,6 +3643,13 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/siginfo": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
"integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
"dev": true,
"license": "ISC"
},
"node_modules/sirv": { "node_modules/sirv": {
"version": "3.0.2", "version": "3.0.2",
"resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz", "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz",
@@ -3596,6 +3673,20 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/stackback": {
"version": "0.0.2",
"resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
"integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
"dev": true,
"license": "MIT"
},
"node_modules/std-env": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz",
"integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==",
"dev": true,
"license": "MIT"
},
"node_modules/supports-preserve-symlinks-flag": { "node_modules/supports-preserve-symlinks-flag": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz",
@@ -3613,6 +3704,7 @@
"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.56.4.tgz", "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.56.4.tgz",
"integrity": "sha512-/d0QHehmRuJW8gVz395MTkPcPozxzdjBMBE8oEYGz8O3b9KTMzzQ9ZHJQLuFKOHOPQbU6kx/X4iid/EBBzH7iw==", "integrity": "sha512-/d0QHehmRuJW8gVz395MTkPcPozxzdjBMBE8oEYGz8O3b9KTMzzQ9ZHJQLuFKOHOPQbU6kx/X4iid/EBBzH7iw==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@jridgewell/remapping": "^2.3.4", "@jridgewell/remapping": "^2.3.4",
"@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/sourcemap-codec": "^1.5.0",
@@ -3760,6 +3852,26 @@
"url": "https://opencollective.com/webpack" "url": "https://opencollective.com/webpack"
} }
}, },
"node_modules/tinybench": {
"version": "6.1.4",
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.1.4.tgz",
"integrity": "sha512-9APumHG7r4yOk4X4WlkmE71aZcv1gvin1czO3OQ1U9iJcFA5Ja/ygyb0vPOVHTthFozUYs8CLoLUlM8grb2lTQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/tinyexec": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.0.tgz",
"integrity": "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/tinyglobby": { "node_modules/tinyglobby": {
"version": "0.2.17", "version": "0.2.17",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
@@ -3824,6 +3936,7 @@
"integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
"devOptional": true, "devOptional": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"bin": { "bin": {
"tsc": "bin/tsc", "tsc": "bin/tsc",
"tsserver": "bin/tsserver" "tsserver": "bin/tsserver"
@@ -3885,6 +3998,7 @@
"resolved": "https://registry.npmjs.org/vite/-/vite-8.1.3.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.3.tgz",
"integrity": "sha512-Ds+gBRbj0lwRO2Y5hwnUBdxSwlAve9LeRyU4sNnAr0ewW0gWF0n5bgXgUzbgZ49MV9BVUAQUFYVcDUcilUExMA==", "integrity": "sha512-Ds+gBRbj0lwRO2Y5hwnUBdxSwlAve9LeRyU4sNnAr0ewW0gWF0n5bgXgUzbgZ49MV9BVUAQUFYVcDUcilUExMA==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"lightningcss": "^1.32.0", "lightningcss": "^1.32.0",
"picomatch": "^4.0.4", "picomatch": "^4.0.4",
@@ -3976,6 +4090,99 @@
} }
} }
}, },
"node_modules/vitest": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-5.0.1.tgz",
"integrity": "sha512-iA95lQbKEkvrtTkdAgnWbXfbipWiiWe/hDl2P5tMi6WFwD76G0NxXAGp/M9EOcYupeGJRr6wppMc7CoA41TQjg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/chai": "^5.2.2",
"@vitest/mocker": "5.0.1",
"chai": "^6.2.2",
"es-module-lexer": "^2.3.2",
"expect-type": "^1.4.0",
"magic-string": "^1.2.3",
"obug": "^2.1.4",
"picomatch": "^4.0.7",
"std-env": "^4.2.0",
"tinybench": "6.1.4",
"tinyexec": "1.3.0",
"tinyglobby": "^0.2.17",
"why-is-node-running": "^2.3.0"
},
"bin": {
"vitest": "vitest.mjs"
},
"engines": {
"node": "^22.12.0 || ^24.0.0 || >=26.0.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"@edge-runtime/vm": "*",
"@opentelemetry/api": "^1.9.0",
"@types/node": "^22.0.0 || >=24.0.0",
"@vitest/browser-playwright": "5.0.1",
"@vitest/browser-preview": "5.0.1",
"@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0",
"@vitest/coverage-istanbul": "5.0.1",
"@vitest/coverage-v8": "5.0.1",
"@vitest/ui": "5.0.1",
"happy-dom": "*",
"jsdom": "*",
"vite": "^6.4.0 || ^7.0.0 || ^8.0.0"
},
"peerDependenciesMeta": {
"@edge-runtime/vm": {
"optional": true
},
"@opentelemetry/api": {
"optional": true
},
"@types/node": {
"optional": true
},
"@vitest/browser-playwright": {
"optional": true
},
"@vitest/browser-preview": {
"optional": true
},
"@vitest/browser-webdriverio": {
"optional": true
},
"@vitest/coverage-istanbul": {
"optional": true
},
"@vitest/coverage-v8": {
"optional": true
},
"@vitest/ui": {
"optional": true
},
"happy-dom": {
"optional": true
},
"jsdom": {
"optional": true
},
"vite": {
"optional": false
}
}
},
"node_modules/vitest/node_modules/magic-string": {
"version": "1.4.2",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.2.tgz",
"integrity": "sha512-vG+rjFRj1PqdIBozIxAGMjPlOhaVe+GXpbttY/iSK7rGcJRMlwNJO7dcUwmUqkymsFLJiNGI06t4D7Fr7yRC9g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.6.0"
}
},
"node_modules/which": { "node_modules/which": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
@@ -3992,6 +4199,23 @@
"node": ">= 8" "node": ">= 8"
} }
}, },
"node_modules/why-is-node-running": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
"integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
"dev": true,
"license": "MIT",
"dependencies": {
"siginfo": "^2.0.0",
"stackback": "0.0.2"
},
"bin": {
"why-is-node-running": "cli.js"
},
"engines": {
"node": ">=8"
}
},
"node_modules/word-wrap": { "node_modules/word-wrap": {
"version": "1.2.5", "version": "1.2.5",
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
+5 -2
View File
@@ -11,9 +11,11 @@
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"gen:api": "npx -y openapi-typescript@7 http://localhost:5111/openapi/v1.json -o src/lib/api/schema.d.ts", "gen:api": "npx -y openapi-typescript@7 http://localhost:5111/openapi/v1.json -o src/lib/api/schema.d.ts",
"test": "vitest run",
"test:watch": "vitest",
"format": "prettier --write .", "format": "prettier --write .",
"lint:tokens": "node scripts/check-tokens.mjs", "lint:tokens": "node scripts/check-tokens.mjs",
"lint": "prettier --check . && eslint . && npm run lint:tokens" "lint": "prettier --check . && eslint . && npm run lint:tokens && npm run test"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^10.0.1", "@eslint/js": "^10.0.1",
@@ -33,7 +35,8 @@
"tailwindcss": "^4.3.0", "tailwindcss": "^4.3.0",
"typescript": "^6.0.3", "typescript": "^6.0.3",
"typescript-eslint": "^8.64.0", "typescript-eslint": "^8.64.0",
"vite": "^8.0.16" "vite": "^8.0.16",
"vitest": "^5.0.1"
}, },
"dependencies": { "dependencies": {
"@fontsource-variable/bricolage-grotesque": "^5.3.0", "@fontsource-variable/bricolage-grotesque": "^5.3.0",
+1
View File
@@ -940,6 +940,7 @@ export interface components {
smallImageKeyName: null | string; smallImageKeyName: null | string;
mediumImageKeyName: null | string; mediumImageKeyName: null | string;
largeImageKeyName: null | string; largeImageKeyName: null | string;
ownerUserId: string;
visibility: components["schemas"]["PlaylistVisibility"]; visibility: components["schemas"]["PlaylistVisibility"];
/** Format: date-time */ /** Format: date-time */
createdAt: string; createdAt: string;
@@ -9,6 +9,8 @@
import Plus from '@lucide/svelte/icons/plus'; import Plus from '@lucide/svelte/icons/plus';
import Logo from '../ui/primitives/Logo.svelte'; import Logo from '../ui/primitives/Logo.svelte';
const SIDEBAR_PLAYLISTS_LIMIT = 8;
interface SidebarPlaylist { interface SidebarPlaylist {
id: string; id: string;
name: string; name: string;
@@ -19,11 +21,14 @@
interface Props { interface Props {
user: SessionUser | null; user: SessionUser | null;
playlists: SidebarPlaylist[]; playlists: SidebarPlaylist[];
playlistCount: number;
} }
let { user, playlists }: Props = $props(); let { user, playlists, playlistCount }: Props = $props();
const navLinks = $derived(appNavLinks(user, { playlists: playlists.length, liked: liked.count })); const visiblePlaylists = $derived(playlists.slice(0, SIDEBAR_PLAYLISTS_LIMIT));
const navLinks = $derived(appNavLinks(user, { playlists: playlistCount, liked: liked.count }));
function isActive(href: string) { function isActive(href: string) {
return isSectionActive(href, page.url.pathname, page.data.section); return isSectionActive(href, page.url.pathname, page.data.section);
@@ -61,10 +66,10 @@
<div class="mx-3 mt-6 mb-3.5 h-px bg-line"></div> <div class="mx-3 mt-6 mb-3.5 h-px bg-line"></div>
<div class="flex items-center justify-between px-3 pb-1.5"> <div class="flex items-center justify-between px-3 pb-1.5">
<span class="text-eyebrow text-fg-2">Tus playlists</span> <span class="text-eyebrow text-fg-2">Tus playlists</span>
{#if playlists.length > 0} {#if visiblePlaylists.length > 0}
<IconButton <IconButton
label="Crear playlist" label="Crear playlist"
tone="plain" plain
size="xs" size="xs"
onclick={() => createPlaylistModal.show()} onclick={() => createPlaylistModal.show()}
> >
@@ -74,7 +79,7 @@
</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-icon-md">
{#each playlists 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
href="/playlists/{playlist.id}" href="/playlists/{playlist.id}"
@@ -91,7 +96,7 @@
<button <button
type="button" type="button"
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 hover:text-fg-2" 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-3.5" />
Crear tu primera lista Crear tu primera lista
@@ -54,14 +54,7 @@
</script> </script>
<header class="hidden items-center gap-3.5 px-6.5 pt-4 pb-3 sm:flex"> <header class="hidden items-center gap-3.5 px-6.5 pt-4 pb-3 sm:flex">
<IconButton <IconButton label="Atrás" shape="round" surface size="xs" onclick={() => history.back()}>
label="Atrás"
shape="round"
tone="subtle"
surface
size="xs"
onclick={() => history.back()}
>
<ChevronLeft class="size-icon-sm" /> <ChevronLeft class="size-icon-sm" />
</IconButton> </IconButton>
@@ -91,9 +84,7 @@
</label> </label>
</form> </form>
<div class="flex-1"></div> <div class="ml-auto shrink-0">
<div class="shrink-0">
{#if user} {#if user}
<AccountMenu {user} {accountUrl}> <AccountMenu {user} {accountUrl}>
{#snippet trigger({ toggle, open })} {#snippet trigger({ toggle, open })}
@@ -12,7 +12,7 @@
import ListMusic from '@lucide/svelte/icons/list-music'; import ListMusic from '@lucide/svelte/icons/list-music';
function seek(value: number) { function seek(value: number) {
if (player.current.duration > 0) player.seekFraction(value / player.current.duration); if (player.duration > 0) player.seekFraction(value / player.duration);
} }
</script> </script>
@@ -39,20 +39,20 @@
</span> </span>
<Slider <Slider
value={player.progress} value={player.progress}
max={player.current.duration} max={player.duration}
label="Barra de progreso" label="Barra de progreso"
oninput={seek} oninput={seek}
variant="seek" variant="seek"
/> />
<span class="min-w-8 text-xs text-fg-2 tabular-nums"> <span class="min-w-8 text-xs text-fg-2 tabular-nums">
{fmtTime(player.current.duration)} {fmtTime(player.duration)}
</span> </span>
</div> </div>
</div> </div>
<div class="hidden w-(--mf-player-side-w) shrink-0 items-center justify-end gap-3.5 sm:flex"> <div class="flex w-(--mf-player-side-w) shrink-0 items-center justify-end gap-3.5">
<IconButton <IconButton
label={player.muted ? 'Activar sonido' : 'Silenciar'} label={player.muted ? 'Activar sonido' : 'Silenciar'}
tone="plain" plain
size="xs" size="xs"
onclick={() => player.toggleMute()} onclick={() => player.toggleMute()}
> >
@@ -73,7 +73,7 @@
/> />
<IconButton <IconButton
label="Cola de reproducción" label="Cola de reproducción"
tone="plain" plain
size="xs" size="xs"
pressed={queuePanel.open} pressed={queuePanel.open}
onclick={() => queuePanel.toggle()} onclick={() => queuePanel.toggle()}
@@ -48,12 +48,12 @@
> >
<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>
<IconButton label="Cerrar cola" tone="plain" size="xs" onclick={() => queuePanel.close()}> <IconButton label="Cerrar cola" plain size="xs" onclick={() => queuePanel.close()}>
<X class="size-icon-md" /> <X class="size-icon-md" />
</IconButton> </IconButton>
</div> </div>
{#if player.current.id} {#if player.current}
<p class="mb-3 text-eyebrow text-fg-2">Reproduciendo</p> <p class="mb-3 text-eyebrow text-fg-2">Reproduciendo</p>
<div class="mb-6 surface-active rounded-control p-2.5"> <div class="mb-6 surface-active rounded-control p-2.5">
<MediaIdentity <MediaIdentity
@@ -13,27 +13,20 @@
let { compact = false }: Props = $props(); let { compact = false }: Props = $props();
const isLiked = $derived(player.current.id !== '' && liked.isLiked(player.current.id)); const current = $derived(player.current);
const isLiked = $derived(current !== null && liked.isLiked(current.id));
function toggleLike() { function toggleLike() {
if (!player.current.id) return; if (current) liked.toggle(current);
liked.toggle({
id: player.current.id,
title: player.current.title,
artist: player.current.artist,
explicit: player.current.explicit,
ownerUserId: player.current.ownerUserId,
duration: player.current.duration
});
} }
</script> </script>
<div class="flex min-w-0 items-center gap-2.5 md:gap-3"> <div class="flex min-w-0 items-center gap-2.5 md:gap-3">
{#if player.current.id} {#if current}
<Artwork <Artwork
trackIds={[player.current.id]} trackIds={[current.id]}
size="sm" size="sm"
alt={player.current.title} alt={current.title}
class="shrink-0 md:size-cover-md" class="shrink-0 md:size-cover-md"
/> />
{:else} {:else}
@@ -41,21 +34,21 @@
{/if} {/if}
<div class="min-w-0"> <div class="min-w-0">
<div class="flex min-w-0 items-center gap-1.5"> <div class="flex min-w-0 items-center gap-1.5">
{#if player.current.explicit} {#if current?.explicit}
<ExplicitBadge /> <ExplicitBadge />
{/if} {/if}
<span class="truncate text-sm text-fg">{player.current.title}</span> <span class="truncate text-sm text-fg">{current?.title ?? ''}</span>
</div> </div>
<ArtistLink <ArtistLink
name={player.current.artist || '—'} name={current?.artist || '—'}
ownerUserId={player.current.ownerUserId} ownerUserId={current?.ownerUserId}
class="mt-0.5 text-xs text-fg-2" class="mt-0.5 text-xs text-fg-2"
/> />
</div> </div>
{#if !compact} {#if !compact}
<IconButton <IconButton
label={isLiked ? 'Quitar de Me gusta' : 'Añadir a Me gusta'} label={isLiked ? 'Quitar de Me gusta' : 'Añadir a Me gusta'}
tone="plain" plain
size="md" size="md"
pressed={isLiked} pressed={isLiked}
onclick={toggleLike} onclick={toggleLike}
@@ -5,7 +5,7 @@
import Field from '../primitives/Field.svelte'; import Field from '../primitives/Field.svelte';
import Input from '../primitives/Input.svelte'; import Input from '../primitives/Input.svelte';
import Textarea from '../primitives/Textarea.svelte'; import Textarea from '../primitives/Textarea.svelte';
import { ALBUM_EARLIEST_YEAR } from '$lib/config'; import { ALBUM_EARLIEST_YEAR, LIMITS } from '$lib/validation';
interface Props { interface Props {
action: string; action: string;
@@ -58,10 +58,13 @@
> >
{#snippet fields()} {#snippet fields()}
<Field label="Título" for="album-title"> <Field label="Título" for="album-title">
{#snippet hint()}
<span class="ml-auto tabular-nums">{title.length}/{LIMITS.albumTitle}</span>
{/snippet}
<Input <Input
id="album-title" id="album-title"
name="title" name="title"
maxlength={200} maxlength={LIMITS.albumTitle}
required required
bind:value={title} bind:value={title}
placeholder={titlePlaceholder} placeholder={titlePlaceholder}
@@ -85,7 +88,7 @@
<Textarea <Textarea
id="album-description" id="album-description"
name="description" name="description"
maxlength={256} maxlength={LIMITS.albumDescription}
bind:value={description} bind:value={description}
placeholder="¿De qué va este álbum?" placeholder="¿De qué va este álbum?"
/> />
@@ -81,10 +81,8 @@
<div class="flex items-center justify-between gap-4"> <div class="flex items-center justify-between gap-4">
{#if helperText} {#if helperText}
<p class="text-sm text-fg-2">{helperText}</p> <p class="text-sm text-fg-2">{helperText}</p>
{:else}
<span></span>
{/if} {/if}
<div class="flex shrink-0 items-center gap-3"> <div class="ml-auto flex shrink-0 items-center gap-3">
<Button type="button" variant="secondary" onclick={onCancel}>Cancelar</Button> <Button type="button" variant="secondary" onclick={onCancel}>Cancelar</Button>
<Button type="submit" disabled={submitting || !canSubmit}> <Button type="submit" disabled={submitting || !canSubmit}>
{submitting ? submittingLabel : submitLabel} {submitting ? submittingLabel : submitLabel}
@@ -37,9 +37,16 @@
function onInput(event: Event) { function onInput(event: Event) {
const file = (event.currentTarget as HTMLInputElement).files?.[0]; const file = (event.currentTarget as HTMLInputElement).files?.[0];
if (!file) return; if (!file) return;
releasePreview();
preview = URL.createObjectURL(file); preview = URL.createObjectURL(file);
onselect?.(file); onselect?.(file);
} }
function releasePreview() {
if (preview !== '') URL.revokeObjectURL(preview);
}
$effect(() => releasePreview);
</script> </script>
<label <label
@@ -7,8 +7,7 @@
import Textarea from '../primitives/Textarea.svelte'; import Textarea from '../primitives/Textarea.svelte';
import SegmentedControl from '../primitives/SegmentedControl.svelte'; import SegmentedControl from '../primitives/SegmentedControl.svelte';
const NAME_MAX_LENGTH = 60; import { LIMITS } from '$lib/validation';
const DESCRIPTION_MAX_LENGTH = 300;
interface Props { interface Props {
action: string; action: string;
@@ -61,12 +60,12 @@
<input type="hidden" name="visibility" value={visibility} /> <input type="hidden" name="visibility" value={visibility} />
<Field label="Nombre" for="playlist-name"> <Field label="Nombre" for="playlist-name">
{#snippet hint()} {#snippet hint()}
<span class="ml-auto tabular-nums">{name.length}/{NAME_MAX_LENGTH}</span> <span class="ml-auto tabular-nums">{name.length}/{LIMITS.playlistName}</span>
{/snippet} {/snippet}
<Input <Input
id="playlist-name" id="playlist-name"
name="name" name="name"
maxlength={NAME_MAX_LENGTH} maxlength={LIMITS.playlistName}
required required
bind:value={name} bind:value={name}
placeholder={namePlaceholder} placeholder={namePlaceholder}
@@ -77,7 +76,7 @@
<Textarea <Textarea
id="playlist-description" id="playlist-description"
name="description" name="description"
maxlength={DESCRIPTION_MAX_LENGTH} maxlength={LIMITS.playlistDescription}
bind:value={description} bind:value={description}
placeholder="Para qué sirve esta playlist, cuándo la escuchas…" placeholder="Para qué sirve esta playlist, cuándo la escuchas…"
class="flex-1" class="flex-1"
@@ -13,7 +13,7 @@
<a <a
href="/user/{ownerUserId}" href="/user/{ownerUserId}"
onclick={(e) => e.stopPropagation()} onclick={(e) => e.stopPropagation()}
class="block max-w-full truncate hover:text-fg-2 hover:underline {klass}" class="block max-w-full truncate hover:underline {klass}"
> >
{name} {name}
</a> </a>
@@ -9,7 +9,6 @@
size: Size; size: Size;
shape?: 'auto' | 'round'; shape?: 'auto' | 'round';
imageSize?: 'small' | 'medium' | 'large'; imageSize?: 'small' | 'medium' | 'large';
fallback?: 'music' | 'gradient' | 'none';
alt?: string; alt?: string;
class?: string; class?: string;
children?: Snippet; children?: Snippet;
@@ -21,7 +20,6 @@
size, size,
shape = 'auto', shape = 'auto',
imageSize, imageSize,
fallback = 'music',
alt = '', alt = '',
class: klass = '', class: klass = '',
children children
@@ -116,7 +114,7 @@
onerror={() => (singleFailed = true)} onerror={() => (singleFailed = true)}
class="h-full w-full object-cover" class="h-full w-full object-cover"
/> />
{:else if fallback === 'music'} {:else}
<div class="grid h-full w-full place-items-center bg-surface"> <div class="grid h-full w-full place-items-center bg-surface">
<svg viewBox="0 0 24 24" fill="currentColor" class="h-2/5 w-2/5 text-fg-2"> <svg viewBox="0 0 24 24" fill="currentColor" class="h-2/5 w-2/5 text-fg-2">
<path <path
@@ -124,10 +122,6 @@
/> />
</svg> </svg>
</div> </div>
{:else if fallback === 'gradient'}
<div class="h-full w-full bg-(image:--mf-cover-grad)"></div>
{:else}
<div class="h-full w-full bg-surface"></div>
{/if} {/if}
{@render children?.()} {@render children?.()}
</div> </div>
@@ -1,19 +1,11 @@
<script lang="ts"> <script lang="ts">
interface Props { interface Props {
size?: number;
barWidth?: number;
paused?: boolean; paused?: boolean;
overlay?: boolean; overlay?: boolean;
class?: string; class?: string;
} }
let { let { paused = false, overlay = false, class: klass = '' }: Props = $props();
size = 13,
barWidth = 2,
paused = false,
overlay = false,
class: klass = ''
}: Props = $props();
const delays = [0, 0.3, 0.6]; const delays = [0, 0.3, 0.6];
</script> </script>
@@ -30,11 +22,11 @@
{/each} {/each}
</div> </div>
{:else} {:else}
<div class="flex items-end justify-center gap-0.5 {klass}" style="height:{size}px"> <div class="flex h-3.5 items-end justify-center gap-0.5 {klass}">
{#each delays as delay (delay)} {#each delays as delay (delay)}
<span <span
class="rounded-full bg-accent" class="h-full w-0.5 rounded-full bg-accent"
style="width:{barWidth}px; height:100%; animation:equalize .9s ease-in-out {delay}s infinite; animation-play-state:{paused style="animation:equalize .9s ease-in-out {delay}s infinite; animation-play-state:{paused
? 'paused' ? 'paused'
: 'running'}" : 'running'}"
></span> ></span>
@@ -5,12 +5,11 @@
interface Props { interface Props {
following: boolean; following: boolean;
userId?: string;
invalidateAll?: boolean; invalidateAll?: boolean;
class?: string; class?: string;
} }
let { following: initial, userId, invalidateAll = false, class: klass = '' }: Props = $props(); let { following: initial, invalidateAll = false, class: klass = '' }: Props = $props();
let following = $state(untrack(() => initial)); let following = $state(untrack(() => initial));
let submitting = $state(false); let submitting = $state(false);
@@ -30,9 +29,6 @@
}; };
}} }}
> >
{#if userId}
<input type="hidden" name="userId" value={userId} />
{/if}
<Button <Button
type="submit" type="submit"
size="sm" size="sm"
@@ -18,7 +18,6 @@
class?: string; class?: string;
art?: Snippet; art?: Snippet;
overlay?: Snippet; overlay?: Snippet;
badge?: Snippet;
} }
let { let {
@@ -34,8 +33,7 @@
titleClass = '', titleClass = '',
class: klass = '', class: klass = '',
art, art,
overlay, overlay
badge
}: Props = $props(); }: Props = $props();
</script> </script>
@@ -61,7 +59,6 @@
<span class="min-w-0 truncate text-sm {active ? 'text-accent-soft' : 'text-fg'} {titleClass}" <span class="min-w-0 truncate text-sm {active ? 'text-accent-soft' : 'text-fg'} {titleClass}"
>{title}</span >{title}</span
> >
{@render badge?.()}
</div> </div>
<ArtistLink name={artist} {ownerUserId} class="mt-0.5 text-xs text-fg-2" /> <ArtistLink name={artist} {ownerUserId} class="mt-0.5 text-xs text-fg-2" />
</div> </div>
@@ -1,18 +1,9 @@
<script lang="ts" module> <script lang="ts" module>
import type { Track } from '$lib/types';
export type TrackListColumn = 'added' | 'uploaded' | 'plays'; export type TrackListColumn = 'added' | 'uploaded' | 'plays';
export interface TrackListTrack { export type TrackListTrack = Track & { date?: string | number };
id: string | number;
title: string;
artist?: string | null;
ownerUserId?: string | number | null;
isExplicit?: boolean;
explicit?: boolean;
duration?: number | string;
listensCount?: number | string;
addedAt?: string | number;
uploadedAt?: string | number;
}
const COLUMN_PRESET: Record<TrackListColumn, { label: string; width: string }> = { const COLUMN_PRESET: Record<TrackListColumn, { label: string; width: string }> = {
added: { label: 'Añadida', width: '124px' }, added: { label: 'Añadida', width: '124px' },
@@ -108,7 +99,7 @@
</div> </div>
<div class="mt-1 flex flex-col gap-0.5" role="list"> <div class="mt-1 flex flex-col gap-0.5" role="list">
{#each tracks as track, i (track.id)} {#each tracks as track, i (track.id)}
{@const active = player.current.id === track.id} {@const active = player.currentId === track.id}
<div <div
role="listitem" role="listitem"
@@ -138,8 +129,6 @@
role="button" role="button"
tabindex="0" tabindex="0"
use:pressable={() => onPlay(i)} use:pressable={() => onPlay(i)}
onclick={(event) => event.stopPropagation()}
onkeydown={(event) => event.stopPropagation()}
class="flex min-w-0 flex-1 text-left" class="flex min-w-0 flex-1 text-left"
> >
<MediaIdentity <MediaIdentity
@@ -147,7 +136,7 @@
title={track.title} title={track.title}
artist={track.artist} artist={track.artist}
ownerUserId={track.ownerUserId} ownerUserId={track.ownerUserId}
explicit={track.isExplicit ?? track.explicit ?? false} explicit={track.explicit}
{active} {active}
> >
{#snippet overlay()} {#snippet overlay()}
@@ -160,26 +149,18 @@
{#each columns as column (column)} {#each columns as column (column)}
<span class="hidden truncate text-center text-xs text-fg-2 tabular-nums sm:block"> <span class="hidden truncate text-center text-xs text-fg-2 tabular-nums sm:block">
{#if column === 'plays'} {#if column === 'plays'}
{Number(track.listensCount ?? 0)} {track.listensCount}
{:else if column === 'added' && track.addedAt} {:else if track.date}
{fmtDate(track.addedAt)} {fmtDate(track.date)}
{:else if column === 'uploaded' && track.uploadedAt}
{fmtDate(track.uploadedAt)}
{/if} {/if}
</span> </span>
{/each} {/each}
<span class="truncate text-center text-xs text-fg-2 tabular-nums"> <span class="truncate text-center text-xs text-fg-2 tabular-nums">
{fmtTime(Number(track.duration))} {fmtTime(track.duration)}
</span> </span>
{#if rowAction} {#if rowAction}
{#if rowAction.action} {#if rowAction.action}
<form <form method="POST" action={rowAction.action} use:enhance>
method="POST"
action={rowAction.action}
use:enhance={() =>
async ({ update }) =>
update()}
>
<input type="hidden" name="trackId" value={track.id} /> <input type="hidden" name="trackId" value={track.id} />
<IconButton type="submit" label={rowAction.label} size="xs" revealOnHover> <IconButton type="submit" label={rowAction.label} size="xs" revealOnHover>
<rowAction.icon class="size-icon-sm" /> <rowAction.icon class="size-icon-sm" />
@@ -61,7 +61,7 @@
{/if} {/if}
<MenuItem <MenuItem
icon={themeMode.current === 'dark' ? Sun : Moon} icon={themeMode.current === 'dark' ? Sun : Moon}
label={themeMode.current === 'dark' ? 'Modo blanco' : 'Modo oscuro'} label={themeMode.current === 'dark' ? 'Modo claro' : 'Modo oscuro'}
onclick={() => themeMode.toggle()} onclick={() => themeMode.toggle()}
/> />
<form method="POST" action="/auth/logout" data-sveltekit-reload> <form method="POST" action="/auth/logout" data-sveltekit-reload>
@@ -9,15 +9,19 @@
description: string; description: string;
confirmLabel: string; confirmLabel: string;
action: string; action: string;
fields?: Record<string, string>;
} }
let { open, onClose, title, description, confirmLabel, action }: Props = $props(); let { open, onClose, title, description, confirmLabel, action, fields = {} }: Props = $props();
</script> </script>
<Dialog {open} {onClose} {title} {description}> <Dialog {open} {onClose} {title} {description}>
{#snippet actions()} {#snippet actions()}
<Button variant="secondary" onclick={onClose}>Cancelar</Button> <Button variant="secondary" onclick={onClose}>Cancelar</Button>
<form method="POST" {action}> <form method="POST" {action}>
{#each Object.entries(fields) as [name, value] (name)}
<input type="hidden" {name} {value} />
{/each}
<Button type="submit" variant="danger">{confirmLabel}</Button> <Button type="submit" variant="danger">{confirmLabel}</Button>
</form> </form>
{/snippet} {/snippet}
@@ -6,7 +6,7 @@
type?: 'button' | 'submit'; type?: 'button' | 'submit';
size?: 'xs' | 'sm' | 'md' | 'lg'; size?: 'xs' | 'sm' | 'md' | 'lg';
shape?: 'square' | 'round'; shape?: 'square' | 'round';
tone?: 'muted' | 'subtle' | 'plain'; plain?: boolean;
pressed?: boolean; pressed?: boolean;
surface?: boolean; surface?: boolean;
revealOnHover?: boolean; revealOnHover?: boolean;
@@ -22,7 +22,7 @@
type = 'button', type = 'button',
size = 'sm', size = 'sm',
shape = 'square', shape = 'square',
tone = 'muted', plain = false,
pressed, pressed,
surface = false, surface = false,
revealOnHover = false, revealOnHover = false,
@@ -40,18 +40,12 @@
lg: 'size-10' lg: 'size-10'
}; };
const TEXT: Record<'muted' | 'subtle' | 'plain', string> = {
muted: 'text-fg-2 hover:text-fg',
subtle: 'text-fg-2 hover:text-fg',
plain: 'text-fg-2 hover:text-fg'
};
const background = $derived( const background = $derived(
surface ? 'bg-surface-2 hover:bg-surface-hover' : tone === 'plain' ? '' : 'hover:bg-hover' surface ? 'bg-surface-2 hover:bg-surface-hover' : plain ? '' : 'hover:bg-hover'
); );
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[tone]} ${background} ${ `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} ${
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}`
); );
@@ -26,7 +26,7 @@
class="relative z-(--z-raised) flex-1 px-4 py-1.5 text-center text-xs font-medium transition-colors {value === class="relative z-(--z-raised) flex-1 px-4 py-1.5 text-center text-xs font-medium transition-colors {value ===
option.value option.value
? 'text-ink' ? 'text-ink'
: 'text-fg-2 hover:text-fg-2'}" : 'text-fg-2'}"
> >
{option.label} {option.label}
</button> </button>
+7 -3
View File
@@ -1,19 +1,23 @@
export const EXPLORE_PAGE_SIZE = 27; export const EXPLORE_PAGE_SIZE = 27;
export const MAX_PAGE_SIZE = 100;
export const SEARCH_DEBOUNCE_MS = 450; export const SEARCH_DEBOUNCE_MS = 450;
export const SEARCH_MIN_LENGTH = 2; export const SEARCH_MIN_LENGTH = 2;
export const INFINITE_SCROLL_ROOT_MARGIN = '300px'; export const INFINITE_SCROLL_ROOT_MARGIN = '300px';
export const ALBUMS_PAGE_SIZE = 50; export const ALBUMS_PAGE_SIZE = 50;
export const ALBUM_TRACKS_PAGE_SIZE = 200; export const ALBUM_TRACKS_PAGE_SIZE = 200;
export const ALBUM_EARLIEST_YEAR = 1877; export const 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 PLAYLISTS_PAGE_SIZE = 50;
export const PLAYLIST_SUMMARY_TRACKS_PAGE_SIZE = 500;
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_LATEST_PAGE_SIZE = 10;
export const HOME_MIXES_LIMIT = 6; export const HOME_MIXES_LIMIT = 6;
export const HOME_SHELF_LIMIT = 6; export const HOME_SHELF_LIMIT = 6;
export const HOME_CONTINUE_LIMIT = 10; export const HOME_CONTINUE_LIMIT = 10;
export const HOME_SPOTLIGHT_TRACKS_LIMIT = 5; export const HOME_SPOTLIGHT_TRACKS_LIMIT = 5;
export const HOME_POPULAR_MAX = 8;
export const PLAYLIST_PICKER_PAGE_SIZE = 50; export const PLAYLIST_PICKER_PAGE_SIZE = 50;
export const LIKED_TRACKS_PAGE_SIZE = 500; export const LIKED_TRACKS_PAGE_SIZE = 500;
export const EXPLORE_USERS_LIMIT = 12; export const EXPLORE_USERS_LIMIT = 12;
+9 -5
View File
@@ -1,8 +1,12 @@
import { toQueueItems, type ApiTrackLike, type QueueItem } from '$lib/player/player.svelte'; import type { Paged, Track } from '$lib/types';
export async function fetchAlbumQueueItems(albumId: string): Promise<QueueItem[]> { async function fetchTracks(url: string): Promise<Track[]> {
const res = await fetch(`/api/albums/${albumId}/tracks`); const res = await fetch(url);
if (!res.ok) return []; if (!res.ok) return [];
const data = (await res.json()) as { items: ApiTrackLike[] }; return ((await res.json()) as Paged<Track>).items;
return toQueueItems(data.items ?? []);
} }
export const fetchAlbumTracks = (albumId: string) => fetchTracks(`/api/albums/${albumId}/tracks`);
export const fetchPlaylistTracks = (playlistId: string) =>
fetchTracks(`/api/playlists/${playlistId}/tracks`);
@@ -0,0 +1,23 @@
import { describe, expect, it } from 'vitest';
import { appendUnique, shuffle } from './collections';
describe('appendUnique', () => {
const keyOf = (item: { id: number }) => item.id;
it('appends only unseen keys', () => {
const result = appendUnique([{ id: 1 }], [{ id: 1 }, { id: 2 }, { id: 2 }, { id: 3 }], keyOf);
expect(result).toEqual([{ id: 1 }, { id: 2 }, { id: 3 }]);
});
it('returns the same array when nothing is new', () => {
const current = [{ id: 1 }];
expect(appendUnique(current, [{ id: 1 }], keyOf)).toBe(current);
});
});
describe('shuffle', () => {
it('keeps the same elements and does not mutate the input', () => {
const input = [1, 2, 3, 4, 5, 6];
const result = shuffle(input);
expect([...result].sort()).toEqual(input);
expect(input).toEqual([1, 2, 3, 4, 5, 6]);
});
});
+28
View File
@@ -0,0 +1,28 @@
import { describe, expect, it } from 'vitest';
import { findConflict, genreHue } from './genres';
describe('genreHue', () => {
it('is stable and within 0-359', () => {
for (const genre of ['rock', 'something-unknown', 'ñandú']) {
const hue = genreHue(genre);
expect(hue).toBe(genreHue(genre));
expect(hue).toBeGreaterThanOrEqual(0);
expect(hue).toBeLessThan(360);
}
});
});
describe('findConflict', () => {
const options = [
{ genre: 'a', incompatibleWith: ['b'] },
{ genre: 'b', incompatibleWith: ['a'] },
{ genre: 'c', incompatibleWith: [] }
];
it('returns the first incompatible pair', () => {
expect(findConflict(['c', 'a', 'b'], options)).toEqual(['a', 'b']);
});
it('returns undefined when compatible or unknown', () => {
expect(findConflict(['a', 'c'], options)).toBeUndefined();
expect(findConflict(['x', 'y'], options)).toBeUndefined();
});
});
-19
View File
@@ -1,19 +0,0 @@
import type { components } from '$lib/api/schema';
import type { ApiTrackLike } from '$lib/player/player.svelte';
export type Mix = components['schemas']['MixApplicationResponse'];
export type MixItem = components['schemas']['MixItemApplicationResponse'];
export function mixItemKey(item: MixItem): string {
return `mf:${item.trackId}`;
}
export function mixItemTrack(item: MixItem): ApiTrackLike {
return {
id: item.trackId,
title: item.title,
artist: item.artist,
duration: item.durationSeconds,
listensCount: item.listensCount
};
}
+8 -15
View File
@@ -1,21 +1,14 @@
import { player, toQueueItems, type ApiTrackLike, type QueueItem } from '$lib/player/player.svelte'; import { player } from '$lib/player/player.svelte';
import type { Track } from '$lib/types';
export function mergeRecentlyPlayed(history: ApiTrackLike[], limit: number): QueueItem[] { export function mergeRecentlyPlayed(history: Track[], limit: number): Track[] {
const seen = new Set<string>(); const seen = new Set<string>();
const merged: QueueItem[] = []; const merged: Track[] = [];
for (const t of player.recentlyPlayed) { for (const track of [...player.recentlyPlayed, ...history]) {
const id = String(t.id); if (seen.has(track.id)) continue;
if (seen.has(id)) continue; seen.add(track.id);
seen.add(id); merged.push(track);
merged.push({ ...t, id });
}
for (const item of toQueueItems(history)) {
const id = String(item.id);
if (seen.has(id)) continue;
seen.add(id);
merged.push({ ...item, id });
} }
return merged.slice(0, limit); return merged.slice(0, limit);
+28
View File
@@ -0,0 +1,28 @@
import { describe, expect, it } from 'vitest';
import { findTopResult } from './search';
const empty = { tracks: [], albums: [], playlists: [], users: [] };
describe('findTopResult', () => {
it('prefers prefix matches by kind: user, track, album, playlist', () => {
const result = findTopResult({
query: 'Ab',
users: [{ name: 'Zed' }, { name: 'abel' }],
tracks: [{ title: 'Abc' }],
albums: [{ title: 'Abd' }],
playlists: [{ name: 'Abe' }]
});
expect(result).toEqual({ kind: 'user', user: { name: 'abel' } });
});
it('matches case-insensitively and ignores surrounding spaces', () => {
const result = findTopResult({ ...empty, query: ' SO ', tracks: [{ title: 'song' }] });
expect(result).toEqual({ kind: 'track', track: { title: 'song' } });
});
it('falls back to the first available result', () => {
const result = findTopResult({ ...empty, query: 'zzz', albums: [{ title: 'Other' }] });
expect(result).toEqual({ kind: 'album', album: { title: 'Other' } });
});
it('returns null without results', () => {
expect(findTopResult({ ...empty, query: 'x' })).toBeNull();
});
});
+12 -41
View File
@@ -1,24 +1,4 @@
import { toQueueItems, type ApiTrackLike } from './player.svelte'; import type { LikedTrack, Track } from '$lib/types';
export interface LikedTrack {
id: string;
title: string;
artist?: string;
explicit?: boolean;
ownerUserId?: string | number | null;
listensCount?: number | string;
duration?: number | string;
likedAt: number;
}
type LikeToggleInput = {
id: string | number;
title: string;
artist?: string;
explicit?: boolean;
ownerUserId?: string | number | null;
duration?: number | string;
};
class LikedStore { class LikedStore {
entries = $state<Record<string, LikedTrack>>({}); entries = $state<Record<string, LikedTrack>>({});
@@ -27,35 +7,23 @@ class LikedStore {
count = $derived(Object.keys(this.entries).length); count = $derived(Object.keys(this.entries).length);
list = $derived(Object.values(this.entries).sort((a, b) => b.likedAt - a.likedAt)); list = $derived(Object.values(this.entries).sort((a, b) => b.likedAt - a.likedAt));
hydrate(tracks: (ApiTrackLike & { createdAt: string; duration: number | string })[]) { hydrate(tracks: LikedTrack[]) {
if (this.hydrated) return; if (this.hydrated) return;
this.hydrated = true; this.hydrated = true;
this.entries = Object.fromEntries(tracks.map((track) => [track.id, track]));
const items = toQueueItems(tracks);
const next: Record<string, LikedTrack> = {};
items.forEach((item, i) => {
const id = String(item.id);
next[id] = {
...item,
id,
duration: tracks[i].duration,
likedAt: new Date(tracks[i].createdAt).getTime()
};
});
this.entries = next;
} }
isLiked(id: string | number): boolean { isLiked(id: string): boolean {
return String(id) in this.entries; return id in this.entries;
} }
async toggle(track: LikeToggleInput) { async toggle(track: Track) {
const id = String(track.id); const id = track.id;
const before = this.entries; const before = this.entries;
const wasLiked = id in before; const wasLiked = id in before;
const next = { ...before }; const next = { ...before };
if (wasLiked) delete next[id]; if (wasLiked) delete next[id];
else next[id] = { ...track, id, likedAt: Date.now() }; else next[id] = { ...track, likedAt: Date.now() };
this.entries = next; this.entries = next;
try { try {
@@ -66,7 +34,10 @@ class LikedStore {
}); });
if (!res.ok) throw new Error('Failed to toggle like'); if (!res.ok) throw new Error('Failed to toggle like');
} catch { } catch {
this.entries = before; const reverted = { ...this.entries };
if (wasLiked) reverted[id] = before[id];
else delete reverted[id];
this.entries = reverted;
} }
} }
} }
+69 -187
View File
@@ -1,85 +1,24 @@
import { browser } from '$app/environment'; import { browser } from '$app/environment';
import { DEFAULT_ACCENT } from '$lib/theme/color'; import { DEFAULT_ACCENT } from '$lib/theme/color';
import { extractAccent, type Accent } from '$lib/theme/palette'; import { extractAccent } from '$lib/theme/palette';
import { shuffle } from '$lib/data/collections'; 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 { ListenTracker } from './listenTracker';
import { readStorage, writeStorage } from '$lib/utils/storage';
import type { Track } from '$lib/types';
import { append, insertNext, isLastIndex, move, nextIndex, previousIndex } from './queue';
export interface PlayerTrack { export function isQueueCurrent(items: { id: string }[]): boolean {
id: string | number; return items.some((item) => item.id === player.currentId);
title: string;
artist: string;
duration: number;
explicit?: boolean;
ownerUserId?: string | number | null;
listensCount?: number | string;
} }
export interface Playlist { export function playAllOrToggle(items: Track[]) {
id: string | number;
name: string;
trackIds: (string | number)[];
}
export interface QueueItem {
id: string | number;
title: string;
artist?: string;
duration?: number | string;
explicit?: boolean;
ownerUserId?: string | number | null;
listensCount?: number | string;
}
export interface ApiTrackLike {
id: string | number;
title: string;
artist?: string | null;
duration?: number | string;
isExplicit?: boolean;
ownerUserId?: string | number | null;
listensCount?: number | string;
}
export function toQueueItems(tracks: ApiTrackLike[]): QueueItem[] {
return tracks.map((track) => ({
id: track.id,
title: track.title,
artist: track.artist ?? undefined,
duration: track.duration,
explicit: track.isExplicit,
ownerUserId: track.ownerUserId,
listensCount: track.listensCount
}));
}
export function toQueueItem(track: ApiTrackLike): QueueItem {
return toQueueItems([track])[0];
}
export function trackFromQueueItem(item: QueueItem): ApiTrackLike {
return {
id: item.id,
title: item.title,
artist: item.artist,
duration: item.duration,
isExplicit: item.explicit,
ownerUserId: item.ownerUserId,
listensCount: item.listensCount
};
}
export function isQueueCurrent(items: { id: string | number }[]): boolean {
return items.some((item) => item.id === player.current.id);
}
export function playAllOrToggle(items: QueueItem[]) {
if (items.length === 0) return; if (items.length === 0) return;
if (isQueueCurrent(items)) player.toggle(); if (isQueueCurrent(items)) player.toggle();
else player.playQueue(items, 0); else player.playQueue(items, 0);
} }
export function playShuffled(items: QueueItem[]) { export function playShuffled(items: Track[]) {
if (items.length === 0) return; if (items.length === 0) return;
player.playQueue(shuffle(items), 0); player.playQueue(shuffle(items), 0);
} }
@@ -94,36 +33,30 @@ async function readErrorMessage(res: Response): Promise<string> {
} }
const RECENT_LIMIT = 15; const RECENT_LIMIT = 15;
const DEFAULT_VOLUME = 100;
const VOLUME_KEY = 'musify.player.volume';
const MUTED_KEY = 'musify.player.muted';
const EMPTY: PlayerTrack = { function clampVolume(value: number): number {
id: '', return Math.min(100, Math.max(0, Math.round(value)));
title: '', }
artist: '',
duration: 0
};
function toTrack(item: QueueItem): PlayerTrack { function loadVolume(): number {
return { const stored = readStorage(VOLUME_KEY);
id: item.id, if (stored === null || stored.trim() === '') return DEFAULT_VOLUME;
title: item.title, const parsed = Number(stored);
artist: item.artist ?? '', return Number.isFinite(parsed) ? clampVolume(parsed) : DEFAULT_VOLUME;
duration: Number(item.duration) || 0,
explicit: item.explicit,
ownerUserId: item.ownerUserId,
listensCount: item.listensCount
};
} }
class PlayerState { class PlayerState {
tracks = $state<PlayerTrack[]>([]); tracks = $state<Track[]>([]);
currentId = $state<string | number | null>(null); currentId = $state<string | null>(null);
playing = $state(false); playing = $state(false);
progress = $state(0); progress = $state(0);
volume = $state(browser ? Number(localStorage.getItem('player.volume') ?? 100) : 100); volume = $state(browser ? loadVolume() : DEFAULT_VOLUME);
muted = $state(browser ? localStorage.getItem('player.muted') === 'true' : false); muted = $state(browser ? readStorage(MUTED_KEY) === 'true' : false);
loading = $state(false); loading = $state(false);
recentlyPlayed = $state<PlayerTrack[]>([]); recentlyPlayed = $state<Track[]>([]);
playlists = $state<Playlist[]>([]);
shuffle = $state(false); shuffle = $state(false);
repeat = $state(false); repeat = $state(false);
@@ -131,20 +64,17 @@ class PlayerState {
#audio: HTMLAudioElement | null = null; #audio: HTMLAudioElement | null = null;
#loadToken = 0; #loadToken = 0;
#accentCache = new Map<string, Accent>(); #accentCache = new Map<string, string>();
#rafId: number | null = null; #rafId: number | null = null;
#listen = new ListenTracker(); #listen = new ListenTracker();
current = $derived(this.tracks.find((t) => t.id === this.currentId) ?? EMPTY); current = $derived(this.tracks.find((t) => t.id === this.currentId) ?? null);
accent = $derived(this.accentColor ?? DEFAULT_ACCENT); accent = $derived(this.accentColor ?? DEFAULT_ACCENT);
duration = $derived(this.current?.duration ?? 0);
progressPercent = $derived( progressPercent = $derived(
this.current.duration > 0 ? Math.min(100, (this.progress / this.current.duration) * 100) : 0 this.duration > 0 ? Math.min(100, (this.progress / this.duration) * 100) : 0
); );
get isPlaying() {
return this.playing;
}
#audioEl(): HTMLAudioElement | null { #audioEl(): HTMLAudioElement | null {
if (!browser) return null; if (!browser) return null;
if (this.#audio) return this.#audio; if (this.#audio) return this.#audio;
@@ -182,6 +112,10 @@ class PlayerState {
audio.play().catch(() => {}); audio.play().catch(() => {});
return; return;
} }
if (!this.shuffle && isLastIndex(this.tracks.length, this.#index())) {
this.#stopAtEnd(audio);
return;
}
this.next(); this.next();
}); });
audio.addEventListener('error', () => { audio.addEventListener('error', () => {
@@ -199,6 +133,13 @@ class PlayerState {
return audio; return audio;
} }
#stopAtEnd(audio: HTMLAudioElement) {
audio.currentTime = 0;
this.progress = 0;
this.playing = false;
this.#syncMediaSessionPlaybackState();
}
#setupMediaSession(audio: HTMLAudioElement) { #setupMediaSession(audio: HTMLAudioElement) {
if (!('mediaSession' in navigator)) return; if (!('mediaSession' in navigator)) return;
navigator.mediaSession.setActionHandler('play', () => audio.play().catch(() => {})); navigator.mediaSession.setActionHandler('play', () => audio.play().catch(() => {}));
@@ -212,15 +153,11 @@ class PlayerState {
navigator.mediaSession.playbackState = this.playing ? 'playing' : 'paused'; navigator.mediaSession.playbackState = this.playing ? 'playing' : 'paused';
} }
#syncMediaSessionMetadata(track: PlayerTrack) { #syncMediaSessionMetadata(track: Track) {
if (!browser || !('mediaSession' in navigator)) return; if (!browser || !('mediaSession' in navigator)) return;
if (!track.id) {
navigator.mediaSession.metadata = null;
return;
}
navigator.mediaSession.metadata = new MediaMetadata({ navigator.mediaSession.metadata = new MediaMetadata({
title: track.title, title: track.title,
artist: track.artist, artist: track.artist ?? '',
artwork: [{ src: `/api/tracks/${track.id}/cover?size=small`, sizes: '256x256' }] artwork: [{ src: `/api/tracks/${track.id}/cover?size=small`, sizes: '256x256' }]
}); });
} }
@@ -262,26 +199,24 @@ class PlayerState {
return this.tracks.findIndex((t) => t.id === this.currentId); return this.tracks.findIndex((t) => t.id === this.currentId);
} }
#pushRecent(track: PlayerTrack) { #pushRecent(track: Track) {
if (!track.id) return;
this.recentlyPlayed = [track, ...this.recentlyPlayed.filter((t) => t.id !== track.id)].slice( this.recentlyPlayed = [track, ...this.recentlyPlayed.filter((t) => t.id !== track.id)].slice(
0, 0,
RECENT_LIMIT RECENT_LIMIT
); );
} }
async #applyAccent(id: string | number) { async #applyAccent(id: string) {
const key = String(id); const cached = this.#accentCache.get(id);
const cached = this.#accentCache.get(key);
if (cached) { if (cached) {
this.accentColor = cached.accent; this.accentColor = cached;
return; return;
} }
const result = await extractAccent(`/api/tracks/${id}/cover?size=small`); const result = await extractAccent(`/api/tracks/${id}/cover?size=small`);
if (this.currentId !== id) return; if (this.currentId !== id) return;
if (result) { if (result) {
this.#accentCache.set(key, result); this.#accentCache.set(id, result);
this.accentColor = result.accent; this.accentColor = result;
} else { } else {
this.accentColor = null; this.accentColor = null;
} }
@@ -306,18 +241,14 @@ class PlayerState {
this.#listen.begin(listenId); this.#listen.begin(listenId);
await audio.play(); await audio.play();
this.loading = false; this.loading = false;
this.#pushRecent(this.current); if (track) this.#pushRecent(track);
} catch (exception) { } catch (err) {
console.error('playback failed', exception); console.error('playback failed', err);
if (token !== this.#loadToken) return; if (token !== this.#loadToken) return;
this.loading = false; this.loading = false;
this.playing = false; this.playing = false;
if (exception instanceof DOMException && exception.name === 'AbortError') return; if (err instanceof DOMException && err.name === 'AbortError') return;
this.#fail( this.#fail(err instanceof Error && !(err instanceof DOMException) ? err.message : undefined);
exception instanceof Error && !(exception instanceof DOMException)
? exception.message
: undefined
);
} }
} }
@@ -359,79 +290,44 @@ class PlayerState {
return `${url}${sep}t=${encodeURIComponent(ticket)}`; return `${url}${sep}t=${encodeURIComponent(ticket)}`;
} }
hydrate(item: ApiTrackLike) { hydrate(track: Track) {
if (this.currentId !== null || this.tracks.length > 0) return; if (this.currentId !== null || this.tracks.length > 0) return;
const track = toTrack(toQueueItem(item));
this.tracks = [track]; this.tracks = [track];
this.currentId = track.id; this.currentId = track.id;
this.#applyAccent(track.id); this.#applyAccent(track.id);
this.#syncMediaSessionMetadata(track); this.#syncMediaSessionMetadata(track);
} }
playlistTracks(ids: (string | number)[]): PlayerTrack[] { playQueue(list: Track[], startIndex = 0) {
return ids
.map((id) => this.tracks.find((t) => t.id === id))
.filter((t): t is PlayerTrack => Boolean(t));
}
playQueue(list: QueueItem[], startIndex = 0) {
if (list.length === 0) return; if (list.length === 0) return;
this.tracks = list.map(toTrack); this.tracks = [...list];
const start = this.tracks[startIndex] ?? this.tracks[0]; const start = this.tracks[startIndex] ?? this.tracks[0];
this.currentId = start.id; this.currentId = start.id;
this.#loadCurrent(); this.#loadCurrent();
} }
playOrToggle(list: QueueItem[], index: number) { playOrToggle(list: Track[], index: number) {
if (index < 0 || index >= list.length) return; if (index < 0 || index >= list.length) return;
if (this.currentId === list[index].id) this.toggle(); if (this.currentId === list[index].id) this.toggle();
else this.playQueue(list, index); else this.playQueue(list, index);
} }
playNextItem(item: QueueItem) { playNext(items: Track[]) {
this.playNext([item]);
}
playNext(items: QueueItem[]) {
if (items.length === 0) return; if (items.length === 0) return;
if (this.currentId === null) { if (this.currentId === null) {
this.playQueue(items, 0); this.playQueue(items, 0);
return; return;
} }
const tracks = [...this.tracks]; this.tracks = insertNext(this.tracks, this.#index(), items);
tracks.splice(this.#index() + 1, 0, ...items.map(toTrack));
this.tracks = tracks;
} }
appendToQueue(items: QueueItem[]) { appendToQueue(items: Track[]) {
if (items.length === 0) return; if (items.length === 0) return;
if (this.currentId === null) { if (this.currentId === null) {
this.playQueue(items, 0); this.playQueue(items, 0);
return; return;
} }
this.tracks = [...this.tracks, ...items.map(toTrack)]; this.tracks = append(this.tracks, items);
}
playTrack(id: string | number) {
if (this.currentId === id) {
this.toggle();
return;
}
const known =
this.tracks.find((t) => t.id === id) ?? this.recentlyPlayed.find((t) => t.id === id);
if (known && !this.tracks.some((t) => t.id === id)) {
this.tracks = [known];
}
if (!this.tracks.some((t) => t.id === id)) return;
this.currentId = id;
this.#loadCurrent();
}
playPlaylist(id: string | number) {
const playlist = this.playlists.find((p) => p.id === id);
if (!playlist) return;
const items = this.playlistTracks(playlist.trackIds);
if (items.length > 0) this.playQueue(items, 0);
} }
toggle() { toggle() {
@@ -454,19 +350,13 @@ class PlayerState {
this.muted = value; this.muted = value;
const audio = this.#audioEl(); const audio = this.#audioEl();
if (audio) audio.muted = value; if (audio) audio.muted = value;
if (browser) localStorage.setItem('player.muted', String(value)); if (browser) writeStorage(MUTED_KEY, String(value));
} }
next() { next() {
if (this.tracks.length === 0) return; if (this.tracks.length === 0) return;
const idx = this.#index(); const idx = this.#index();
let nextIdx = (idx + 1) % this.tracks.length; this.currentId = this.tracks[nextIndex(this.tracks.length, idx, this.shuffle)].id;
if (this.shuffle && this.tracks.length > 1) {
do {
nextIdx = Math.floor(Math.random() * this.tracks.length);
} while (nextIdx === idx);
}
this.currentId = this.tracks[nextIdx].id;
this.#loadCurrent(); this.#loadCurrent();
} }
@@ -490,17 +380,11 @@ class PlayerState {
} }
moveQueueItem(from: number, insertAt: number) { moveQueueItem(from: number, insertAt: number) {
if (from < 0 || from >= this.tracks.length) return; this.tracks = move(this.tracks, from, insertAt);
const target = insertAt > from ? insertAt - 1 : insertAt;
if (target === from) return;
const tracks = [...this.tracks];
const [item] = tracks.splice(from, 1);
tracks.splice(target, 0, item);
this.tracks = tracks;
} }
clearUpcoming() { clearUpcoming() {
this.tracks = this.currentId === null ? [] : [this.current]; this.tracks = this.current ? [this.current] : [];
} }
previous() { previous() {
@@ -511,26 +395,24 @@ class PlayerState {
return; return;
} }
const idx = this.#index(); const idx = this.#index();
this.currentId = this.tracks[(idx - 1 + this.tracks.length) % this.tracks.length].id; this.currentId = this.tracks[previousIndex(this.tracks.length, idx)].id;
this.#loadCurrent(); this.#loadCurrent();
} }
seekFraction(fraction: number) { seekFraction(fraction: number) {
const audio = this.#audioEl(); const audio = this.#audioEl();
const clamped = Math.min(1, Math.max(0, fraction)); const clamped = Math.min(1, Math.max(0, fraction));
const target = clamped * this.current.duration; const target = clamped * this.duration;
this.progress = target; this.progress = target;
if (audio && Number.isFinite(audio.duration)) audio.currentTime = target; if (audio && Number.isFinite(audio.duration)) audio.currentTime = target;
} }
setVolume(value: number) { setVolume(value: number) {
this.volume = Math.min(100, Math.max(0, Math.round(value))); this.volume = Number.isFinite(value) ? clampVolume(value) : this.volume;
if (this.muted && this.volume > 0) this.#applyMuted(false); if (this.muted && this.volume > 0) this.#applyMuted(false);
const audio = this.#audioEl(); const audio = this.#audioEl();
if (audio) { if (audio) audio.volume = this.volume / 100;
audio.volume = this.volume / 100; if (browser) writeStorage(VOLUME_KEY, String(this.volume));
localStorage.setItem('player.volume', String(this.volume));
}
} }
} }
+85
View File
@@ -0,0 +1,85 @@
import { describe, expect, it } from 'vitest';
import { append, insertNext, isLastIndex, move, nextIndex, previousIndex } from './queue';
const t = (id: string) => ({ id });
const ids = (queue: { id: string | number }[]) => queue.map((item) => item.id);
describe('insertNext', () => {
it('inserts right after the current track without mutating', () => {
const queue = ['a', 'b', 'c'].map(t);
expect(ids(insertNext(queue, 0, ['x', 'y'].map(t)))).toEqual(['a', 'x', 'y', 'b', 'c']);
expect(ids(queue)).toEqual(['a', 'b', 'c']);
});
it('inserts at the start when there is no current track', () => {
expect(ids(insertNext([t('a')], -1, [t('x')]))).toEqual(['x', 'a']);
});
it('moves tracks already queued instead of duplicating them', () => {
const queue = ['a', 'b', 'c', 'd'].map(t);
expect(ids(insertNext(queue, 1, [t('d'), t('a')]))).toEqual(['b', 'd', 'a', 'c']);
});
it('ignores the current track', () => {
const queue = ['a', 'b'].map(t);
expect(ids(insertNext(queue, 0, [t('a')]))).toEqual(['a', 'b']);
});
});
describe('append', () => {
it('adds new items at the end', () => {
expect(ids(append([t('a')], ['b', 'c'].map(t)))).toEqual(['a', 'b', 'c']);
});
it('skips tracks already in the queue and repeated ones', () => {
expect(ids(append(['a', 'b'].map(t), ['b', 'c', 'c'].map(t)))).toEqual(['a', 'b', 'c']);
});
it('returns the same queue when nothing is added', () => {
const queue = [t('a')];
expect(append(queue, [t('a')])).toBe(queue);
});
});
describe('move', () => {
const queue = ['a', 'b', 'c', 'd'];
it('moves an item forward using insert position semantics', () => {
expect(move(queue, 0, 3)).toEqual(['b', 'c', 'a', 'd']);
});
it('moves an item backward', () => {
expect(move(queue, 3, 1)).toEqual(['a', 'd', 'b', 'c']);
});
it('is a no-op when the target equals the origin', () => {
expect(move(queue, 1, 1)).toBe(queue);
expect(move(queue, 1, 2)).toBe(queue);
});
it('ignores out-of-range origins', () => {
expect(move(queue, -1, 0)).toBe(queue);
expect(move(queue, 4, 0)).toBe(queue);
});
});
describe('nextIndex', () => {
it('advances and wraps around', () => {
expect(nextIndex(3, 0, false)).toBe(1);
expect(nextIndex(3, 2, false)).toBe(0);
});
it('never repeats the current index when shuffling', () => {
const values = [0.4, 0.5, 0.9];
let call = 0;
expect(nextIndex(3, 1, true, () => values[call++])).toBe(2);
expect(call).toBe(3);
});
it('ignores shuffle with a single track', () => {
expect(nextIndex(1, 0, true)).toBe(0);
});
});
describe('previousIndex', () => {
it('steps back and wraps around', () => {
expect(previousIndex(3, 2)).toBe(1);
expect(previousIndex(3, 0)).toBe(2);
});
});
describe('isLastIndex', () => {
it('detects the end of the queue', () => {
expect(isLastIndex(3, 2)).toBe(true);
expect(isLastIndex(3, 1)).toBe(false);
});
});
+60
View File
@@ -0,0 +1,60 @@
interface Identified {
id: string | number;
}
export function insertNext<T extends Identified>(
queue: T[],
currentIndex: number,
items: T[]
): T[] {
const currentId = queue[currentIndex]?.id;
const incoming = items.filter((item) => item.id !== currentId);
const moving = new Set(incoming.map((item) => item.id));
const rest = queue.filter((item) => !moving.has(item.id));
const anchor = currentId === undefined ? -1 : rest.findIndex((item) => item.id === currentId);
return [...rest.slice(0, anchor + 1), ...incoming, ...rest.slice(anchor + 1)];
}
export function append<T extends Identified>(queue: T[], items: T[]): T[] {
const present = new Set(queue.map((item) => item.id));
const added = items.filter((item) => {
if (present.has(item.id)) return false;
present.add(item.id);
return true;
});
return added.length > 0 ? [...queue, ...added] : queue;
}
export function move<T>(queue: T[], from: number, insertAt: number): T[] {
if (from < 0 || from >= queue.length) return queue;
const target = insertAt > from ? insertAt - 1 : insertAt;
if (target === from) return queue;
const result = [...queue];
const [item] = result.splice(from, 1);
result.splice(target, 0, item);
return result;
}
export function isLastIndex(length: number, index: number): boolean {
return index >= length - 1;
}
export function nextIndex(
length: number,
index: number,
shuffle: boolean,
random: () => number = Math.random
): number {
if (shuffle && length > 1) {
let next: number;
do {
next = Math.floor(random() * length);
} while (next === index);
return next;
}
return (index + 1) % length;
}
export function previousIndex(length: number, index: number): number {
return (index - 1 + length) % length;
}
+58 -14
View File
@@ -1,5 +1,5 @@
import createClient from 'openapi-fetch'; import createClient from 'openapi-fetch';
import { error, fail, redirect, type ActionFailure } from '@sveltejs/kit'; import { error, fail, redirect, type ActionFailure, type RequestEvent } from '@sveltejs/kit';
import type { paths } from '$lib/api/schema'; import type { paths } from '$lib/api/schema';
import type { SessionUser } from '$lib/types'; import type { SessionUser } from '$lib/types';
import { apiConfig } from '$lib/server/config'; import { apiConfig } from '$lib/server/config';
@@ -17,9 +17,18 @@ export function createApiClient({ fetch, accessToken }: ApiClientOptions) {
}); });
} }
export function requireUser(locals: App.Locals, url: URL): SessionUser { export type ApiClient = ReturnType<typeof createApiClient>;
if (!locals.user)
export function apiFor({ fetch, locals }: Pick<RequestEvent, 'fetch' | 'locals'>): ApiClient {
return createApiClient({ fetch, accessToken: locals.accessToken ?? undefined });
}
export function loginRedirect(url: URL): never {
redirect(302, `/auth?returnTo=${encodeURIComponent(url.pathname + url.search)}`); redirect(302, `/auth?returnTo=${encodeURIComponent(url.pathname + url.search)}`);
}
export function requireUser(locals: App.Locals, url: URL): SessionUser {
if (!locals.user) loginRedirect(url);
return locals.user; return locals.user;
} }
@@ -29,16 +38,42 @@ export function optionalUser(
allowAnonymousListening: boolean allowAnonymousListening: boolean
): SessionUser | null { ): SessionUser | null {
if (locals.user) return locals.user; if (locals.user) return locals.user;
if (!allowAnonymousListening) if (!allowAnonymousListening) loginRedirect(url);
redirect(302, `/auth?returnTo=${encodeURIComponent(url.pathname + url.search)}`);
return null; return null;
} }
export function requireAccessTokenAction( export function formString(form: FormData, key: string): string {
locals: App.Locals, return String(form.get(key) ?? '');
message = 'Inicia sesión.' }
): string | ActionFailure<{ message: string }> {
return locals.accessToken ?? fail(401, { message }); export function formFile(form: FormData, key: string): File | null {
const value = form.get(key);
return value instanceof File && value.size > 0 ? value : null;
}
interface ActionContext {
api: ApiClient;
form: FormData;
params: Record<string, string>;
locals: App.Locals;
url: URL;
}
export function authedAction<R>(
handler: (context: ActionContext) => R | Promise<R>,
unauthorizedMessage = 'Inicia sesión.'
) {
return async (event: RequestEvent) => {
if (!event.locals.accessToken) return fail(401, { message: unauthorizedMessage });
const form = await event.request.formData();
return handler({
api: apiFor(event),
form,
params: event.params as Record<string, string>,
locals: event.locals,
url: event.url
});
};
} }
export function unwrapOrError<T>( export function unwrapOrError<T>(
@@ -70,9 +105,18 @@ export function apiErrorDetail(error: unknown): string | undefined {
return undefined; return undefined;
} }
export function unwrapOrFail( type Failure = ActionFailure<{ message: string; detail?: string }>;
result: { error?: unknown },
message: string export function failOnError(result: { error?: unknown }, message: string): Failure | undefined {
): ActionFailure<{ message: string; detail?: string }> | undefined {
return result.error ? fail(502, { message, detail: apiErrorDetail(result.error) }) : undefined; return result.error ? fail(502, { message, detail: apiErrorDetail(result.error) }) : undefined;
} }
export function requireData<T>(
result: { data?: T; error?: unknown },
message: string
): { data: T; failure?: undefined } | { data?: undefined; failure: Failure } {
if (result.error || result.data === undefined) {
return { failure: fail(502, { message, detail: apiErrorDetail(result.error) }) };
}
return { data: result.data };
}
+21
View File
@@ -0,0 +1,21 @@
import { describe, expect, it } from 'vitest';
import { safeReturnTo } from './auth';
describe('safeReturnTo', () => {
it('keeps local paths', () => {
expect(safeReturnTo('/')).toBe('/');
expect(safeReturnTo('/playlists/1?tab=x#top')).toBe('/playlists/1?tab=x#top');
});
it('falls back to / for missing values', () => {
expect(safeReturnTo(null)).toBe('/');
expect(safeReturnTo(undefined)).toBe('/');
expect(safeReturnTo('')).toBe('/');
});
it('rejects absolute and protocol-relative URLs', () => {
expect(safeReturnTo('https://example.com')).toBe('/');
expect(safeReturnTo('//example.com')).toBe('/');
expect(safeReturnTo('/\\example.com')).toBe('/');
expect(safeReturnTo('javascript:alert(1)')).toBe('/');
expect(safeReturnTo('relative/path')).toBe('/');
});
});
+12 -5
View File
@@ -18,6 +18,13 @@ export interface Session extends SessionUser {
expiresAt: number; expiresAt: number;
} }
export function safeReturnTo(value: string | null | undefined): string {
if (!value || !value.startsWith('/') || value.startsWith('//') || value.startsWith('/\\')) {
return '/';
}
return value;
}
let configPromise: Promise<client.Configuration> | undefined; let configPromise: Promise<client.Configuration> | undefined;
export function getOidcConfig(): Promise<client.Configuration> { export function getOidcConfig(): Promise<client.Configuration> {
configPromise ??= (async () => { configPromise ??= (async () => {
@@ -29,9 +36,9 @@ export function getOidcConfig(): Promise<client.Configuration> {
return secret return secret
? client.discovery(issuer, authConfig.clientId, secret, undefined, options) ? client.discovery(issuer, authConfig.clientId, secret, undefined, options)
: client.discovery(issuer, authConfig.clientId, undefined, client.None(), options); : client.discovery(issuer, authConfig.clientId, undefined, client.None(), options);
})().catch((exception) => { })().catch((err) => {
configPromise = undefined; configPromise = undefined;
throw exception; throw err;
}); });
return configPromise; return configPromise;
} }
@@ -44,7 +51,7 @@ function sessionKey(): Promise<Uint8Array> {
return keyPromise; return keyPromise;
} }
export function sessionCookieOptions(url: URL) { function sessionCookieOptions(url: URL) {
return { return {
httpOnly: true, httpOnly: true,
sameSite: 'lax' as const, sameSite: 'lax' as const,
@@ -144,8 +151,8 @@ export async function refreshSession(session: Session): Promise<Session | null>
idToken: tokens.id_token ?? session.idToken, idToken: tokens.id_token ?? session.idToken,
expiresAt: Math.floor(Date.now() / 1000) + (tokens.expires_in ?? 3600) expiresAt: Math.floor(Date.now() / 1000) + (tokens.expires_in ?? 3600)
}; };
} catch (exception) { } catch (err) {
console.error('Token refresh failed', exception); console.error('Token refresh failed', err);
return null; return null;
} }
} }
+8 -1
View File
@@ -1,6 +1,7 @@
import { env } from '$env/dynamic/private'; import { env } from '$env/dynamic/private';
const DAY_SECONDS = 60 * 60 * 24; const DAY_SECONDS = 60 * 60 * 24;
const MIN_SESSION_SECRET_LENGTH = 32;
const DEFAULT_OIDC_SCOPE = 'openid profile email offline_access'; const DEFAULT_OIDC_SCOPE = 'openid profile email offline_access';
function positiveInt(value: string | undefined, fallback: number): number { function positiveInt(value: string | undefined, fallback: number): number {
@@ -35,7 +36,13 @@ export const authConfig = {
return required('AUTH_POST_LOGOUT_URI', env.AUTH_POST_LOGOUT_URI); return required('AUTH_POST_LOGOUT_URI', env.AUTH_POST_LOGOUT_URI);
}, },
get sessionSecret() { get sessionSecret() {
return env.SESSION_SECRET; const secret = required('SESSION_SECRET', env.SESSION_SECRET);
if (secret.length < MIN_SESSION_SECRET_LENGTH) {
throw new Error(
`SESSION_SECRET must be at least ${MIN_SESSION_SECRET_LENGTH} characters long.`
);
}
return secret;
}, },
get sessionTtlSeconds() { get sessionTtlSeconds() {
return positiveInt(env.SESSION_TTL_SECONDS, 7 * DAY_SECONDS); return positiveInt(env.SESSION_TTL_SECONDS, 7 * DAY_SECONDS);
+8 -12
View File
@@ -1,15 +1,11 @@
import { fail, type RequestEvent } from '@sveltejs/kit'; import { fail } from '@sveltejs/kit';
import { createApiClient, requireAccessTokenAction } from '$lib/server/api'; import { authedAction } from '$lib/server/api';
async function setFollowing({ request, params, locals, fetch }: RequestEvent, follow: boolean) { const setFollowing = (follow: boolean) =>
const accessToken = requireAccessTokenAction(locals); authedAction(async ({ api, params }) => {
if (typeof accessToken !== 'string') return accessToken; const id = params.id ?? '';
const form = await request.formData();
const id = String(form.get('userId') ?? '') || String(params.id ?? '');
if (!id) return fail(400, { message: 'Falta el usuario.' }); if (!id) return fail(400, { message: 'Falta el usuario.' });
const api = createApiClient({ fetch, accessToken });
const options = { params: { path: { id } } }; const options = { params: { path: { id } } };
const { error } = follow const { error } = follow
? await api.POST('/users/{id}/follow', options) ? await api.POST('/users/{id}/follow', options)
@@ -22,7 +18,7 @@ async function setFollowing({ request, params, locals, fetch }: RequestEvent, fo
} }
return { following: follow, userId: id }; return { following: follow, userId: id };
} });
export const followUserAction = (event: RequestEvent) => setFollowing(event, true); export const followUserAction = setFollowing(true);
export const unfollowUserAction = (event: RequestEvent) => setFollowing(event, false); export const unfollowUserAction = setFollowing(false);
+1 -3
View File
@@ -1,8 +1,6 @@
import type { createApiClient } from '$lib/server/api'; import type { ApiClient } from '$lib/server/api';
import type { FollowList } from '$lib/types'; import type { FollowList } from '$lib/types';
type ApiClient = ReturnType<typeof createApiClient>;
export function fetchFollowList( export function fetchFollowList(
api: ApiClient, api: ApiClient,
list: FollowList, list: FollowList,
@@ -0,0 +1,36 @@
import { describe, expect, it } from 'vitest';
import { ALBUM_EARLIEST_YEAR } from '$lib/validation';
import { parseAlbumForm } from './albumForm';
function form(fields: Record<string, string>): FormData {
const data = new FormData();
for (const [key, value] of Object.entries(fields)) data.set(key, value);
return data;
}
describe('parseAlbumForm', () => {
it('parses a valid form and trims values', () => {
expect(
parseAlbumForm(form({ title: ' Title ', description: ' d ', releaseYear: '2020' }))
).toEqual({ body: { title: 'Title', description: 'd', releaseYear: 2020 } });
});
it('turns empty optional fields into null', () => {
expect(parseAlbumForm(form({ title: 'T' }))).toEqual({
body: { title: 'T', description: null, releaseYear: null }
});
});
it('rejects empty or too long titles', () => {
expect(parseAlbumForm(form({ title: ' ' }))).toHaveProperty('failMessage');
expect(parseAlbumForm(form({ title: 'x'.repeat(201) }))).toHaveProperty('failMessage');
});
it('rejects too long descriptions', () => {
expect(parseAlbumForm(form({ title: 'T', description: 'x'.repeat(257) }))).toHaveProperty(
'failMessage'
);
});
it('rejects invalid years', () => {
for (const releaseYear of ['abc', '2020.5', String(ALBUM_EARLIEST_YEAR - 1), '9999']) {
expect(parseAlbumForm(form({ title: 'T', releaseYear }))).toHaveProperty('failMessage');
}
});
});
@@ -1,4 +1,5 @@
import { ALBUM_EARLIEST_YEAR } from '$lib/config'; import { formString } from '$lib/server/api';
import { ALBUM_EARLIEST_YEAR, LIMITS } from '$lib/validation';
interface AlbumFormBody { interface AlbumFormBody {
title: string; title: string;
@@ -7,17 +8,19 @@ interface AlbumFormBody {
} }
export function parseAlbumForm(form: FormData): { body: AlbumFormBody } | { failMessage: string } { export function parseAlbumForm(form: FormData): { body: AlbumFormBody } | { failMessage: string } {
const title = String(form.get('title') ?? '').trim(); const title = formString(form, 'title').trim();
if (title === '' || title.length > 200) { if (title === '' || title.length > LIMITS.albumTitle) {
return { failMessage: 'El título es obligatorio (máx. 200 caracteres).' }; return { failMessage: `El título es obligatorio (máx. ${LIMITS.albumTitle} caracteres).` };
} }
const description = String(form.get('description') ?? '').trim(); const description = formString(form, 'description').trim();
if (description.length > 256) { if (description.length > LIMITS.albumDescription) {
return { failMessage: 'La descripción no puede pasar de 256 caracteres.' }; return {
failMessage: `La descripción no puede pasar de ${LIMITS.albumDescription} caracteres.`
};
} }
const rawYear = String(form.get('releaseYear') ?? '').trim(); const rawYear = formString(form, 'releaseYear').trim();
const latestYear = new Date().getFullYear() + 1; const latestYear = new Date().getFullYear() + 1;
let releaseYear: number | null = null; let releaseYear: number | null = null;
@@ -0,0 +1,34 @@
import { describe, expect, it } from 'vitest';
import { LIMITS } from '$lib/validation';
import { parsePlaylistForm } from './playlistForm';
function form(fields: Record<string, string>): FormData {
const data = new FormData();
for (const [key, value] of Object.entries(fields)) data.set(key, value);
return data;
}
describe('parsePlaylistForm', () => {
it('parses a valid form', () => {
expect(
parsePlaylistForm(form({ name: ' Mix ', description: ' d ', visibility: 'public' }))
).toEqual({ body: { name: 'Mix', description: 'd', visibility: 'Public', cover: null } });
});
it('defaults to private', () => {
const parsed = parsePlaylistForm(form({ name: 'Mix' }));
expect(parsed).toMatchObject({ body: { visibility: 'Private', description: '' } });
});
it('rejects empty or too long names keeping the typed values', () => {
expect(parsePlaylistForm(form({ name: ' ', description: 'd' }))).toMatchObject({
failMessage: expect.any(String),
description: 'd'
});
expect(parsePlaylistForm(form({ name: 'x'.repeat(LIMITS.playlistName + 1) }))).toHaveProperty(
'failMessage'
);
});
it('rejects too long descriptions', () => {
const description = 'x'.repeat(LIMITS.playlistDescription + 1);
expect(parsePlaylistForm(form({ name: 'Mix', description }))).toHaveProperty('failMessage');
});
});
@@ -0,0 +1,42 @@
import { formFile, formString } from '$lib/server/api';
import { LIMITS } from '$lib/validation';
interface PlaylistFormBody {
name: string;
description: string;
visibility: 'Public' | 'Private';
cover: File | null;
}
export type PlaylistFormFailure = { failMessage: string; name: string; description: string };
export function parsePlaylistForm(
form: FormData
): { body: PlaylistFormBody } | PlaylistFormFailure {
const name = formString(form, 'name').trim();
const description = formString(form, 'description').trim();
if (name === '' || name.length > LIMITS.playlistName) {
return {
failMessage: `El nombre es obligatorio (máx. ${LIMITS.playlistName} caracteres).`,
name,
description
};
}
if (description.length > LIMITS.playlistDescription) {
return {
failMessage: `La descripción no puede pasar de ${LIMITS.playlistDescription} caracteres.`,
name,
description
};
}
return {
body: {
name,
description,
visibility: formString(form, 'visibility') === 'public' ? 'Public' : 'Private',
cover: formFile(form, 'cover')
}
};
}
@@ -0,0 +1,36 @@
import { describe, expect, it } from 'vitest';
import { LIMITS } from '$lib/validation';
import { parseTrackUploadForm } from './trackUploadForm';
function form(fields: Record<string, string | string[] | File>): FormData {
const data = new FormData();
for (const [key, value] of Object.entries(fields)) {
if (Array.isArray(value)) value.forEach((item) => data.append(key, item));
else data.set(key, value);
}
return data;
}
const audio = new File(['a'], 'a.mp3');
const cover = new File(['c'], 'c.png');
describe('parseTrackUploadForm', () => {
it('parses a valid form and deduplicates tags', () => {
const parsed = parseTrackUploadForm(
form({ title: ' Song ', audio, cover, tags: ['rock', 'rock', 'pop'], isExplicit: 'on' })
);
expect(parsed).toMatchObject({
body: { title: 'Song', tags: ['rock', 'pop'], isExplicit: true }
});
});
it('validates each field', () => {
const ok = { title: 'Song', audio, cover, tags: ['rock'] };
expect(parseTrackUploadForm(form({ ...ok, title: '' }))).toHaveProperty('failMessage');
expect(
parseTrackUploadForm(form({ ...ok, title: 'x'.repeat(LIMITS.trackTitle + 1) }))
).toHaveProperty('failMessage');
expect(parseTrackUploadForm(form({ ...ok, audio: '' }))).toHaveProperty('failMessage');
expect(parseTrackUploadForm(form({ ...ok, cover: '' }))).toHaveProperty('failMessage');
expect(parseTrackUploadForm(form({ ...ok, tags: [] }))).toHaveProperty('failMessage');
});
});
@@ -0,0 +1,30 @@
import { formFile, formString } from '$lib/server/api';
import { LIMITS } from '$lib/validation';
interface TrackUploadBody {
title: string;
audio: File;
cover: File;
tags: string[];
isExplicit: boolean;
}
export function parseTrackUploadForm(
form: FormData
): { body: TrackUploadBody } | { failMessage: string } {
const title = formString(form, 'title').trim();
if (title === '' || title.length > LIMITS.trackTitle) {
return { failMessage: `El título es obligatorio (máx. ${LIMITS.trackTitle} caracteres).` };
}
const audio = formFile(form, 'audio');
if (!audio) return { failMessage: 'Selecciona un archivo de audio.' };
const cover = formFile(form, 'cover');
if (!cover) return { failMessage: 'Selecciona una portada.' };
const tags = [...new Set(form.getAll('tags').map(String))];
if (tags.length === 0) return { failMessage: 'Elige al menos un género.' };
return { body: { title, audio, cover, tags, isExplicit: form.get('isExplicit') === 'on' } };
}
@@ -0,0 +1,22 @@
import { describe, expect, it } from 'vitest';
import { pickGreeting } from './greeting';
const BIRTHDAY = 'Hoy toca celebrar. Tú eliges la música.';
describe('pickGreeting', () => {
it('uses the birthday message over special dates', () => {
const date = new Date(2026, 11, 25, 10);
expect(pickGreeting(date, { month: 12, day: 25 })).toBe(BIRTHDAY);
});
it('uses the special date message', () => {
expect(pickGreeting(new Date(2026, 11, 25, 10))).toBe('Feliz Navidad. Pon tu banda sonora.');
});
it('returns a non-empty schedule message at every hour', () => {
for (let hour = 0; hour < 24; hour++) {
expect(pickGreeting(new Date(2026, 2, 10, hour)).length).toBeGreaterThan(0);
}
});
it('ignores a birthday on another day', () => {
expect(pickGreeting(new Date(2026, 2, 10, 10), { month: 1, day: 2 })).not.toBe(BIRTHDAY);
});
});
+3 -1
View File
@@ -10,7 +10,9 @@ export async function proxyCoverImage(
): Promise<Response> { ): Promise<Response> {
const safeSize = size && ALLOWED_SIZES.has(size) ? size : 'medium'; const safeSize = size && ALLOWED_SIZES.has(size) ? size : 'medium';
const upstream = await fetch(`${apiConfig.baseUrl}/${kind}/${id}/cover?size=${safeSize}`); const upstream = await fetch(
`${apiConfig.baseUrl}/${kind}/${encodeURIComponent(id)}/cover?size=${safeSize}`
);
if (!upstream.ok) { if (!upstream.ok) {
return new Response(null, { status: upstream.status }); return new Response(null, { status: upstream.status });
+139
View File
@@ -0,0 +1,139 @@
import { describe, expect, it } from 'vitest';
import type { components } from '$lib/api/schema';
import {
toAlbum,
toDatedTrack,
toLikedTrack,
toMix,
toPage,
toPlaylist,
toPlaylistSummary,
toTrack
} from './mappers';
const trackDto: components['schemas']['TrackApplicationResponse'] = {
id: 't1',
title: 'Song',
artist: null,
audioStatus: 'Completed',
duration: '183.5',
listensCount: '12',
createdAt: '2026-01-02T00:00:00Z',
updatedAt: '2026-01-03T00:00:00Z',
ownerUserId: '42',
tags: [],
isExplicit: true
};
describe('toTrack', () => {
it('normalizes numeric strings and renames isExplicit', () => {
expect(toTrack(trackDto)).toEqual({
id: 't1',
title: 'Song',
artist: null,
ownerUserId: '42',
explicit: true,
duration: 183.5,
listensCount: 12
});
});
it('falls back to 0 for invalid numbers', () => {
const track = toTrack({ ...trackDto, duration: 'x', listensCount: 'y' });
expect(track.duration).toBe(0);
expect(track.listensCount).toBe(0);
});
});
describe('toDatedTrack and toLikedTrack', () => {
it('keep the creation date', () => {
expect(toDatedTrack(trackDto).date).toBe('2026-01-02T00:00:00Z');
expect(toLikedTrack(trackDto).likedAt).toBe(Date.parse('2026-01-02T00:00:00Z'));
});
});
describe('toMix', () => {
it('maps items into tracks', () => {
const mix = toMix({
id: 'm1',
title: 'Mix',
subtitle: null,
itemCount: 1,
items: [{ trackId: 't1', title: 'Song', artist: 'A', durationSeconds: '60', listensCount: 3 }]
});
expect(mix.tracks).toEqual([
{
id: 't1',
title: 'Song',
artist: 'A',
ownerUserId: null,
explicit: false,
duration: 60,
listensCount: 3
}
]);
});
});
describe('toAlbum', () => {
const dto: components['schemas']['AlbumApplicationResponse'] = {
id: 'a1',
title: 'Album',
description: null,
releaseYear: '2020',
ownerUserId: '42',
trackCount: '5',
smallImageKeyName: null,
mediumImageKeyName: null,
largeImageKeyName: null,
createdAt: '',
updatedAt: 'u',
coverTrackIds: ['t1']
};
it('normalizes year and count', () => {
expect(toAlbum(dto)).toMatchObject({ releaseYear: 2020, trackCount: 5, updatedAt: 'u' });
});
it('keeps a missing year as null', () => {
expect(toAlbum({ ...dto, releaseYear: null }).releaseYear).toBeNull();
});
});
describe('toPlaylist', () => {
const dto: components['schemas']['PlayListApplicationResponse'] = {
id: 'p1',
name: 'List',
description: 'd',
smallImageKeyName: null,
mediumImageKeyName: null,
largeImageKeyName: null,
ownerUserId: '42',
visibility: 'Public',
createdAt: '',
updatedAt: 'u',
coverTrackIds: []
};
it('drops storage keys', () => {
expect(toPlaylist(dto)).toEqual({
id: 'p1',
name: 'List',
description: 'd',
visibility: 'Public',
ownerUserId: '42',
coverTrackIds: [],
updatedAt: 'u'
});
});
it('adds the track count in summaries', () => {
expect(toPlaylistSummary(dto, 7).trackCount).toBe(7);
});
});
describe('toPage', () => {
it('maps items and normalizes numbers', () => {
expect(
toPage(
{ items: [1, 2], pageNumber: '2', hasNextPage: true, totalItemCount: '10' },
(n) => n * 2
)
).toEqual({ items: [2, 4], pageNumber: 2, hasNextPage: true, totalItemCount: 10 });
});
});
+94
View File
@@ -0,0 +1,94 @@
import type { components } from '$lib/api/schema';
import type { Album, LikedTrack, Mix, Paged, Playlist, PlaylistSummary, Track } from '$lib/types';
type TrackDto = components['schemas']['TrackApplicationResponse'];
type AlbumDto = components['schemas']['AlbumApplicationResponse'];
type PlaylistDto = components['schemas']['PlayListApplicationResponse'];
type MixDto = components['schemas']['MixApplicationResponse'];
type MixItemDto = components['schemas']['MixItemApplicationResponse'];
interface PageDto<T> {
items: T[];
pageNumber: number | string;
hasNextPage: boolean;
totalItemCount: number | string;
}
export function toTrack(dto: TrackDto): Track {
return {
id: dto.id,
title: dto.title,
artist: dto.artist,
ownerUserId: dto.ownerUserId,
explicit: dto.isExplicit,
duration: Number(dto.duration) || 0,
listensCount: Number(dto.listensCount) || 0
};
}
export function toDatedTrack(dto: TrackDto): Track & { date: string } {
return { ...toTrack(dto), date: dto.createdAt };
}
export function toLikedTrack(dto: TrackDto): LikedTrack {
return { ...toTrack(dto), likedAt: new Date(dto.createdAt).getTime() };
}
export function toMixTrack(item: MixItemDto): Track {
return {
id: item.trackId,
title: item.title,
artist: item.artist,
ownerUserId: null,
explicit: false,
duration: Number(item.durationSeconds) || 0,
listensCount: Number(item.listensCount) || 0
};
}
export function toMix(dto: MixDto): Mix {
return {
id: dto.id,
title: dto.title,
subtitle: dto.subtitle,
tracks: dto.items.map(toMixTrack)
};
}
export function toAlbum(dto: AlbumDto): Album {
return {
id: dto.id,
title: dto.title,
description: dto.description,
releaseYear: dto.releaseYear === null ? null : Number(dto.releaseYear),
ownerUserId: dto.ownerUserId,
trackCount: Number(dto.trackCount) || 0,
coverTrackIds: dto.coverTrackIds,
updatedAt: dto.updatedAt
};
}
export function toPlaylist(dto: PlaylistDto): Playlist {
return {
id: dto.id,
name: dto.name,
description: dto.description,
visibility: dto.visibility,
ownerUserId: dto.ownerUserId,
coverTrackIds: dto.coverTrackIds,
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> {
return {
items: dto.items.map(map),
pageNumber: Number(dto.pageNumber) || 1,
hasNextPage: dto.hasNextPage,
totalItemCount: Number(dto.totalItemCount) || 0
};
}
+2 -1
View File
@@ -9,7 +9,8 @@ export async function getAllowAnonymousListening(fetchFn: typeof fetch): Promise
try { try {
const api = createApiClient({ fetch: fetchFn }); const api = createApiClient({ fetch: fetchFn });
const { data } = await api.GET('/config/playback'); const { data, error } = await api.GET('/config/playback');
if (error !== undefined) return cached?.allowAnonymousListening ?? false;
const allowAnonymousListening = data?.allowAnonymousListening ?? false; const allowAnonymousListening = data?.allowAnonymousListening ?? false;
cached = { allowAnonymousListening, expiresAt: Date.now() + CACHE_TTL_MS }; cached = { allowAnonymousListening, expiresAt: Date.now() + CACHE_TTL_MS };
+17 -27
View File
@@ -1,26 +1,21 @@
import { fail, type RequestEvent } from '@sveltejs/kit'; import { fail } from '@sveltejs/kit';
import { createApiClient, requireAccessTokenAction } from '$lib/server/api'; import { authedAction, formString } from '$lib/server/api';
import { ALBUM_TRACKS_PAGE_SIZE } from '$lib/config'; import { ALBUM_TRACKS_PAGE_SIZE } from '$lib/config';
export async function addTrackAction({ request, params, locals, fetch }: RequestEvent) { export const addTrackAction = authedAction(async ({ api, form, params }) => {
const accessToken = requireAccessTokenAction(locals); const playlistId = formString(form, 'playlistId') || (params.id ?? '');
if (typeof accessToken !== 'string') return accessToken; const trackId = formString(form, 'trackId');
const form = await request.formData();
const playlistId = String(form.get('playlistId') ?? '') || String(params.id ?? '');
const trackId = String(form.get('trackId') ?? '');
if (!playlistId || !trackId) { if (!playlistId || !trackId) {
return fail(400, { message: 'Faltan datos de la canción.' }); return fail(400, { message: 'Faltan datos de la canción.' });
} }
const api = createApiClient({ fetch, accessToken }); const { error, response } = await api.POST('/playlists/{playlistId}/tracks', {
const { error: err, response } = await api.POST('/playlists/{playlistId}/tracks', {
params: { path: { playlistId } }, params: { path: { playlistId } },
body: { trackId } body: { trackId }
}); });
if (err) { if (error) {
if (response?.status === 409) { if (response?.status === 409) {
return fail(409, { message: 'La canción ya está en esa playlist.', trackId }); return fail(409, { message: 'La canción ya está en esa playlist.', trackId });
} }
@@ -28,37 +23,32 @@ export async function addTrackAction({ request, params, locals, fetch }: Request
} }
return { added: true, trackId, playlistId }; return { added: true, trackId, playlistId };
} });
export async function addAlbumToPlaylistAction({ request, params, locals, fetch }: RequestEvent) { export const addAlbumToPlaylistAction = authedAction(async ({ api, form, params }) => {
const accessToken = requireAccessTokenAction(locals); const playlistId = formString(form, 'playlistId') || (params.id ?? '');
if (typeof accessToken !== 'string') return accessToken; const albumId = formString(form, 'albumId');
const form = await request.formData();
const playlistId = String(form.get('playlistId') ?? '') || String(params.id ?? '');
const albumId = String(form.get('albumId') ?? '');
if (!playlistId || !albumId) { if (!playlistId || !albumId) {
return fail(400, { message: 'Faltan datos del álbum.' }); return fail(400, { message: 'Faltan datos del álbum.' });
} }
const api = createApiClient({ fetch, accessToken }); const { data: tracks, error: tracksError } = await api.GET('/albums/{albumId}/tracks', {
const { data: tracks, error: tracksErr } = await api.GET('/albums/{albumId}/tracks', {
params: { path: { albumId }, query: { pageNumber: 1, pageSize: ALBUM_TRACKS_PAGE_SIZE } } params: { path: { albumId }, query: { pageNumber: 1, pageSize: ALBUM_TRACKS_PAGE_SIZE } }
}); });
if (tracksErr || !tracks) { if (tracksError || !tracks) {
return fail(502, { message: 'No se pudo cargar el álbum.', albumId }); return fail(502, { message: 'No se pudo cargar el álbum.', albumId });
} }
let added = 0; let added = 0;
for (const track of tracks.items) { for (const track of tracks.items) {
const { error: err } = await api.POST('/playlists/{playlistId}/tracks', { const { error } = await api.POST('/playlists/{playlistId}/tracks', {
params: { path: { playlistId } }, params: { path: { playlistId } },
body: { trackId: String(track.id) } body: { trackId: track.id }
}); });
if (!err) added++; if (!error) added++;
} }
return { addedAlbum: true, albumId, playlistId, added }; return { addedAlbum: true, albumId, playlistId, added };
} });
+17
View File
@@ -78,6 +78,8 @@ export async function putPresigned(
}); });
} }
import type { ApiClient } from '$lib/server/api';
export type UploadImageResult = { intentId: string } | { failMessage: string; detail?: string }; export type UploadImageResult = { intentId: string } | { failMessage: string; detail?: string };
export async function uploadPresignedImage( export async function uploadPresignedImage(
@@ -109,3 +111,18 @@ export async function uploadPresignedImage(
return { intentId: reserved.intentId }; return { intentId: reserved.intentId };
} }
export async function uploadOptionalCover(
api: ApiClient,
endpoint: '/albums/upload-picture' | '/playlists/upload-picture',
file: File | null
): Promise<{ intentId: string | null } | { failMessage: string; detail?: string }> {
if (!file) return { intentId: null };
return uploadPresignedImage(
(args) =>
endpoint === '/albums/upload-picture'
? api.POST('/albums/upload-picture', { body: args })
: api.POST('/playlists/upload-picture', { body: args }),
file
);
}
+5 -4
View File
@@ -2,9 +2,10 @@ import {
contextMenuPosition, contextMenuPosition,
type MenuPosition type MenuPosition
} from '$lib/components/ui/overlay/ContextMenu.svelte'; } from '$lib/components/ui/overlay/ContextMenu.svelte';
import { player, toQueueItem, type ApiTrackLike } from '../player/player.svelte'; import { player } from '$lib/player/player.svelte';
import type { Track } from '$lib/types';
export type TrackMenuState = { track: ApiTrackLike } & MenuPosition; export type TrackMenuState = { track: Track } & MenuPosition;
export function createTrackMenu() { export function createTrackMenu() {
let state = $state<TrackMenuState | null>(null); let state = $state<TrackMenuState | null>(null);
@@ -13,7 +14,7 @@ export function createTrackMenu() {
get state() { get state() {
return state; return state;
}, },
open(event: MouseEvent, track: ApiTrackLike) { open(event: MouseEvent, track: Track) {
state = { track, ...contextMenuPosition(event) }; state = { track, ...contextMenuPosition(event) };
}, },
close() { close() {
@@ -21,7 +22,7 @@ export function createTrackMenu() {
}, },
playNext() { playNext() {
if (!state) return; if (!state) return;
player.playNextItem(toQueueItem(state.track)); player.playNext([state.track]);
state = null; state = null;
} }
}; };
+3 -10
View File
@@ -1,4 +1,5 @@
import { browser } from '$app/environment'; import { browser } from '$app/environment';
import { readStorage, writeStorage } from '$lib/utils/storage';
export type ThemeName = 'dark' | 'light'; export type ThemeName = 'dark' | 'light';
@@ -6,11 +7,7 @@ const STORAGE_KEY = 'musify.theme';
function loadInitial(): ThemeName { function loadInitial(): ThemeName {
if (!browser) return 'dark'; if (!browser) return 'dark';
try { return readStorage(STORAGE_KEY) === 'light' ? 'light' : 'dark';
return localStorage.getItem(STORAGE_KEY) === 'light' ? 'light' : 'dark';
} catch {
return 'dark';
}
} }
function applyTheme(theme: ThemeName) { function applyTheme(theme: ThemeName) {
@@ -29,11 +26,7 @@ class ThemeModeStore {
this.current = this.current === 'dark' ? 'light' : 'dark'; this.current = this.current === 'dark' ? 'light' : 'dark';
applyTheme(this.current); applyTheme(this.current);
if (!browser) return; if (!browser) return;
try { writeStorage(STORAGE_KEY, this.current);
localStorage.setItem(STORAGE_KEY, this.current);
} catch {
/* localStorage unavailable (private mode, quota) — preference just won't persist */
}
} }
} }
+2 -12
View File
@@ -1,11 +1,6 @@
import { browser } from '$app/environment'; import { browser } from '$app/environment';
import { ACCENT_LIGHTNESS } from './color'; import { ACCENT_LIGHTNESS } from './color';
export interface Accent {
accent: string;
gradient: string;
}
function srgbToLinear(channel: number): number { function srgbToLinear(channel: number): number {
const c = channel / 255; const c = channel / 255;
return c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4); return c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
@@ -45,7 +40,7 @@ function loadImage(url: string): Promise<HTMLImageElement> {
const BUCKETS = 36; const BUCKETS = 36;
export async function extractAccent(url: string): Promise<Accent | null> { export async function extractAccent(url: string): Promise<string | null> {
if (!browser) return null; if (!browser) return null;
let img: HTMLImageElement; let img: HTMLImageElement;
@@ -104,11 +99,6 @@ export async function extractAccent(url: string): Promise<Accent | null> {
const h = hue.toFixed(1); const h = hue.toFixed(1);
const c = chroma.toFixed(3); const c = chroma.toFixed(3);
const c2 = (chroma * 0.85).toFixed(3);
const h2 = ((hue + 25) % 360).toFixed(1);
return { return `oklch(${ACCENT_LIGHTNESS}% ${c} ${h})`;
accent: `oklch(${ACCENT_LIGHTNESS}% ${c} ${h})`,
gradient: `linear-gradient(135deg, oklch(74% ${c} ${h}), oklch(56% ${c2} ${h2}))`
};
} }
+53
View File
@@ -6,3 +6,56 @@ export interface SessionUser {
} }
export type FollowList = 'followers' | 'following'; export type FollowList = 'followers' | 'following';
export interface Track {
id: string;
title: string;
artist: string | null;
ownerUserId: string | null;
explicit: boolean;
duration: number;
listensCount: number;
}
export interface LikedTrack extends Track {
likedAt: number;
}
export interface Album {
id: string;
title: string;
description: string | null;
releaseYear: number | null;
ownerUserId: string;
trackCount: number;
coverTrackIds: string[];
updatedAt: string;
}
export interface Playlist {
id: string;
name: string;
description: string | null;
visibility: 'Public' | 'Private';
ownerUserId: string;
coverTrackIds: string[];
updatedAt: string;
}
export interface PlaylistSummary extends Playlist {
trackCount: number;
}
export interface Mix {
id: string;
title: string;
subtitle: string | null;
tracks: Track[];
}
export interface Paged<T> {
items: T[];
pageNumber: number;
hasNextPage: boolean;
totalItemCount: number;
}
+46
View File
@@ -0,0 +1,46 @@
import { describe, expect, it } from 'vitest';
import { albumMeta, fmtDurationLong, fmtTime, plural } from './format';
describe('fmtTime', () => {
it('formats minutes and padded seconds', () => {
expect(fmtTime(0)).toBe('0:00');
expect(fmtTime(65)).toBe('1:05');
expect(fmtTime(600)).toBe('10:00');
});
it('floors fractions and clamps invalid input', () => {
expect(fmtTime(59.9)).toBe('0:59');
expect(fmtTime(-5)).toBe('0:00');
expect(fmtTime(NaN)).toBe('0:00');
});
});
describe('fmtDurationLong', () => {
it('shows minutes only under an hour', () => {
expect(fmtDurationLong(59)).toBe('0 Min');
expect(fmtDurationLong(125)).toBe('2 Min');
});
it('shows hours and minutes', () => {
expect(fmtDurationLong(3600)).toBe('1 H 0 Min');
expect(fmtDurationLong(3600 * 2 + 60 * 5)).toBe('2 H 5 Min');
});
});
describe('plural', () => {
it('uses the singular only for 1', () => {
expect(plural(1, 'canción', 'canciones')).toBe('1 canción');
expect(plural(0, 'canción', 'canciones')).toBe('0 canciones');
expect(plural(2, 'canción', 'canciones')).toBe('2 canciones');
});
});
describe('albumMeta', () => {
it('joins year and track count', () => {
expect(albumMeta(2020, 1)).toBe('2020 · 1 canción');
expect(albumMeta(2020, 12)).toBe('2020 · 12 canciones');
});
it('omits missing parts', () => {
expect(albumMeta(undefined, 3)).toBe('3 canciones');
expect(albumMeta(2020)).toBe('2020');
expect(albumMeta()).toBe('');
});
});
+9 -3
View File
@@ -22,12 +22,12 @@ export function plural(n: number, one: string, many: string): string {
return `${n} ${n === 1 ? one : many}`; return `${n} ${n === 1 ? one : many}`;
} }
export function fmtPlays(count: number | string | undefined): string { export function fmtPlays(count: number): string {
const value = Math.max(0, Math.floor(Number(count ?? 0))); const value = Math.max(0, Math.floor(count));
return plural(value, 'reproducción', 'reproducciones'); return plural(value, 'reproducción', 'reproducciones');
} }
export function albumMeta(releaseYear?: number, trackCount?: number): string { export function albumMeta(releaseYear?: number | null, trackCount?: number): string {
return [ return [
releaseYear ? String(releaseYear) : undefined, releaseYear ? String(releaseYear) : undefined,
trackCount === undefined ? undefined : plural(trackCount, 'canción', 'canciones') trackCount === undefined ? undefined : plural(trackCount, 'canción', 'canciones')
@@ -36,6 +36,12 @@ export function albumMeta(releaseYear?: number, trackCount?: number): string {
.join(' · '); .join(' · ');
} }
export function formatSize(bytes: number): string {
if (bytes === 0) return '';
const mb = bytes / (1024 * 1024);
return mb >= 1 ? `${mb.toFixed(1)} MB` : `${Math.round(bytes / 1024)} KB`;
}
export function playlistMeta(trackCount: number): string { export function playlistMeta(trackCount: number): string {
return plural(trackCount, 'canción', 'canciones'); return plural(trackCount, 'canción', 'canciones');
} }
+15
View File
@@ -0,0 +1,15 @@
export function readStorage(key: string, fallback: string | null = null): string | null {
try {
return localStorage.getItem(key) ?? fallback;
} catch {
return fallback;
}
}
export function writeStorage(key: string, value: string): void {
try {
localStorage.setItem(key, value);
} catch {
/* localStorage unavailable (private mode, quota) — the value just won't persist */
}
}
+9
View File
@@ -0,0 +1,9 @@
export const ALBUM_EARLIEST_YEAR = 1877;
export const LIMITS = {
playlistName: 50,
playlistDescription: 256,
albumTitle: 200,
albumDescription: 256,
trackTitle: 200
} as const;
@@ -1,43 +1,31 @@
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 { import { apiFor, authedAction, formFile, requireUser, unwrapOrError } from '$lib/server/api';
createApiClient, import { toAlbum, toPage } from '$lib/server/mappers';
requireAccessTokenAction,
requireUser,
unwrapOrError
} from '$lib/server/api';
import { ALBUMS_PAGE_SIZE } from '$lib/config'; import { ALBUMS_PAGE_SIZE } from '$lib/config';
import { parseAlbumForm } from '$lib/server/albumForm'; import { parseAlbumForm } from '$lib/server/forms/albumForm';
import { uploadPresignedImage } from '$lib/server/upload'; import { uploadPresignedImage } from '$lib/server/upload';
export const load: PageServerLoad = async ({ locals, url, fetch }) => { export const load: PageServerLoad = async ({ locals, url, fetch }) => {
const user = requireUser(locals, url); const user = requireUser(locals, url);
const api = createApiClient({ fetch, accessToken: locals.accessToken ?? undefined }); const api = apiFor({ fetch, locals });
const result = await api.GET('/albums/users/{userId}', { const result = await api.GET('/albums/users/{userId}', {
params: { path: { userId: user.sub }, query: { pageNumber: 1, pageSize: ALBUMS_PAGE_SIZE } } params: { path: { userId: user.sub }, query: { pageNumber: 1, pageSize: ALBUMS_PAGE_SIZE } }
}); });
const albums = unwrapOrError(result, 'No se pudieron cargar tus álbumes.'); const albums = toPage(unwrapOrError(result, 'No se pudieron cargar tus álbumes.'), toAlbum);
return { albums }; return { albums };
}; };
export const actions: Actions = { export const actions: Actions = {
create: async ({ request, locals, fetch }) => { create: authedAction(async ({ api, form }) => {
const accessToken = requireAccessTokenAction(locals);
if (typeof accessToken !== 'string') return accessToken;
const form = await request.formData();
const parsed = parseAlbumForm(form); const parsed = parseAlbumForm(form);
if ('failMessage' in parsed) return fail(400, { message: parsed.failMessage }); if ('failMessage' in parsed) return fail(400, { message: parsed.failMessage });
const cover = form.get('cover'); const cover = formFile(form, 'cover');
if (!(cover instanceof File) || cover.size === 0) { if (!cover) return fail(400, { message: 'La portada es obligatoria.' });
return fail(400, { message: 'La portada es obligatoria.' });
}
const api = createApiClient({ fetch, accessToken });
const uploaded = await uploadPresignedImage( const uploaded = await uploadPresignedImage(
(args) => api.POST('/albums/upload-picture', { body: args }), (args) => api.POST('/albums/upload-picture', { body: args }),
@@ -47,12 +35,12 @@ export const actions: Actions = {
return fail(502, { message: uploaded.failMessage, detail: uploaded.detail }); return fail(502, { message: uploaded.failMessage, detail: uploaded.detail });
} }
const { data, error: err } = await api.POST('/albums', { const { data, error } = await api.POST('/albums', {
body: { ...parsed.body, pictureIntentId: uploaded.intentId } body: { ...parsed.body, pictureIntentId: uploaded.intentId }
}); });
if (err || !data) return fail(502, { message: 'No se pudo crear el álbum.' }); if (error || !data) return fail(502, { message: 'No se pudo crear el álbum.' });
redirect(303, `/albums/${data.id}`); redirect(303, `/albums/${data.id}`);
} })
}; };
@@ -40,10 +40,7 @@
<MediaCard <MediaCard
href="/albums/{album.id}" href="/albums/{album.id}"
title={album.title} title={album.title}
subtitle={albumMeta( subtitle={albumMeta(album.releaseYear, album.trackCount)}
album.releaseYear === null ? undefined : Number(album.releaseYear),
Number(album.trackCount)
)}
trackIds={album.coverTrackIds} trackIds={album.coverTrackIds}
src="/api/albums/{album.id}/cover?size=large" src="/api/albums/{album.id}/cover?size=large"
index={i} index={i}
@@ -1,20 +1,23 @@
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 { import {
createApiClient, apiFor,
authedAction,
failOnError,
formFile,
formString,
optionalUser, optionalUser,
requireAccessTokenAction, unwrapOrError
unwrapOrError,
unwrapOrFail
} from '$lib/server/api'; } from '$lib/server/api';
import { toAlbum, toTrack } from '$lib/server/mappers';
import { ALBUM_TRACKS_PAGE_SIZE, LIBRARY_PICKER_PAGE_SIZE } from '$lib/config'; import { ALBUM_TRACKS_PAGE_SIZE, LIBRARY_PICKER_PAGE_SIZE } from '$lib/config';
import { parseAlbumForm } from '$lib/server/albumForm'; import { parseAlbumForm } from '$lib/server/forms/albumForm';
import { uploadPresignedImage } from '$lib/server/upload'; import { uploadOptionalCover } from '$lib/server/upload';
export const load: PageServerLoad = async ({ params, locals, url, fetch, parent }) => { export const load: PageServerLoad = async ({ params, locals, url, fetch, parent }) => {
const { allowAnonymousListening } = await parent(); const { allowAnonymousListening } = await parent();
const user = optionalUser(locals, url, allowAnonymousListening); const user = optionalUser(locals, url, allowAnonymousListening);
const api = createApiClient({ fetch, accessToken: locals.accessToken ?? undefined }); const api = apiFor({ fetch, locals });
const [albumRes, tracksRes, libraryRes] = await Promise.all([ const [albumRes, tracksRes, libraryRes] = await Promise.all([
api.GET('/albums/{id}', { params: { path: { id: params.id } } }), api.GET('/albums/{id}', { params: { path: { id: params.id } } }),
@@ -34,11 +37,13 @@ export const load: PageServerLoad = async ({ params, locals, url, fetch, parent
: Promise.resolve(null) : Promise.resolve(null)
]); ]);
const album = unwrapOrError(albumRes, 'Álbum no encontrado.', 404); const album = toAlbum(unwrapOrError(albumRes, 'Álbum no encontrado.', 404));
const tracks = tracksRes.data?.items ?? []; const tracks = (tracksRes.data?.items ?? []).map(toTrack);
const inAlbum = new Set(tracks.map((track) => track.id)); const inAlbum = new Set(tracks.map((track) => track.id));
const library = (libraryRes?.data?.items ?? []).filter((track) => !inAlbum.has(track.id)); const library = (libraryRes?.data?.items ?? [])
.filter((track) => !inAlbum.has(track.id))
.map(toTrack);
const isOwner = user !== null && String(album.ownerUserId) === user.sub; const isOwner = user !== null && String(album.ownerUserId) === user.sub;
@@ -46,59 +51,33 @@ export const load: PageServerLoad = async ({ params, locals, url, fetch, parent
}; };
export const actions: Actions = { export const actions: Actions = {
addTrack: async ({ request, params, locals, fetch }) => { addTrack: authedAction(async ({ api, form, params }) => {
const accessToken = requireAccessTokenAction(locals); const trackId = formString(form, 'trackId');
if (typeof accessToken !== 'string') return accessToken;
const trackId = String((await request.formData()).get('trackId') ?? '');
if (!trackId) return fail(400, { message: 'Falta la canción.' }); if (!trackId) return fail(400, { message: 'Falta la canción.' });
const api = createApiClient({ fetch, accessToken });
const result = await api.POST('/albums/{albumId}/tracks/{trackId}', { const result = await api.POST('/albums/{albumId}/tracks/{trackId}', {
params: { path: { albumId: params.id, trackId } } params: { path: { albumId: params.id, trackId } }
}); });
const failure = unwrapOrFail(result, 'No se pudo añadir la canción.'); return failOnError(result, 'No se pudo añadir la canción.') ?? { added: true };
if (failure) return failure; }),
return { added: true };
},
removeTrack: async ({ request, params, locals, fetch }) => { removeTrack: authedAction(async ({ api, form, params }) => {
const accessToken = requireAccessTokenAction(locals); const trackId = formString(form, 'trackId');
if (typeof accessToken !== 'string') return accessToken;
const trackId = String((await request.formData()).get('trackId') ?? '');
if (!trackId) return fail(400, { message: 'Falta la canción.' }); if (!trackId) return fail(400, { message: 'Falta la canción.' });
const api = createApiClient({ fetch, accessToken });
const result = await api.DELETE('/albums/{albumId}/tracks/{trackId}', { const result = await api.DELETE('/albums/{albumId}/tracks/{trackId}', {
params: { path: { albumId: params.id, trackId } } params: { path: { albumId: params.id, trackId } }
}); });
const failure = unwrapOrFail(result, 'No se pudo quitar la canción.'); return failOnError(result, 'No se pudo quitar la canción.') ?? { removed: true };
if (failure) return failure; }),
return { removed: true };
},
edit: async ({ request, params, locals, fetch }) => { edit: authedAction(async ({ api, form, params }) => {
const accessToken = requireAccessTokenAction(locals);
if (typeof accessToken !== 'string') return accessToken;
const form = await request.formData();
const parsed = parseAlbumForm(form); const parsed = parseAlbumForm(form);
if ('failMessage' in parsed) return fail(400, { message: parsed.failMessage }); if ('failMessage' in parsed) return fail(400, { message: parsed.failMessage });
const api = createApiClient({ fetch, accessToken }); const cover = await uploadOptionalCover(api, '/albums/upload-picture', formFile(form, 'cover'));
if ('failMessage' in cover) {
let newPictureIntentId: string | null = null; return fail(502, { message: cover.failMessage, detail: cover.detail });
const cover = form.get('cover');
if (cover instanceof File && cover.size > 0) {
const uploaded = await uploadPresignedImage(
(args) => api.POST('/albums/upload-picture', { body: args }),
cover
);
if ('failMessage' in uploaded) {
return fail(502, { message: uploaded.failMessage, detail: uploaded.detail });
}
newPictureIntentId = uploaded.intentId;
} }
const result = await api.PUT('/albums/{albumId}', { const result = await api.PUT('/albums/{albumId}', {
@@ -107,24 +86,18 @@ export const actions: Actions = {
newTitle: parsed.body.title, newTitle: parsed.body.title,
newDescription: parsed.body.description, newDescription: parsed.body.description,
newReleaseYear: parsed.body.releaseYear, newReleaseYear: parsed.body.releaseYear,
newPictureIntentId newPictureIntentId: cover.intentId
} }
}); });
const failure = unwrapOrFail(result, 'No se pudo actualizar el álbum.'); return failOnError(result, 'No se pudo actualizar el álbum.') ?? { edited: true };
if (failure) return failure; }),
return { edited: true };
},
delete: async ({ params, locals, fetch }) => { delete: authedAction(async ({ api, params }) => {
const accessToken = requireAccessTokenAction(locals);
if (typeof accessToken !== 'string') return accessToken;
const api = createApiClient({ fetch, accessToken });
const result = await api.DELETE('/albums/{albumId}', { const result = await api.DELETE('/albums/{albumId}', {
params: { path: { albumId: params.id } } params: { path: { albumId: params.id } }
}); });
const failure = unwrapOrFail(result, 'No se pudo eliminar el álbum.'); const failure = failOnError(result, 'No se pudo eliminar el álbum.');
if (failure) return failure; if (failure) return failure;
redirect(303, '/albums'); redirect(303, '/albums');
} })
}; };
@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { player, toQueueItems, isQueueCurrent, playAllOrToggle } from '$lib/player/player.svelte'; import { player, isQueueCurrent, playAllOrToggle } from '$lib/player/player.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';
@@ -28,15 +28,15 @@
const isCurrentQueue = $derived(isQueueCurrent(tracks)); const isCurrentQueue = $derived(isQueueCurrent(tracks));
function playAll() { function playAll() {
playAllOrToggle(toQueueItems(tracks)); playAllOrToggle(tracks);
} }
function playTrackFrom(index: number) { function playTrackFrom(index: number) {
player.playOrToggle(toQueueItems(tracks), index); player.playOrToggle(tracks, index);
} }
function playLibraryFrom(index: number) { function playLibraryFrom(index: number) {
player.playOrToggle(toQueueItems(library), index); player.playOrToggle(library, index);
} }
</script> </script>
@@ -55,10 +55,7 @@
eyebrow="Álbum" eyebrow="Álbum"
title={album.title} title={album.title}
description={album.description ?? undefined} description={album.description ?? undefined}
meta={albumMeta( meta={albumMeta(album.releaseYear, tracks.length)}
album.releaseYear === null ? undefined : Number(album.releaseYear),
tracks.length
)}
> >
{#snippet cover()} {#snippet cover()}
<Artwork <Artwork
@@ -118,7 +115,7 @@
action="?/edit" action="?/edit"
initialTitle={album.title} initialTitle={album.title}
initialDescription={album.description ?? ''} initialDescription={album.description ?? ''}
initialReleaseYear={album.releaseYear === null ? undefined : Number(album.releaseYear)} initialReleaseYear={album.releaseYear ?? undefined}
coverFallbackUrl="/api/albums/{album.id}/cover?size=medium" coverFallbackUrl="/api/albums/{album.id}/cover?size=medium"
formMessage={form?.message} formMessage={form?.message}
submitLabel="Guardar" submitLabel="Guardar"
@@ -1,12 +1,8 @@
import type { PageServerLoad, Actions } from './$types'; import type { PageServerLoad, Actions } from './$types';
import { createApiClient, unwrapOrError } from '$lib/server/api'; import { apiFor, unwrapOrError } from '$lib/server/api';
import { toAlbum, toPage, toTrack } from '$lib/server/mappers';
import { addAlbumToPlaylistAction, addTrackAction } from '$lib/server/playlistActions'; import { addAlbumToPlaylistAction, addTrackAction } from '$lib/server/playlistActions';
import { followUserAction, unfollowUserAction } from '$lib/server/followActions'; import { EXPLORE_ALBUMS_PAGE_SIZE, EXPLORE_PAGE_SIZE, EXPLORE_USERS_LIMIT } from '$lib/config';
import {
EXPLORE_ALBUMS_PAGE_SIZE,
EXPLORE_PAGE_SIZE as PAGE_SIZE,
EXPLORE_USERS_LIMIT
} from '$lib/config';
export const load: PageServerLoad = async ({ url, locals, fetch }) => { export const load: PageServerLoad = async ({ url, locals, fetch }) => {
const query = url.searchParams.get('q')?.trim() ?? ''; const query = url.searchParams.get('q')?.trim() ?? '';
@@ -14,7 +10,7 @@ export const load: PageServerLoad = async ({ url, locals, fetch }) => {
const page = Math.max(1, Number(url.searchParams.get('page')) || 1); const page = Math.max(1, Number(url.searchParams.get('page')) || 1);
const user = locals.user; const user = locals.user;
const api = createApiClient({ fetch, accessToken: locals.accessToken ?? undefined }); const api = apiFor({ fetch, locals });
const genresRes = await api.GET('/genres'); const genresRes = await api.GET('/genres');
const genres = genresRes.data ?? []; const genres = genresRes.data ?? [];
@@ -26,17 +22,11 @@ export const load: PageServerLoad = async ({ url, locals, fetch }) => {
name: query || undefined, name: query || undefined,
genre: genre ?? undefined, genre: genre ?? undefined,
pageNumber: page, pageNumber: page,
pageSize: PAGE_SIZE pageSize: EXPLORE_PAGE_SIZE
} }
} }
}); });
const playlistsPromise = user
? api.GET('/playlists/users/{userId}', {
params: { path: { userId: user.sub }, query: { pageNumber: 1, pageSize: 50 } }
})
: Promise.resolve(null);
const albumsPromise = query const albumsPromise = query
? api.GET('/albums', { ? api.GET('/albums', {
params: { query: { title: query, pageNumber: 1, pageSize: EXPLORE_ALBUMS_PAGE_SIZE } } params: { query: { title: query, pageNumber: 1, pageSize: EXPLORE_ALBUMS_PAGE_SIZE } }
@@ -49,30 +39,30 @@ export const load: PageServerLoad = async ({ url, locals, fetch }) => {
}) })
: Promise.resolve(null); : Promise.resolve(null);
const [tracksRes, playlistsRes, albumsRes, usersRes] = await Promise.all([ const [tracksRes, albumsRes, usersRes] = await Promise.all([
tracksPromise, tracksPromise,
playlistsPromise,
albumsPromise, albumsPromise,
usersPromise usersPromise
]); ]);
const tracks = unwrapOrError(tracksRes, 'No se pudieron cargar las canciones.'); const tracks = toPage(unwrapOrError(tracksRes, 'No se pudieron cargar las canciones.'), (item) =>
toTrack(item.track)
);
return { return {
query, query,
genre, genre,
genres, genres,
tracks, tracks,
albums: (albumsRes?.data?.items ?? []).map((item) => item.album), albums: (albumsRes?.data?.items ?? []).map((item) => toAlbum(item.album)),
albumsTotal: Number(albumsRes?.data?.totalItemCount ?? 0),
users: usersRes?.data?.items ?? [], users: usersRes?.data?.items ?? [],
viewerId: user?.sub ?? null, usersTotal: Number(usersRes?.data?.totalItemCount ?? 0),
playlists: playlistsRes?.data?.items ?? [] viewerId: user?.sub ?? null
}; };
}; };
export const actions: Actions = { export const actions: Actions = {
addTrack: addTrackAction, addTrack: addTrackAction,
addAlbumToPlaylist: addAlbumToPlaylistAction, addAlbumToPlaylist: addAlbumToPlaylistAction
follow: followUserAction,
unfollow: unfollowUserAction
}; };
@@ -1,7 +1,8 @@
<script lang="ts"> <script lang="ts">
import Music from '@lucide/svelte/icons/music'; import Music from '@lucide/svelte/icons/music';
import { player, toQueueItem } from '$lib/player/player.svelte'; import { player } from '$lib/player/player.svelte';
import { fetchAlbumQueueItems } from '$lib/data/albums'; import { fetchAlbumTracks } from '$lib/data/albums';
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';
@@ -49,9 +50,7 @@
let albumMenu = $state<AlbumMenuState | null>(null); let albumMenu = $state<AlbumMenuState | null>(null);
let sfilter = $state<SearchFilter>('Todo'); let sfilter = $state<SearchFilter>('Todo');
type LocalTrack = (typeof data.tracks.items)[number]['track']; let localItems = $state<Track[]>([]);
let localItems = $state<LocalTrack[]>([]);
let localPage = $state(1); let localPage = $state(1);
let hasMore = $state(false); let hasMore = $state(false);
let loadingMore = $state(false); let loadingMore = $state(false);
@@ -60,19 +59,12 @@
trackMenu.close(); trackMenu.close();
albumMenu = null; albumMenu = null;
sfilter = data.genre ? 'Canciones' : 'Todo'; sfilter = data.genre ? 'Canciones' : 'Todo';
localItems = data.tracks.items.map((item) => item.track); localItems = data.tracks.items;
localPage = Number(data.tracks.pageNumber); localPage = data.tracks.pageNumber;
hasMore = data.tracks.hasNextPage; hasMore = data.tracks.hasNextPage;
}); });
const songRows = $derived( const items = $derived(localItems);
localItems.map((track) => ({
track,
seconds: Number(track.duration)
}))
);
const items = $derived(songRows.map((row) => row.track));
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);
@@ -83,7 +75,7 @@
const hasAlbums = $derived(albums.length > 0); const hasAlbums = $derived(albums.length > 0);
const hasUsers = $derived(users.length > 0); const hasUsers = $derived(users.length > 0);
const playlistMatches = $derived(matchPlaylists(data.playlists, data.query)); const playlistMatches = $derived(matchPlaylists(data.userPlaylists, data.query));
const hasPlaylists = $derived(playlistMatches.length > 0); const hasPlaylists = $derived(playlistMatches.length > 0);
const nothingFound = $derived( const nothingFound = $derived(
@@ -92,10 +84,10 @@
const counts = $derived( const counts = $derived(
searchCounts({ searchCounts({
tracks: songRows.length, tracks: data.tracks.totalItemCount,
albums: albums.length, albums: data.albumsTotal,
playlists: playlistMatches.length, playlists: playlistMatches.length,
users: users.length users: data.usersTotal
}) })
); );
const chips = $derived(searchChips(counts)); const chips = $derived(searchChips(counts));
@@ -106,7 +98,7 @@
type TopResult = NonNullable< type TopResult = NonNullable<
ReturnType< ReturnType<
typeof findTopResult< typeof findTopResult<
LocalTrack, Track,
(typeof albums)[number], (typeof albums)[number],
(typeof playlistMatches)[number], (typeof playlistMatches)[number],
(typeof users)[number] (typeof users)[number]
@@ -128,7 +120,7 @@
function playTopResult() { function playTopResult() {
if (topResult?.kind !== 'track') return; if (topResult?.kind !== 'track') return;
player.playOrToggle([toQueueItem(topResult.track)], 0); player.playOrToggle([topResult.track], 0);
} }
const topResultHref = $derived.by(() => { const topResultHref = $derived.by(() => {
@@ -139,11 +131,7 @@
}); });
function togglePlay(index: number) { function togglePlay(index: number) {
player.playOrToggle(items.map(toQueueItem), index); player.playOrToggle(items, index);
}
function openContextMenu(event: MouseEvent, track: LocalTrack) {
trackMenu.open(event, track);
} }
function openAlbumMenu(event: MouseEvent, albumId: string) { function openAlbumMenu(event: MouseEvent, albumId: string) {
@@ -154,14 +142,14 @@
if (!albumMenu) return; if (!albumMenu) return;
const { albumId } = albumMenu; const { albumId } = albumMenu;
albumMenu = null; albumMenu = null;
player.playNext(await fetchAlbumQueueItems(albumId)); player.playNext(await fetchAlbumTracks(albumId));
} }
async function albumAddToQueue() { async function albumAddToQueue() {
if (!albumMenu) return; if (!albumMenu) return;
const { albumId } = albumMenu; const { albumId } = albumMenu;
albumMenu = null; albumMenu = null;
player.appendToQueue(await fetchAlbumQueueItems(albumId)); player.appendToQueue(await fetchAlbumTracks(albumId));
} }
async function loadMore() { async function loadMore() {
@@ -177,12 +165,8 @@
const res = await fetch(`/api/tracks?${params}`); const res = await fetch(`/api/tracks?${params}`);
if (!res.ok) throw new Error(String(res.status)); if (!res.ok) throw new Error(String(res.status));
const next = (await res.json()) as typeof data.tracks; const next = (await res.json()) as typeof data.tracks;
localItems = appendUnique( localItems = appendUnique(localItems, next.items, (track) => track.id);
localItems, localPage = next.pageNumber;
next.items.map((item) => item.track),
(track) => track.id
);
localPage = Number(next.pageNumber);
hasMore = next.hasNextPage; hasMore = next.hasNextPage;
} catch { } catch {
hasMore = false; hasMore = false;
@@ -340,30 +324,30 @@
<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, 'Canciones')}
<div> <div>
{#if songRows.length > 0} {#if items.length > 0}
<SectionHeading title="Canciones" count={counts.Canciones} /> <SectionHeading title="Canciones" count={counts.Canciones} />
<ul class="flex flex-col gap-1"> <ul class="flex flex-col gap-1">
{#each capped(sfilter, songRows) as { track, seconds }, i (track.id)} {#each capped(sfilter, items) as track, i (track.id)}
<li> <li>
<ListRow <ListRow
title={track.title} title={track.title}
subtitle={track.artist} subtitle={track.artist}
subtitleHref={track.ownerUserId} subtitleHref={track.ownerUserId}
explicit={track.isExplicit} explicit={track.explicit}
active={player.current.id === track.id} active={player.currentId === track.id}
size="lg" size="lg"
trackId={track.id} trackId={track.id}
onclick={() => togglePlay(i)} onclick={() => togglePlay(i)}
oncontextmenu={(e) => openContextMenu(e, track)} oncontextmenu={(e) => trackMenu.open(e, track)}
> >
{#snippet overlay()} {#snippet overlay()}
{#if player.current.id === track.id} {#if player.currentId === track.id}
<EqBars overlay paused={!player.playing} /> <EqBars overlay paused={!player.playing} />
{/if} {/if}
{/snippet} {/snippet}
{#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">
{fmtTime(seconds)} · {fmtPlays(track.listensCount)} {fmtTime(track.duration)} · {fmtPlays(track.listensCount)}
</span> </span>
{/snippet} {/snippet}
</ListRow> </ListRow>
@@ -400,7 +384,7 @@
> >
{#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">
{Number(album.trackCount)} canciones {plural(album.trackCount, 'canción', 'canciones')}
</span> </span>
{/snippet} {/snippet}
</ListRow> </ListRow>
@@ -459,10 +443,10 @@
]} ]}
playlistAction={{ playlistAction={{
action: '?/addTrack', action: '?/addTrack',
fields: { trackId: String(trackMenu.state.track.id) }, fields: { trackId: trackMenu.state.track.id },
label: 'Añadir a una playlist' label: 'Añadir a una playlist'
}} }}
playlists={data.playlists} playlists={data.userPlaylists}
/> />
{/if} {/if}
@@ -481,6 +465,6 @@
fields: { albumId: albumMenu.albumId }, fields: { albumId: albumMenu.albumId },
label: 'Añadir álbum a una playlist' label: 'Añadir álbum a una playlist'
}} }}
playlists={data.playlists} playlists={data.userPlaylists}
/> />
{/if} {/if}
@@ -1,38 +1,36 @@
import type { PageServerLoad, Actions } from './$types'; import type { PageServerLoad, Actions } from './$types';
import { fail } from '@sveltejs/kit'; import { fail } from '@sveltejs/kit';
import { toDatedTrack, toPage } from '$lib/server/mappers';
import { import {
createApiClient, apiFor,
requireAccessTokenAction, authedAction,
failOnError,
formString,
requireUser, requireUser,
unwrapOrError, unwrapOrError
unwrapOrFail
} from '$lib/server/api'; } from '$lib/server/api';
import { LIBRARY_PAGE_SIZE } from '$lib/config';
const PAGE_SIZE = 50;
export const load: PageServerLoad = async ({ locals, url, fetch }) => { export const load: PageServerLoad = async ({ locals, url, fetch }) => {
const user = requireUser(locals, url); const user = requireUser(locals, url);
const api = createApiClient({ fetch, accessToken: locals.accessToken ?? undefined }); const api = apiFor({ fetch, locals });
const result = await api.GET('/tracks/users/{userId}', { const result = await api.GET('/tracks/users/{userId}', {
params: { path: { userId: user.sub }, query: { pageNumber: 1, pageSize: PAGE_SIZE } } params: { path: { userId: user.sub }, query: { pageNumber: 1, pageSize: LIBRARY_PAGE_SIZE } }
}); });
return { tracks: unwrapOrError(result, 'No se pudo cargar tu biblioteca.') }; return {
tracks: toPage(unwrapOrError(result, 'No se pudo cargar tu biblioteca.'), toDatedTrack)
};
}; };
export const actions: Actions = { export const actions: Actions = {
deleteTrack: async ({ request, locals, fetch }) => { deleteTrack: authedAction(async ({ api, form }) => {
const accessToken = requireAccessTokenAction(locals); const trackId = formString(form, 'trackId');
if (typeof accessToken !== 'string') return accessToken;
const trackId = String((await request.formData()).get('trackId') ?? '');
if (!trackId) return fail(400, { message: 'Falta la canción.' }); if (!trackId) return fail(400, { message: 'Falta la canción.' });
const api = createApiClient({ fetch, accessToken });
const result = await api.DELETE('/tracks/{trackId}', { const result = await api.DELETE('/tracks/{trackId}', {
params: { path: { trackId } } params: { path: { trackId } }
}); });
const failure = unwrapOrFail(result, 'No se pudo borrar la canción.'); return failOnError(result, 'No se pudo borrar la canción.') ?? { deleted: true };
if (failure) return failure; })
return { deleted: true };
}
}; };
@@ -2,22 +2,24 @@
import Upload from '@lucide/svelte/icons/upload'; import Upload from '@lucide/svelte/icons/upload';
import X from '@lucide/svelte/icons/x'; import X from '@lucide/svelte/icons/x';
import Music from '@lucide/svelte/icons/music'; import Music from '@lucide/svelte/icons/music';
import { player, toQueueItems } from '$lib/player/player.svelte'; import { player } from '$lib/player/player.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';
import PageHeader from '$lib/components/ui/layout/PageHeader.svelte'; import PageHeader from '$lib/components/ui/layout/PageHeader.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 ConfirmDialog from '$lib/components/ui/overlay/ConfirmDialog.svelte';
import TrackList from '$lib/components/ui/media/TrackList.svelte'; import TrackList from '$lib/components/ui/media/TrackList.svelte';
let { data } = $props(); let { data } = $props();
const items = $derived(data.tracks.items); const items = $derived(data.tracks.items);
const total = $derived(Number(data.tracks.totalItemCount)); const total = $derived(data.tracks.totalItemCount);
const listTracks = $derived(items.map((track) => ({ ...track, uploadedAt: track.createdAt })));
let deleting = $state<(typeof items)[number] | null>(null);
function togglePlay(index: number) { function togglePlay(index: number) {
player.playOrToggle(toQueueItems(items), index); player.playOrToggle(items, index);
} }
</script> </script>
@@ -43,10 +45,14 @@
{#if items.length > 0} {#if items.length > 0}
<TrackList <TrackList
tracks={listTracks} tracks={items}
columns={['uploaded', 'plays']} columns={['uploaded', 'plays']}
onPlay={togglePlay} onPlay={togglePlay}
rowAction={{ action: '?/deleteTrack', icon: X, label: 'Borrar canción' }} rowAction={{
onclick: (track) => (deleting = items.find((item) => item.id === track.id) ?? null),
icon: X,
label: 'Borrar canción'
}}
/> />
{:else} {:else}
<EmptyState <EmptyState
@@ -63,3 +69,13 @@
</EmptyState> </EmptyState>
{/if} {/if}
</Page> </Page>
<ConfirmDialog
open={deleting !== null}
onClose={() => (deleting = null)}
title="¿Borrar «{deleting?.title ?? ''}»?"
description="Esta acción no se puede deshacer, la canción se eliminará de tu biblioteca, de tus álbumes y de las playlists donde esté."
confirmLabel="Borrar"
action="?/deleteTrack"
fields={{ trackId: deleting?.id ?? '' }}
/>
@@ -11,7 +11,7 @@
import TrackList from '$lib/components/ui/media/TrackList.svelte'; import TrackList from '$lib/components/ui/media/TrackList.svelte';
const tracks = $derived(liked.list); const tracks = $derived(liked.list);
const listTracks = $derived(tracks.map((track) => ({ ...track, addedAt: track.likedAt }))); const listTracks = $derived(tracks.map((track) => ({ ...track, date: track.likedAt })));
const isCurrentQueue = $derived(isQueueCurrent(tracks)); const isCurrentQueue = $derived(isQueueCurrent(tracks));
function playFrom(index: number) { function playFrom(index: number) {
@@ -55,7 +55,7 @@
columns={['added', 'plays']} columns={['added', 'plays']}
onPlay={playFrom} onPlay={playFrom}
rowAction={{ rowAction={{
onclick: (track) => liked.toggle({ ...track, artist: track.artist ?? undefined }), onclick: (track) => liked.toggle(track),
icon: X, icon: X,
label: 'Quitar de Me gusta' label: 'Quitar de Me gusta'
}} }}
@@ -1,25 +1,15 @@
import type { PageServerLoad, Actions } from './$types'; import type { PageServerLoad, Actions } from './$types';
import { createApiClient, requireUser, unwrapOrError } from '$lib/server/api'; import { apiFor, requireUser, unwrapOrError } from '$lib/server/api';
import { toMix } from '$lib/server/mappers';
import { addTrackAction } from '$lib/server/playlistActions'; import { addTrackAction } from '$lib/server/playlistActions';
import { PLAYLIST_PICKER_PAGE_SIZE } from '$lib/config';
export const load: PageServerLoad = async ({ params, locals, url, fetch }) => { export const load: PageServerLoad = async ({ params, locals, url, fetch }) => {
const user = requireUser(locals, url); requireUser(locals, url);
const api = createApiClient({ fetch, accessToken: locals.accessToken ?? undefined }); const api = apiFor({ fetch, locals });
const [mixRes, playlistsRes] = await Promise.all([ const mixRes = await api.GET('/mixes/{id}', { params: { path: { id: params.id } } });
api.GET('/mixes/{id}', { params: { path: { id: params.id } } }),
api.GET('/playlists/users/{userId}', {
params: {
path: { userId: user.sub },
query: { pageNumber: 1, pageSize: PLAYLIST_PICKER_PAGE_SIZE }
}
})
]);
const mix = unwrapOrError(mixRes, 'Mezcla no encontrada.', 404); return { mix: toMix(unwrapOrError(mixRes, 'Mezcla no encontrada.', 404)) };
return { mix, playlists: playlistsRes.data?.items ?? [] };
}; };
export const actions: Actions = { export const actions: Actions = {
@@ -1,12 +1,6 @@
<script lang="ts"> <script lang="ts">
import Shuffle from '@lucide/svelte/icons/shuffle'; import Shuffle from '@lucide/svelte/icons/shuffle';
import { import { player, isQueueCurrent, playAllOrToggle, playShuffled } from '$lib/player/player.svelte';
player,
isQueueCurrent,
playAllOrToggle,
playShuffled,
toQueueItem
} from '$lib/player/player.svelte';
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';
@@ -18,33 +12,19 @@
import ContextMenu from '$lib/components/ui/overlay/ContextMenu.svelte'; import ContextMenu from '$lib/components/ui/overlay/ContextMenu.svelte';
import ListPlus from '@lucide/svelte/icons/list-plus'; import ListPlus from '@lucide/svelte/icons/list-plus';
import { createTrackMenu } from '$lib/state/menus.svelte'; import { createTrackMenu } from '$lib/state/menus.svelte';
import { mixItemTrack } from '$lib/data/mixes';
let { data, form } = $props(); let { data, form } = $props();
const mix = $derived(data.mix); const mix = $derived(data.mix);
const items = $derived(mix.items); const tracks = $derived(mix.tracks);
const tracks = $derived(items.map(mixItemTrack));
const queue = $derived(tracks.map(toQueueItem));
const listTracks = $derived(
items.map((item) => ({
id: item.trackId,
title: item.title,
artist: item.artist,
duration: item.durationSeconds,
listensCount: item.listensCount
}))
);
const totalSeconds = $derived( const totalSeconds = $derived(tracks.reduce((total, track) => total + track.duration, 0));
items.reduce((total, item) => total + Number(item.durationSeconds), 0) const isCurrentQueue = $derived(isQueueCurrent(tracks));
);
const isCurrentQueue = $derived(isQueueCurrent(queue));
const trackMenu = createTrackMenu(); const trackMenu = createTrackMenu();
function playFrom(index: number) { function playFrom(index: number) {
player.playOrToggle(queue, index); player.playOrToggle(tracks, index);
} }
</script> </script>
@@ -60,18 +40,18 @@
eyebrow="Mezcla" eyebrow="Mezcla"
title={mix.title} title={mix.title}
description={mix.subtitle ?? undefined} description={mix.subtitle ?? undefined}
meta="{plural(items.length, 'canción', 'canciones')} · {fmtTime(totalSeconds)}" meta="{plural(tracks.length, 'canción', 'canciones')} · {fmtTime(totalSeconds)}"
> >
{#snippet cover()} {#snippet cover()}
<Artwork <Artwork
trackIds={items.map((item) => item.trackId)} trackIds={tracks.map((track) => track.id)}
size="hero" size="hero"
alt={mix.title} alt={mix.title}
class="shrink-0" class="shrink-0"
/> />
{/snippet} {/snippet}
{#snippet actions()} {#snippet actions()}
<Button size="sm" onclick={() => playAllOrToggle(queue)} disabled={queue.length === 0}> <Button size="sm" onclick={() => playAllOrToggle(tracks)} disabled={tracks.length === 0}>
{#if isCurrentQueue && player.playing} {#if isCurrentQueue && player.playing}
Pausar Pausar
{:else} {:else}
@@ -81,8 +61,8 @@
<Button <Button
variant="secondary" variant="secondary"
size="sm" size="sm"
onclick={() => playShuffled(queue)} onclick={() => playShuffled(tracks)}
disabled={queue.length === 0} disabled={tracks.length === 0}
> >
<Shuffle class="size-4" /> <Shuffle class="size-4" />
Aleatorio Aleatorio
@@ -95,7 +75,7 @@
{/if} {/if}
<TrackList <TrackList
tracks={listTracks} {tracks}
columns={['plays']} columns={['plays']}
onPlay={playFrom} onPlay={playFrom}
oncontextmenu={(e, _track, i) => trackMenu.open(e, tracks[i])} oncontextmenu={(e, _track, i) => trackMenu.open(e, tracks[i])}
@@ -113,9 +93,9 @@
]} ]}
playlistAction={{ playlistAction={{
action: '?/addTrack', action: '?/addTrack',
fields: { trackId: String(trackMenu.state.track.id) }, fields: { trackId: trackMenu.state.track.id },
label: 'Añadir a una playlist' label: 'Añadir a una playlist'
}} }}
playlists={data.playlists} playlists={data.userPlaylists}
/> />
{/if} {/if}
@@ -1,20 +1,16 @@
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 { import { apiFor, authedAction, requireUser, unwrapOrError } from '$lib/server/api';
createApiClient, import { PLAYLISTS_PAGE_SIZE, PLAYLIST_SUMMARY_TRACKS_PAGE_SIZE } from '$lib/config';
requireAccessTokenAction, import { toPlaylistSummary, toTrack } from '$lib/server/mappers';
requireUser, import { parsePlaylistForm } from '$lib/server/forms/playlistForm';
unwrapOrError import { uploadOptionalCover } from '$lib/server/upload';
} from '$lib/server/api';
import { uploadPresignedImage } from '$lib/server/upload';
const MAX_NAME = 100;
export const load: PageServerLoad = async ({ locals, url, fetch }) => { export const load: PageServerLoad = async ({ locals, url, fetch }) => {
const user = requireUser(locals, url); const user = requireUser(locals, url);
const api = createApiClient({ fetch, accessToken: locals.accessToken ?? undefined }); const api = apiFor({ fetch, locals });
const result = await api.GET('/playlists/users/{userId}', { const result = await api.GET('/playlists/users/{userId}', {
params: { path: { userId: user.sub }, query: { pageNumber: 1, pageSize: 50 } } params: { path: { userId: user.sub }, query: { pageNumber: 1, pageSize: PLAYLISTS_PAGE_SIZE } }
}); });
const data = unwrapOrError(result, 'No se pudieron cargar tus playlists.'); const data = unwrapOrError(result, 'No se pudieron cargar tus playlists.');
@@ -23,68 +19,54 @@ export const load: PageServerLoad = async ({ locals, url, fetch }) => {
data.items.map((playlist) => data.items.map((playlist) =>
api api
.GET('/playlists/{playlistId}/tracks', { .GET('/playlists/{playlistId}/tracks', {
params: { path: { playlistId: playlist.id }, query: { pageNumber: 1, pageSize: 500 } } params: {
path: { playlistId: playlist.id },
query: { pageNumber: 1, pageSize: PLAYLIST_SUMMARY_TRACKS_PAGE_SIZE }
}
}) })
.then((res) => res.data?.items ?? []) .then((res) => (res.data?.items ?? []).map(toTrack))
) )
); );
const items = data.items.map((playlist, i) => { const items = data.items.map((playlist, i) =>
const tracks = tracksByPlaylist[i]; toPlaylistSummary(playlist, tracksByPlaylist[i].length)
const trackCount = tracks.length; );
const durationSeconds = tracks.reduce((sum, t) => sum + Number(t.duration || 0), 0);
return { ...playlist, trackCount, durationSeconds };
});
const totals = { const totals = {
playlistCount: items.length, playlistCount: items.length,
trackCount: items.reduce((sum, p) => sum + p.trackCount, 0), trackCount: items.reduce((sum, playlist) => sum + playlist.trackCount, 0),
durationSeconds: items.reduce((sum, p) => sum + p.durationSeconds, 0) durationSeconds: tracksByPlaylist.flat().reduce((sum, track) => sum + track.duration, 0)
}; };
return { playlists: { ...data, items }, totals }; return { playlists: items, totals };
}; };
export const actions: Actions = { export const actions: Actions = {
create: async ({ request, locals, fetch }) => { create: authedAction(async ({ api, form }) => {
const accessToken = requireAccessTokenAction(locals); const parsed = parsePlaylistForm(form);
if (typeof accessToken !== 'string') return accessToken; if ('failMessage' in parsed) {
const { failMessage: message, name, description } = parsed;
return fail(400, { message, name, description });
}
const { name, description, visibility, cover } = parsed.body;
const form = await request.formData(); const uploaded = await uploadOptionalCover(api, '/playlists/upload-picture', cover);
const name = String(form.get('name') ?? '').trim(); if ('failMessage' in uploaded) {
const description = String(form.get('description') ?? '').trim(); return fail(502, {
const visibility = form.get('visibility') === 'public' ? 'Public' : 'Private'; message: uploaded.failMessage,
const cover = form.get('cover');
if (name === '' || name.length > MAX_NAME) {
return fail(400, {
message: 'El nombre es obligatorio (máx. 100 caracteres).',
name, name,
description description,
detail: uploaded.detail
}); });
} }
const api = createApiClient({ fetch, accessToken }); const { data, error } = await api.POST('/playlists', {
body: { name, description, pictureIntentId: uploaded.intentId, visibility }
let pictureIntentId: string | null = null;
if (cover instanceof File && cover.size > 0) {
const result = await uploadPresignedImage(
(args) => api.POST('/playlists/upload-picture', { body: args }),
cover
);
if ('failMessage' in result) {
return fail(502, { message: result.failMessage, name, description, detail: result.detail });
}
pictureIntentId = result.intentId;
}
const { data, error: err } = await api.POST('/playlists', {
body: { name, description, pictureIntentId, visibility }
}); });
if (err || !data) if (error || !data)
return fail(502, { message: 'No se pudo crear la playlist.', name, description }); return fail(502, { message: 'No se pudo crear la playlist.', name, description });
redirect(303, `/playlists/${data.id}`); redirect(303, `/playlists/${data.id}`);
} })
}; };
@@ -10,7 +10,7 @@
let { data } = $props(); let { data } = $props();
const items = $derived(data.playlists.items); const items = $derived(data.playlists);
const totals = $derived(data.totals); const totals = $derived(data.totals);
const summary = $derived( const summary = $derived(
`${plural(totals.playlistCount, 'playlist', 'playlists')} · ${plural(totals.trackCount, 'canción', 'canciones')} · ${fmtDurationLong(totals.durationSeconds)}` `${plural(totals.playlistCount, 'playlist', 'playlists')} · ${plural(totals.trackCount, 'canción', 'canciones')} · ${fmtDurationLong(totals.durationSeconds)}`
@@ -40,7 +40,7 @@
<MediaCard <MediaCard
href="/playlists/{playlist.id}" href="/playlists/{playlist.id}"
title={playlist.name} title={playlist.name}
subtitle={playlistMeta(Number(playlist.trackCount))} subtitle={playlistMeta(playlist.trackCount)}
trackIds={playlist.coverTrackIds} trackIds={playlist.coverTrackIds}
src="/api/playlists/{playlist.id}/cover?size=large&v={encodeURIComponent( src="/api/playlists/{playlist.id}/cover?size=large&v={encodeURIComponent(
playlist.updatedAt playlist.updatedAt
@@ -1,95 +1,80 @@
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 { import {
createApiClient, apiFor,
authedAction,
failOnError,
formString,
optionalUser, optionalUser,
requireAccessTokenAction, unwrapOrError
unwrapOrError,
unwrapOrFail
} from '$lib/server/api'; } from '$lib/server/api';
import { uploadPresignedImage } from '$lib/server/upload'; import { PLAYLIST_TRACKS_PAGE_SIZE } from '$lib/config';
import { toDatedTrack, toPlaylist } from '$lib/server/mappers';
const MAX_NAME = 100; import { parsePlaylistForm } from '$lib/server/forms/playlistForm';
import { uploadOptionalCover } from '$lib/server/upload';
export const load: PageServerLoad = async ({ params, locals, url, fetch, parent }) => { export const load: PageServerLoad = async ({ params, locals, url, fetch, parent }) => {
const { allowAnonymousListening } = await parent(); const { allowAnonymousListening } = await parent();
optionalUser(locals, url, allowAnonymousListening); const user = optionalUser(locals, url, allowAnonymousListening);
const api = createApiClient({ fetch, accessToken: locals.accessToken ?? undefined }); const api = apiFor({ fetch, locals });
const [playlistRes, tracksRes] = await Promise.all([ const [playlistRes, tracksRes] = await Promise.all([
api.GET('/playlists/{id}', { params: { path: { id: params.id } } }), api.GET('/playlists/{id}', { params: { path: { id: params.id } } }),
api.GET('/playlists/{playlistId}/tracks', { api.GET('/playlists/{playlistId}/tracks', {
params: { path: { playlistId: params.id }, query: { pageNumber: 1, pageSize: 200 } } params: {
path: { playlistId: params.id },
query: { pageNumber: 1, pageSize: PLAYLIST_TRACKS_PAGE_SIZE }
}
}) })
]); ]);
const playlist = unwrapOrError(playlistRes, 'Playlist no encontrada.', 404); const playlist = toPlaylist(unwrapOrError(playlistRes, 'Playlist no encontrada.', 404));
const tracks = tracksRes.data?.items ?? []; const tracks = (tracksRes.data?.items ?? []).map(toDatedTrack);
return { playlist, tracks }; const isOwner = user !== null && String(playlist.ownerUserId) === user.sub;
return { playlist, tracks, isOwner, section: isOwner ? '/playlists' : null };
}; };
export const actions: Actions = { export const actions: Actions = {
removeTrack: async ({ request, params, locals, fetch }) => { removeTrack: authedAction(async ({ api, form, params }) => {
const accessToken = requireAccessTokenAction(locals); const trackId = formString(form, 'trackId');
if (typeof accessToken !== 'string') return accessToken;
const trackId = String((await request.formData()).get('trackId') ?? '');
if (!trackId) return fail(400, { message: 'Falta la canción.' }); if (!trackId) return fail(400, { message: 'Falta la canción.' });
const api = createApiClient({ fetch, accessToken });
const result = await api.DELETE('/playlists/{playlistId}/tracks/{trackId}', { const result = await api.DELETE('/playlists/{playlistId}/tracks/{trackId}', {
params: { path: { playlistId: params.id, trackId } } params: { path: { playlistId: params.id, trackId } }
}); });
const failure = unwrapOrFail(result, 'No se pudo quitar la canción.'); return failOnError(result, 'No se pudo quitar la canción.') ?? { removed: true };
if (failure) return failure; }),
return { removed: true };
},
rename: async ({ request, params, locals, fetch }) => { edit: authedAction(async ({ api, form, params }) => {
const accessToken = requireAccessTokenAction(locals); const parsed = parsePlaylistForm(form);
if (typeof accessToken !== 'string') return accessToken; if ('failMessage' in parsed) return fail(400, { message: parsed.failMessage });
const form = await request.formData(); const { name, description, visibility, cover } = parsed.body;
const name = String(form.get('name') ?? '').trim();
const description = String(form.get('description') ?? '').trim();
const newVisibility = form.get('visibility') === 'public' ? 'Public' : 'Private';
const cover = form.get('cover');
if (name === '' || name.length > MAX_NAME) {
return fail(400, { message: 'El nombre es obligatorio (máx. 100 caracteres).' });
}
const api = createApiClient({ fetch, accessToken }); const uploaded = await uploadOptionalCover(api, '/playlists/upload-picture', cover);
if ('failMessage' in uploaded) {
let newPictureIntentId: string | null = null; return fail(502, { message: uploaded.failMessage, detail: uploaded.detail });
if (cover instanceof File && cover.size > 0) {
const result = await uploadPresignedImage(
(args) => api.POST('/playlists/upload-picture', { body: args }),
cover
);
if ('failMessage' in result) {
return fail(502, { message: result.failMessage, detail: result.detail });
}
newPictureIntentId = result.intentId;
} }
const result = await api.PUT('/playlists/{playlistId}', { const result = await api.PUT('/playlists/{playlistId}', {
params: { path: { playlistId: params.id } }, params: { path: { playlistId: params.id } },
body: { newName: name, newDescription: description, newPictureIntentId, newVisibility } body: {
newName: name,
newDescription: description,
newPictureIntentId: uploaded.intentId,
newVisibility: visibility
}
}); });
const failure = unwrapOrFail(result, 'No se pudo actualizar la playlist.'); return failOnError(result, 'No se pudo actualizar la playlist.') ?? { edited: true };
if (failure) return failure; }),
return { renamed: true };
},
delete: async ({ params, locals, fetch }) => { delete: authedAction(async ({ api, params }) => {
const accessToken = requireAccessTokenAction(locals);
if (typeof accessToken !== 'string') return accessToken;
const api = createApiClient({ fetch, accessToken });
const result = await api.DELETE('/playlists/{playlistId}', { const result = await api.DELETE('/playlists/{playlistId}', {
params: { path: { playlistId: params.id } } params: { path: { playlistId: params.id } }
}); });
const failure = unwrapOrFail(result, 'No se pudo eliminar la playlist.'); const failure = failOnError(result, 'No se pudo eliminar la playlist.');
if (failure) return failure; if (failure) return failure;
redirect(303, '/playlists'); redirect(303, '/playlists');
} })
}; };
@@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
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, toQueueItems, isQueueCurrent, playAllOrToggle } from '$lib/player/player.svelte'; import { player, isQueueCurrent, playAllOrToggle } from '$lib/player/player.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';
@@ -9,6 +9,7 @@
import Artwork from '$lib/components/ui/media/Artwork.svelte'; import Artwork from '$lib/components/ui/media/Artwork.svelte';
import PlaylistForm from '$lib/components/ui/forms/PlaylistForm.svelte'; import PlaylistForm from '$lib/components/ui/forms/PlaylistForm.svelte';
import Button from '$lib/components/ui/primitives/Button.svelte'; import Button from '$lib/components/ui/primitives/Button.svelte';
import BackLink from '$lib/components/ui/primitives/BackLink.svelte';
import EmptyState from '$lib/components/ui/primitives/EmptyState.svelte'; import EmptyState from '$lib/components/ui/primitives/EmptyState.svelte';
import TrackList from '$lib/components/ui/media/TrackList.svelte'; import TrackList from '$lib/components/ui/media/TrackList.svelte';
import { playlistMeta } from '$lib/utils/format'; import { playlistMeta } from '$lib/utils/format';
@@ -17,7 +18,7 @@
const playlist = $derived(data.playlist); const playlist = $derived(data.playlist);
const tracks = $derived(data.tracks); const tracks = $derived(data.tracks);
const listTracks = $derived(tracks.map((track) => ({ ...track, addedAt: track.createdAt }))); const isOwner = $derived(data.isOwner);
let editing = $state(false); let editing = $state(false);
let confirmingDelete = $state(false); let confirmingDelete = $state(false);
@@ -25,11 +26,11 @@
const isCurrentQueue = $derived(isQueueCurrent(tracks)); const isCurrentQueue = $derived(isQueueCurrent(tracks));
function playAll() { function playAll() {
playAllOrToggle(toQueueItems(tracks)); playAllOrToggle(tracks);
} }
function playFrom(index: number) { function playFrom(index: number) {
player.playOrToggle(toQueueItems(tracks), index); player.playOrToggle(tracks, index);
} }
</script> </script>
@@ -39,8 +40,13 @@
</svelte:head> </svelte:head>
<Page> <Page>
<BackLink
href={isOwner ? '/playlists' : '/'}
label={isOwner ? 'Volver a tus playlists' : 'Volver al inicio'}
/>
<PageHeader <PageHeader
eyebrow="Lista" eyebrow="Playlist"
title={playlist.name} title={playlist.name}
description={playlist.description ?? undefined} description={playlist.description ?? undefined}
meta="{playlistMeta(tracks.length)} · {playlist.visibility === 'Public' meta="{playlistMeta(tracks.length)} · {playlist.visibility === 'Public'
@@ -67,19 +73,23 @@
Reproducir Reproducir
{/if} {/if}
</Button> </Button>
{#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)}>
Eliminar Eliminar
</Button> </Button>
{/if}
{/snippet} {/snippet}
</PageHeader> </PageHeader>
{#if tracks.length > 0} {#if tracks.length > 0}
<TrackList <TrackList
tracks={listTracks} {tracks}
columns={['added', 'plays']} columns={['added', 'plays']}
onPlay={playFrom} onPlay={playFrom}
rowAction={{ action: '?/removeTrack', icon: X, label: 'Quitar de la playlist' }} rowAction={isOwner
? { action: '?/removeTrack', icon: X, label: 'Quitar de la playlist' }
: undefined}
/> />
{:else} {:else}
<EmptyState <EmptyState
@@ -96,7 +106,7 @@
<Modal open={editing} onClose={() => (editing = false)} title="Editar playlist"> <Modal open={editing} onClose={() => (editing = false)} title="Editar playlist">
<PlaylistForm <PlaylistForm
action="?/rename" action="?/edit"
initialName={playlist.name} initialName={playlist.name}
initialDescription={playlist.description ?? ''} initialDescription={playlist.description ?? ''}
initialVisibility={playlist.visibility === 'Public' ? 'public' : 'private'} initialVisibility={playlist.visibility === 'Public' ? 'public' : 'private'}
@@ -1,59 +1,33 @@
import type { Actions, PageServerLoad } from './$types'; import type { Actions, PageServerLoad } from './$types';
import { fail } from '@sveltejs/kit'; import { fail } from '@sveltejs/kit';
import { import { apiFor, authedAction, requireData, requireUser } from '$lib/server/api';
createApiClient, import { parseTrackUploadForm } from '$lib/server/forms/trackUploadForm';
requireAccessTokenAction,
requireUser,
unwrapOrFail
} from '$lib/server/api';
import { putPresigned, extOf, contentTypeOf, AUDIO_TYPES, IMAGE_TYPES } from '$lib/server/upload'; import { putPresigned, extOf, contentTypeOf, AUDIO_TYPES, IMAGE_TYPES } from '$lib/server/upload';
import { findConflict, genreInfo } from '$lib/data/genres'; import { findConflict, genreInfo } from '$lib/data/genres';
import type { components } from '$lib/api/schema'; import type { components } from '$lib/api/schema';
type Genre = components['schemas']['Genre']; type Genre = components['schemas']['Genre'];
const MAX_TITLE = 100;
export const load: PageServerLoad = async ({ locals, url, fetch }) => { export const load: PageServerLoad = async ({ locals, url, fetch }) => {
requireUser(locals, url); requireUser(locals, url);
const api = createApiClient({ fetch, accessToken: locals.accessToken ?? undefined }); const api = apiFor({ fetch, locals });
const { data } = await api.GET('/genres/available'); const { data } = await api.GET('/genres/available');
return { genres: data ?? [] }; return { genres: data ?? [] };
}; };
export const actions: Actions = { export const actions: Actions = {
default: async ({ request, locals, fetch }) => { default: authedAction(async ({ api, form }) => {
const accessToken = requireAccessTokenAction(locals, 'Inicia sesión para subir música.'); const parsed = parseTrackUploadForm(form);
if (typeof accessToken !== 'string') return accessToken; if ('failMessage' in parsed) return fail(400, { message: parsed.failMessage });
const { title, audio, cover, tags: rawTags, isExplicit } = parsed.body;
const form = await request.formData();
const title = String(form.get('title') ?? '').trim();
const audio = form.get('audio');
const cover = form.get('cover');
const rawTags = form.getAll('tags').map(String);
const isExplicit = form.get('isExplicit') === 'on';
if (title === '' || title.length > MAX_TITLE) {
return fail(400, { message: 'El título es obligatorio (máx. 100 caracteres).' });
}
if (!(audio instanceof File) || audio.size === 0) {
return fail(400, { message: 'Selecciona un archivo de audio.' });
}
if (!(cover instanceof File) || cover.size === 0) {
return fail(400, { message: 'Selecciona una portada.' });
}
if (rawTags.length === 0) {
return fail(400, { message: 'Elige al menos un género.' });
}
const api = createApiClient({ fetch, accessToken });
const { data: available } = await api.GET('/genres/available'); const { data: available } = await api.GET('/genres/available');
const options = available ?? []; const options = available ?? [];
const known = new Set<string>(options.map((option) => option.genre)); const known = new Set<string>(options.map((option) => option.genre));
if (!rawTags.every((tag) => known.has(tag))) { if (!rawTags.every((tag) => known.has(tag))) {
return fail(400, { message: 'Alguno de los géneros no es válido.' }); return fail(400, { message: 'Alguno de los géneros no es válido.' });
} }
const tags = [...new Set(rawTags)] as Genre[]; const tags = rawTags as Genre[];
const conflict = findConflict(tags, options); const conflict = findConflict(tags, options);
if (conflict) { if (conflict) {
return fail(400, { return fail(400, {
@@ -75,9 +49,11 @@ export const actions: Actions = {
} }
}); });
const urlsFailure = unwrapOrFail(urlsResult, 'No se pudieron reservar las URLs de subida.'); const { data: urls, failure: urlsFailure } = requireData(
urlsResult,
'No se pudieron reservar las URLs de subida.'
);
if (urlsFailure) return urlsFailure; if (urlsFailure) return urlsFailure;
const urls = urlsResult.data!;
try { try {
const [audioBuffer, coverBuffer] = await Promise.all([ const [audioBuffer, coverBuffer] = await Promise.all([
@@ -105,10 +81,12 @@ export const actions: Actions = {
} }
}); });
const trackFailure = unwrapOrFail(trackResult, 'No se pudo crear la pista tras la subida.'); const { data: track, failure: trackFailure } = requireData(
trackResult,
'No se pudo crear la pista tras la subida.'
);
if (trackFailure) return trackFailure; if (trackFailure) return trackFailure;
const track = trackResult.data!;
return { success: true, trackId: track.id, title: track.title }; return { success: true, trackId: track.id, title: track.title };
} }, 'Inicia sesión para subir música.')
}; };
@@ -15,13 +15,13 @@
import Button from '$lib/components/ui/primitives/Button.svelte'; import Button from '$lib/components/ui/primitives/Button.svelte';
import ExplicitBadge from '$lib/components/ui/media/ExplicitBadge.svelte'; import ExplicitBadge from '$lib/components/ui/media/ExplicitBadge.svelte';
import Chip from '$lib/components/ui/primitives/Chip.svelte'; import Chip from '$lib/components/ui/primitives/Chip.svelte';
import { LIMITS } from '$lib/validation';
import { formatSize } from '$lib/utils/format';
import { genreOptions } from '$lib/data/genres'; import { genreOptions } from '$lib/data/genres';
type Status = 'idle' | 'uploading' | 'done' | 'error'; type Status = 'idle' | 'uploading' | 'done' | 'error';
type StepState = 'idle' | 'active' | 'done'; type StepState = 'idle' | 'active' | 'done';
const MAX_TITLE = 100;
let { data } = $props(); let { data } = $props();
let title = $state(''); let title = $state('');
@@ -70,12 +70,6 @@
return s === 'idle' ? '' : 'bg-accent-tint'; return s === 'idle' ? '' : 'bg-accent-tint';
} }
function formatSize(bytes: number) {
if (bytes === 0) return '';
const mb = bytes / (1024 * 1024);
return mb >= 1 ? `${mb.toFixed(1)} MB` : `${Math.round(bytes / 1024)} KB`;
}
function pickAudio(file: File) { function pickAudio(file: File) {
audioName = file.name; audioName = file.name;
audioSize = file.size; audioSize = file.size;
@@ -256,12 +250,12 @@
id="title" id="title"
name="title" name="title"
bind:value={title} bind:value={title}
maxlength={MAX_TITLE} maxlength={LIMITS.trackTitle}
placeholder="Nombre de la canción" placeholder="Nombre de la canción"
/> />
{#snippet hint()} {#snippet hint()}
<p>Se usa como nombre público de la pista en el catálogo.</p> <p>Se usa como nombre público de la pista en el catálogo.</p>
<span class="shrink-0 tabular-nums">{title.length}/{MAX_TITLE}</span> <span class="shrink-0 tabular-nums">{title.length}/{LIMITS.trackTitle}</span>
{/snippet} {/snippet}
</Field> </Field>
@@ -299,14 +293,9 @@
</Surface> </Surface>
<Checkbox bind:checked={acceptedTerms}> <Checkbox bind:checked={acceptedTerms}>
Acepto los Acepto los términos y condiciones al subir {title.trim() !== ''
<a ? `«${title}»`
href="/terms" : 'esta canción'}.
class="text-accent-soft underline underline-offset-2 transition hover:brightness-110"
>
términos y condiciones
</a>
al subir {title.trim() !== '' ? `«${title}»` : 'esta canción'}.
</Checkbox> </Checkbox>
{#if status === 'uploading'} {#if status === 'uploading'}
@@ -1,13 +1,13 @@
import type { PageServerLoad, Actions } from './$types'; import type { PageServerLoad, Actions } from './$types';
import { createApiClient, optionalUser, unwrapOrError } from '$lib/server/api'; import { apiFor, optionalUser, unwrapOrError } from '$lib/server/api';
import { toPlaylistSummary } from '$lib/server/mappers';
import { COUNT_ONLY_PAGE_SIZE, PROFILE_PLAYLISTS_PAGE_SIZE } from '$lib/config';
import { followUserAction, unfollowUserAction } from '$lib/server/followActions'; import { followUserAction, unfollowUserAction } from '$lib/server/followActions';
const PROFILE_PLAYLISTS_PAGE_SIZE = 50;
export const load: PageServerLoad = async ({ params, locals, url, fetch, parent }) => { export const load: PageServerLoad = async ({ params, locals, url, fetch, parent }) => {
const { allowAnonymousListening } = await parent(); const { allowAnonymousListening } = await parent();
const viewer = optionalUser(locals, url, allowAnonymousListening); const viewer = optionalUser(locals, url, allowAnonymousListening);
const api = createApiClient({ fetch, accessToken: locals.accessToken ?? undefined }); const api = apiFor({ fetch, locals });
const [profileRes, playlistsRes] = await Promise.all([ const [profileRes, playlistsRes] = await Promise.all([
api.GET('/users/{id}/profile', { params: { path: { id: params.id } } }), api.GET('/users/{id}/profile', { params: { path: { id: params.id } } }),
@@ -26,20 +26,28 @@ export const load: PageServerLoad = async ({ params, locals, url, fetch, parent
playlistItems.map((playlist) => playlistItems.map((playlist) =>
api api
.GET('/playlists/{playlistId}/tracks', { .GET('/playlists/{playlistId}/tracks', {
params: { path: { playlistId: playlist.id }, query: { pageNumber: 1, pageSize: 1 } } params: {
path: { playlistId: playlist.id },
query: { pageNumber: 1, pageSize: COUNT_ONLY_PAGE_SIZE }
}
}) })
.then((res) => Number(res.data?.totalItemCount ?? 0)) .then((res) => Number(res.data?.totalItemCount ?? 0))
) )
); );
const playlists = playlistItems.map((playlist, i) => ({ const playlists = playlistItems.map((playlist, i) =>
...playlist, toPlaylistSummary(playlist, tracksByPlaylist[i])
trackCount: tracksByPlaylist[i] );
}));
const isOwnProfile = viewer !== null && viewer.sub === params.id; const isOwnProfile = viewer !== null && viewer.sub === params.id;
return { profile, playlists, isOwnProfile, isAnonymous: viewer === null }; return {
profile,
followersCount: Number(profile.followersCount),
playlists,
isOwnProfile,
isAnonymous: viewer === null
};
}; };
export const actions: Actions = { export const actions: Actions = {
@@ -26,7 +26,7 @@
{/snippet} {/snippet}
{#snippet followStats()} {#snippet followStats()}
{@const followers = plural(Number(profile.followersCount), 'seguidor', 'seguidores')} {@const followers = plural(data.followersCount, 'seguidor', 'seguidores')}
{#if data.isOwnProfile || profile.canViewFollowers} {#if data.isOwnProfile || profile.canViewFollowers}
<a href="/user/{profile.id}/followers" class="transition-colors hover:text-fg">{followers}</a> <a href="/user/{profile.id}/followers" class="transition-colors hover:text-fg">{followers}</a>
{:else} {:else}
@@ -60,7 +60,7 @@
<MediaCard <MediaCard
href="/playlists/{playlist.id}" href="/playlists/{playlist.id}"
title={playlist.name} title={playlist.name}
subtitle={playlistMeta(Number(playlist.trackCount))} subtitle={playlistMeta(playlist.trackCount)}
trackIds={playlist.coverTrackIds} trackIds={playlist.coverTrackIds}
src="/api/playlists/{playlist.id}/cover?size=large&v={encodeURIComponent( src="/api/playlists/{playlist.id}/cover?size=large&v={encodeURIComponent(
playlist.updatedAt playlist.updatedAt
@@ -1,13 +1,13 @@
import type { PageServerLoad, Actions } from './$types'; import type { PageServerLoad } from './$types';
import { createApiClient, optionalUser, unwrapOrError } from '$lib/server/api'; import { apiFor, optionalUser, unwrapOrError } from '$lib/server/api';
import { toPage } from '$lib/server/mappers';
import { fetchFollowList } from '$lib/server/follows'; import { fetchFollowList } from '$lib/server/follows';
import { followUserAction, unfollowUserAction } from '$lib/server/followActions';
import { FOLLOW_LIST_PAGE_SIZE } from '$lib/config'; import { FOLLOW_LIST_PAGE_SIZE } from '$lib/config';
export const load: PageServerLoad = async ({ params, locals, url, fetch, parent }) => { export const load: PageServerLoad = async ({ params, locals, url, fetch, parent }) => {
const { allowAnonymousListening } = await parent(); const { allowAnonymousListening } = await parent();
const viewer = optionalUser(locals, url, allowAnonymousListening); const viewer = optionalUser(locals, url, allowAnonymousListening);
const api = createApiClient({ fetch, accessToken: locals.accessToken ?? undefined }); const api = apiFor({ fetch, locals });
const [profileRes, listRes] = await Promise.all([ const [profileRes, listRes] = await Promise.all([
api.GET('/users/{id}/profile', { params: { path: { id: params.id } } }), api.GET('/users/{id}/profile', { params: { path: { id: params.id } } }),
@@ -20,13 +20,10 @@ export const load: PageServerLoad = async ({ params, locals, url, fetch, parent
return { return {
list: params.list, list: params.list,
profile, profile,
users: forbidden ? null : unwrapOrError(listRes, 'No se pudo cargar la lista.'), users: forbidden
? null
: toPage(unwrapOrError(listRes, 'No se pudo cargar la lista.'), (item) => item),
forbidden, forbidden,
viewerId: viewer?.sub ?? null viewerId: viewer?.sub ?? null
}; };
}; };
export const actions: Actions = {
follow: followUserAction,
unfollow: unfollowUserAction
};
@@ -5,12 +5,11 @@
import PageHeader from '$lib/components/ui/layout/PageHeader.svelte'; import PageHeader from '$lib/components/ui/layout/PageHeader.svelte';
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 Alert from '$lib/components/ui/primitives/Alert.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 { appendUnique } from '$lib/data/collections';
import { plural } from '$lib/utils/format'; import { plural } from '$lib/utils/format';
let { data, form } = $props(); let { data } = $props();
type ListUser = NonNullable<typeof data.users>['items'][number]; type ListUser = NonNullable<typeof data.users>['items'][number];
@@ -21,13 +20,13 @@
$effect(() => { $effect(() => {
items = data.users?.items ?? []; items = data.users?.items ?? [];
page = Number(data.users?.pageNumber ?? 1); page = data.users?.pageNumber ?? 1;
hasMore = data.users?.hasNextPage ?? false; 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(Number(data.users?.totalItemCount ?? 0)); const total = $derived(data.users?.totalItemCount ?? 0);
async function loadMore() { async function loadMore() {
if (loadingMore) return; if (loadingMore) return;
@@ -37,7 +36,7 @@
if (!res.ok) throw new Error(String(res.status)); if (!res.ok) throw new Error(String(res.status));
const next = (await res.json()) as NonNullable<typeof data.users>; const next = (await res.json()) as NonNullable<typeof data.users>;
items = appendUnique(items, next.items, (u) => u.id); items = appendUnique(items, next.items, (u) => u.id);
page = Number(next.pageNumber); page = next.pageNumber;
hasMore = next.hasNextPage; hasMore = next.hasNextPage;
} catch { } catch {
hasMore = false; hasMore = false;
@@ -62,10 +61,6 @@
: plural(total, 'usuario', 'usuarios')} : plural(total, 'usuario', 'usuarios')}
/> />
{#if form?.message}
<Alert class="mb-4">{form.message}</Alert>
{/if}
{#if data.forbidden} {#if data.forbidden}
<EmptyState <EmptyState
icon={Lock} icon={Lock}
+1 -1
View File
@@ -10,7 +10,7 @@
<p class="mb-6.5 text-body text-fg-2"> <p class="mb-6.5 text-body text-fg-2">
{page.status === 404 {page.status === 404
? 'Comprueba el enlace o vuelve al inicio.' ? 'Comprueba el enlace o vuelve al inicio.'
: 'La conexión se interrumpió. Lo que tengas descargado seguirá disponible sin conexión.'} : 'Algo salió mal al cargar la página. Inténtalo de nuevo en unos momentos.'}
</p> </p>
<Button href="/" variant="secondary">Volver</Button> <Button href="/" variant="secondary">Volver</Button>
</div> </div>
+43 -59
View File
@@ -1,92 +1,76 @@
import { redirect } from '@sveltejs/kit';
import type { LayoutServerLoad } from './$types'; import type { LayoutServerLoad } from './$types';
import { getAllowAnonymousListening } from '$lib/server/playbackConfig'; import { getAllowAnonymousListening } from '$lib/server/playbackConfig';
import { createApiClient } from '$lib/server/api'; import { apiFor, loginRedirect } from '$lib/server/api';
import { toLikedTrack, toPlaylist, toTrack } from '$lib/server/mappers';
import { authConfig } from '$lib/server/config'; import { authConfig } from '$lib/server/config';
import { LIKED_TRACKS_PAGE_SIZE } from '$lib/config'; import { LIKED_TRACKS_PAGE_SIZE, PLAYLIST_PICKER_PAGE_SIZE } from '$lib/config';
const PUBLIC_PATHS = new Set(['/auth']); const PUBLIC_PATHS = new Set(['/auth']);
const SIDEBAR_PLAYLISTS_LIMIT = 8;
async function fetchSidebarPlaylists( function orFallback<T>(load: Promise<T>, fallback: T): Promise<T> {
fetchFn: typeof fetch, return load.catch(() => fallback);
accessToken: string | null,
userId: string
) {
try {
const api = createApiClient({ fetch: fetchFn, accessToken: accessToken ?? undefined });
const { data } = await api.GET('/playlists/users/{userId}', {
params: {
path: { userId },
query: { pageNumber: 1, pageSize: SIDEBAR_PLAYLISTS_LIMIT }
}
});
return data?.items ?? [];
} catch {
return [];
}
}
async function fetchLikedTracks(fetchFn: typeof fetch, accessToken: string | null) {
try {
const api = createApiClient({ fetch: fetchFn, accessToken: accessToken ?? undefined });
const { data } = await api.GET('/likes', {
params: { query: { pageNumber: 1, pageSize: LIKED_TRACKS_PAGE_SIZE } }
});
return data?.items ?? [];
} catch {
return [];
}
}
async function fetchLastPlayedTrack(
fetchFn: typeof fetch,
accessToken: string | null,
userId: string
) {
try {
const api = createApiClient({ fetch: fetchFn, accessToken: accessToken ?? undefined });
const { data } = await api.GET('/users/{id}/last-listened-track', {
params: { path: { id: userId } }
});
return data ?? null;
} catch {
return null;
}
} }
export const load: LayoutServerLoad = async ({ locals, url, fetch }) => { export const load: LayoutServerLoad = async ({ locals, url, fetch }) => {
const allowAnonymousListening = await getAllowAnonymousListening(fetch); const allowAnonymousListening = await getAllowAnonymousListening(fetch);
if (!locals.user && !allowAnonymousListening && !PUBLIC_PATHS.has(url.pathname)) { if (!locals.user && !allowAnonymousListening && !PUBLIC_PATHS.has(url.pathname)) {
redirect(302, `/auth?returnTo=${encodeURIComponent(url.pathname + url.search)}`); loginRedirect(url);
} }
const accountUrl = authConfig.issuer const accountUrl = `${authConfig.issuer.replace(/\/+$/, '')}/ui/console`;
? `${authConfig.issuer.replace(/\/+$/, '')}/ui/console`
: null;
if (!locals.user) { if (!locals.user) {
return { return {
user: locals.user, user: locals.user,
allowAnonymousListening, allowAnonymousListening,
sidebarPlaylists: [], userPlaylists: [],
userPlaylistsTotal: 0,
likedTracks: [], likedTracks: [],
lastPlayedTrack: null, lastPlayedTrack: null,
accountUrl accountUrl
}; };
} }
const [sidebarPlaylists, likedTracks, lastPlayedTrack] = await Promise.all([ const api = apiFor({ fetch, locals });
fetchSidebarPlaylists(fetch, locals.accessToken, locals.user.sub), const userId = locals.user.sub;
fetchLikedTracks(fetch, locals.accessToken),
fetchLastPlayedTrack(fetch, locals.accessToken, locals.user.sub) const [userPlaylists, likedTracks, lastPlayedTrack] = await Promise.all([
orFallback(
api
.GET('/playlists/users/{userId}', {
params: {
path: { userId },
query: { pageNumber: 1, pageSize: PLAYLIST_PICKER_PAGE_SIZE }
}
})
.then(({ data }) => ({
items: (data?.items ?? []).map(toPlaylist),
total: Number(data?.totalItemCount ?? 0)
})),
{ items: [], total: 0 }
),
orFallback(
api
.GET('/likes', {
params: { query: { pageNumber: 1, pageSize: LIKED_TRACKS_PAGE_SIZE } }
})
.then(({ data }) => (data?.items ?? []).map(toLikedTrack)),
[]
),
orFallback(
api
.GET('/users/{id}/last-listened-track', { params: { path: { id: userId } } })
.then(({ data }) => (data ? toTrack(data) : null)),
null
)
]); ]);
return { return {
user: locals.user, user: locals.user,
allowAnonymousListening, allowAnonymousListening,
sidebarPlaylists, userPlaylists: userPlaylists.items,
userPlaylistsTotal: userPlaylists.total,
likedTracks, likedTracks,
lastPlayedTrack, lastPlayedTrack,
accountUrl accountUrl
+9 -4
View File
@@ -41,10 +41,11 @@
const initialThemeCss = `:root{${tokensToCss(buildThemeTokens(ACCENT_HUE, 'dark'))}}:root[data-theme='light']{${tokensToCss(buildThemeTokens(ACCENT_HUE, 'light'))}}`; const initialThemeCss = `:root{${tokensToCss(buildThemeTokens(ACCENT_HUE, 'dark'))}}:root[data-theme='light']{${tokensToCss(buildThemeTokens(ACCENT_HUE, 'light'))}}`;
let wasPlaying = false; let queueAutoOpened = false;
$effect(() => { $effect(() => {
if (player.playing && !wasPlaying) queuePanel.show(); if (!player.playing || queueAutoOpened) return;
wasPlaying = player.playing; queueAutoOpened = true;
queuePanel.show();
}); });
function isTypingTarget(target: EventTarget | null): boolean { function isTypingTarget(target: EventTarget | null): boolean {
@@ -107,7 +108,11 @@
<div class="app-backdrop"></div> <div class="app-backdrop"></div>
<div class="app-vignette"></div> <div class="app-vignette"></div>
<Sidebar user={data.user} playlists={data.sidebarPlaylists ?? []} /> <Sidebar
user={data.user}
playlists={data.userPlaylists ?? []}
playlistCount={data.userPlaylistsTotal ?? 0}
/>
<div class="flex min-w-0 flex-1 flex-col"> <div class="flex min-w-0 flex-1 flex-col">
<div class="flex min-h-0 flex-1"> <div class="flex min-h-0 flex-1">
+14 -20
View File
@@ -1,23 +1,16 @@
import { redirect } from '@sveltejs/kit'; import { redirect } from '@sveltejs/kit';
import type { PageServerLoad, Actions } from './$types'; import type { PageServerLoad, Actions } from './$types';
import { createApiClient, requireUser } from '$lib/server/api'; import { apiFor, requireUser } from '$lib/server/api';
import { toMix, toPlaylist, toTrack } from '$lib/server/mappers';
import { addTrackAction } from '$lib/server/playlistActions'; import { addTrackAction } from '$lib/server/playlistActions';
import { import { HOME_MIXES_LIMIT, HOME_SHELF_LIMIT, HOME_SPOTLIGHT_TRACKS_LIMIT } from '$lib/config';
HOME_LATEST_PAGE_SIZE,
HOME_MIXES_LIMIT,
HOME_SHELF_LIMIT,
HOME_SPOTLIGHT_TRACKS_LIMIT
} from '$lib/config';
import { pickGreeting } from '$lib/server/greeting'; import { pickGreeting } from '$lib/server/greeting';
export const load: PageServerLoad = async ({ locals, url, fetch }) => { export const load: PageServerLoad = async ({ locals, url, fetch }) => {
if (!locals.user) redirect(302, '/explore'); if (!locals.user) redirect(302, '/explore');
const user = requireUser(locals, url); const user = requireUser(locals, url);
const api = createApiClient({ fetch, accessToken: locals.accessToken ?? undefined }); const api = apiFor({ fetch, locals });
const latestPromise = api.GET('/tracks', {
params: { query: { pageNumber: 1, pageSize: HOME_LATEST_PAGE_SIZE } }
});
const playlistsPromise = api.GET('/playlists/users/{userId}', { const playlistsPromise = api.GET('/playlists/users/{userId}', {
params: { params: {
path: { userId: user.sub }, path: { userId: user.sub },
@@ -30,17 +23,19 @@ export const load: PageServerLoad = async ({ locals, url, fetch }) => {
}); });
const listeningStatsPromise = api const listeningStatsPromise = api
.GET('/users/{id}/listening-stats', { params: { path: { id: user.sub } } }) .GET('/users/{id}/listening-stats', { params: { path: { id: user.sub } } })
.then((res) => res.data ?? { tracksThisWeek: 0, secondsThisWeek: 0, streakDays: 0 }) .then((res) => ({
tracksThisWeek: Number(res.data?.tracksThisWeek ?? 0),
secondsThisWeek: Number(res.data?.secondsThisWeek ?? 0),
streakDays: Number(res.data?.streakDays ?? 0)
}))
.catch(() => ({ tracksThisWeek: 0, secondsThisWeek: 0, streakDays: 0 })); .catch(() => ({ tracksThisWeek: 0, secondsThisWeek: 0, streakDays: 0 }));
const [latest, playlists, mixes, recentlyPlayed] = await Promise.all([ const [playlists, mixes, recentlyPlayed] = await Promise.all([
latestPromise,
playlistsPromise, playlistsPromise,
mixesPromise, mixesPromise,
recentlyPlayedPromise recentlyPlayedPromise
]); ]);
const playlistItems = playlists.data?.items ?? []; const playlistItems = (playlists.data?.items ?? []).map(toPlaylist);
const latestItems = (latest.data?.items ?? []).map((item) => item.track);
const spotlightPlaylist = playlistItems[0] ?? null; const spotlightPlaylist = playlistItems[0] ?? null;
const spotlightTracksPromise = spotlightPlaylist const spotlightTracksPromise = spotlightPlaylist
@@ -52,7 +47,7 @@ export const load: PageServerLoad = async ({ locals, url, fetch }) => {
} }
}) })
.then((res) => ({ .then((res) => ({
items: res.data?.items ?? [], items: (res.data?.items ?? []).map(toTrack),
totalCount: Number(res.data?.totalItemCount ?? 0) totalCount: Number(res.data?.totalItemCount ?? 0)
})) }))
.catch(() => ({ items: [], totalCount: 0 })) .catch(() => ({ items: [], totalCount: 0 }))
@@ -60,11 +55,10 @@ export const load: PageServerLoad = async ({ locals, url, fetch }) => {
return { return {
greeting: pickGreeting(new Date()), greeting: pickGreeting(new Date()),
recentlyPlayed: recentlyPlayed.data ?? [], recentlyPlayed: (recentlyPlayed.data ?? []).map(toTrack),
mixes: (mixes.data ?? []).slice(0, HOME_MIXES_LIMIT), mixes: (mixes.data ?? []).slice(0, HOME_MIXES_LIMIT).map(toMix),
playlists: playlistItems.slice(0, HOME_SHELF_LIMIT), playlists: playlistItems.slice(0, HOME_SHELF_LIMIT),
playlistsHasMore: playlistItems.length > HOME_SHELF_LIMIT, playlistsHasMore: playlistItems.length > HOME_SHELF_LIMIT,
newReleases: latestItems,
spotlightPlaylist, spotlightPlaylist,
spotlightTracks: spotlightTracksPromise, spotlightTracks: spotlightTracksPromise,
listeningStats: listeningStatsPromise listeningStats: listeningStatsPromise
+49 -138
View File
@@ -3,23 +3,14 @@
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 { import { player, isQueueCurrent, playAllOrToggle, playShuffled } from '$lib/player/player.svelte';
player, import type { Track } from '$lib/types';
isQueueCurrent,
playAllOrToggle,
playShuffled,
toQueueItem,
trackFromQueueItem,
type ApiTrackLike
} from '$lib/player/player.svelte';
import { pressable } from '$lib/actions/pressable';
import { mergeRecentlyPlayed } from '$lib/data/recentlyPlayed'; import { mergeRecentlyPlayed } from '$lib/data/recentlyPlayed';
import { mixItemTrack } from '$lib/data/mixes'; import { fetchPlaylistTracks } from '$lib/data/albums';
import { fmtTime, fmtDurationLong, fmtPlays, plural } from '$lib/utils/format'; import { fmtTime, fmtDurationLong, plural } from '$lib/utils/format';
import Page from '$lib/components/ui/layout/Page.svelte'; import Page from '$lib/components/ui/layout/Page.svelte';
import Artwork from '$lib/components/ui/media/Artwork.svelte'; import Artwork from '$lib/components/ui/media/Artwork.svelte';
import MediaCard from '$lib/components/ui/media/MediaCard.svelte'; import MediaCard from '$lib/components/ui/media/MediaCard.svelte';
import EqBars from '$lib/components/ui/media/EqBars.svelte';
import ListRow from '$lib/components/ui/media/ListRow.svelte'; import ListRow from '$lib/components/ui/media/ListRow.svelte';
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';
@@ -27,92 +18,40 @@
import ContextMenu from '$lib/components/ui/overlay/ContextMenu.svelte'; import ContextMenu from '$lib/components/ui/overlay/ContextMenu.svelte';
import ListPlus from '@lucide/svelte/icons/list-plus'; import ListPlus from '@lucide/svelte/icons/list-plus';
import { createTrackMenu } from '$lib/state/menus.svelte'; import { createTrackMenu } from '$lib/state/menus.svelte';
import { HOME_CONTINUE_LIMIT, HOME_POPULAR_MAX } from '$lib/config'; import { HOME_CONTINUE_LIMIT } from '$lib/config';
let { data } = $props(); let { data } = $props();
const trackMenu = createTrackMenu(); const trackMenu = createTrackMenu();
type SpotlightTrack = Awaited<typeof data.spotlightTracks>['items'][number];
type SpotlightRow = { track: SpotlightTrack; seconds: number };
let spotlightRows = $state<SpotlightRow[]>([]);
let spotlightTotal = $state(0);
$effect(() => {
spotlightRows = [];
spotlightTotal = 0;
let cancelled = false;
data.spotlightTracks.then(({ items, totalCount }) => {
if (cancelled) return;
spotlightRows = items.map((track) => ({
track,
seconds: Number(track.duration)
}));
spotlightTotal = totalCount;
});
return () => {
cancelled = true;
};
});
const mixes = $derived(data.mixes); const mixes = $derived(data.mixes);
const playlists = $derived(data.playlists); const playlists = $derived(data.playlists);
const spotlight = $derived(data.spotlightPlaylist); const spotlight = $derived(data.spotlightPlaylist);
const spotlightItems = $derived(spotlightRows.map((row) => row.track));
const spotlightDurationSeconds = $derived(
spotlightRows.reduce((sum, row) => sum + row.seconds, 0)
);
const spotlightTrackCount = $derived(spotlightTotal || spotlightItems.length);
const spotlightVisibilityLabel = $derived(
spotlight?.visibility === 'Public' ? 'Pública' : 'Privada'
);
const spotlightMeta = $derived(
spotlightTrackCount === 0
? spotlightVisibilityLabel
: [
plural(spotlightTrackCount, 'canción', 'canciones'),
fmtDurationLong(spotlightDurationSeconds),
spotlightVisibilityLabel
].join(' · ')
);
const continueItems = $derived(mergeRecentlyPlayed(data.recentlyPlayed, HOME_CONTINUE_LIMIT)); const continueItems = $derived(mergeRecentlyPlayed(data.recentlyPlayed, HOME_CONTINUE_LIMIT));
const recentPool = $derived(mergeRecentlyPlayed(data.recentlyPlayed, 24));
const popularTracks = $derived<ApiTrackLike[]>(
recentPool.map(trackFromQueueItem).slice(0, HOME_POPULAR_MAX)
);
const spotlightQueue = $derived(spotlightItems.map(toQueueItem));
const spotlightIsCurrent = $derived(isQueueCurrent(spotlightQueue));
const heroPlaying = $derived(spotlightIsCurrent && player.playing);
function playContinue(index: number) { function playContinue(index: number) {
player.playOrToggle(continueItems, index); player.playOrToggle(continueItems, index);
} }
function playPopular(index: number) { function playSpotlightTrack(items: Track[], index: number) {
player.playOrToggle(popularTracks.map(toQueueItem), index); player.playOrToggle(items, index);
} }
function playSpotlightTrack(index: number) { async function playSpotlight(previewItems: Track[]) {
player.playOrToggle(spotlightQueue, index); if (!spotlight) return;
if (isQueueCurrent(previewItems)) {
player.toggle();
return;
} }
function playSpotlight() { playAllOrToggle(await fetchPlaylistTracks(spotlight.id));
playAllOrToggle(spotlightQueue);
} }
function shuffleSpotlight() { async function shuffleSpotlight() {
playShuffled(spotlightQueue); if (!spotlight) return;
} playShuffled(await fetchPlaylistTracks(spotlight.id));
function openContextMenu(event: MouseEvent, track: ApiTrackLike) {
trackMenu.open(event, track);
} }
function playMix(mix: (typeof mixes)[number], event: MouseEvent) { function playMix(mix: (typeof mixes)[number], event: MouseEvent) {
event.preventDefault(); event.preventDefault();
player.playQueue( player.playQueue(mix.tracks, 0);
mix.items.map((item) => toQueueItem(mixItemTrack(item))),
0
);
} }
</script> </script>
@@ -149,10 +88,12 @@
</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}
<Button variant="primary" onclick={playSpotlight}> {#await data.spotlightTracks then { items: previewItems }}
{heroPlaying ? 'Pausar' : 'Reanudar'} <Button variant="primary" onclick={() => playSpotlight(previewItems)}>
{isQueueCurrent(previewItems) && player.playing ? 'Pausar' : 'Reanudar'}
{spotlight.name} {spotlight.name}
</Button> </Button>
{/await}
{/if} {/if}
<Button href="/explore" variant="secondary">Explorar música</Button> <Button href="/explore" variant="secondary">Explorar música</Button>
</div> </div>
@@ -162,7 +103,7 @@
{@const statsList = [ {@const statsList = [
{ value: stats.tracksThisWeek, label: 'Canciones nuevas escuchadas esta semana' }, { value: stats.tracksThisWeek, label: 'Canciones nuevas escuchadas esta semana' },
{ {
value: fmtDurationLong(Number(stats.secondsThisWeek)), value: fmtDurationLong(stats.secondsThisWeek),
label: 'Tiempo de escucha esta semana' label: 'Tiempo de escucha esta semana'
}, },
{ value: stats.streakDays, label: 'Días consecutivos escuchando' } { value: stats.streakDays, label: 'Días consecutivos escuchando' }
@@ -181,14 +122,14 @@
<div class="mt-10 flex flex-col gap-section"> <div class="mt-10 flex flex-col gap-section">
<section> <section>
<SectionHeading title="Continuar escuchando" subtitle="Retomalo donde lo dejaste" /> <SectionHeading title="Continuar escuchando" subtitle="Retómalo donde lo dejaste" />
{#if continueItems.length > 0} {#if continueItems.length > 0}
<div class="grid grid-cols-2 gap-2.5 sm:grid-cols-3 lg:grid-cols-5"> <div class="grid grid-cols-2 gap-2.5 sm:grid-cols-3 lg:grid-cols-5">
{#each continueItems as item, i (item.id)} {#each continueItems as item, i (item.id)}
{@const isCurrent = player.current.id === item.id} {@const isCurrent = player.currentId === item.id}
<ListRow <ListRow
onclick={() => playContinue(i)} onclick={() => playContinue(i)}
oncontextmenu={(e) => openContextMenu(e, trackFromQueueItem(item))} oncontextmenu={(e) => trackMenu.open(e, item)}
active={isCurrent} active={isCurrent}
size="lg" size="lg"
variant="card" variant="card"
@@ -209,14 +150,18 @@
{#if spotlight} {#if spotlight}
<section> <section>
{#await data.spotlightTracks then { items: previewItems, totalCount }}
{@const visibilityLabel = spotlight.visibility === 'Public' ? 'Pública' : 'Privada'}
{@const meta =
totalCount === 0
? visibilityLabel
: `${plural(totalCount, 'canción', 'canciones')} · ${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"
> >
<div class="flex flex-col justify-center p-8 sm:p-9"> <div class="flex flex-col justify-center p-8 sm:p-9">
<div <a
role="button" href="/playlists/{spotlight.id}"
tabindex="0"
use:pressable={() => goto(`/playlists/${spotlight.id}`)}
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
@@ -233,27 +178,29 @@
<h3 class="truncate text-display-2 text-fg"> <h3 class="truncate text-display-2 text-fg">
{spotlight.name} {spotlight.name}
</h3> </h3>
<p class="text-sm text-fg-2">{spotlightMeta}</p> <p class="text-sm text-fg-2">{meta}</p>
</div>
</div> </div>
</a>
<p class="mt-5 max-w-prose-sm text-sm text-fg-2"> <p class="mt-5 max-w-prose-sm text-sm text-fg-2">
{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}>Reproducir</Button> <Button variant="accent" onclick={() => playSpotlight(previewItems)}
>Reproducir</Button
>
<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 spotlightRows as { track, seconds }, i (track.id)} {#each previewItems as track, i (track.id)}
<ListRow <ListRow
onclick={() => playSpotlightTrack(i)} onclick={() => playSpotlightTrack(previewItems, i)}
active={player.current.id === track.id} active={player.currentId === track.id}
size="sm" size="sm"
title={track.title} title={track.title}
subtitle={track.artist} subtitle={track.artist}
subtitleHref={track.ownerUserId} subtitleHref={track.ownerUserId}
explicit={track.isExplicit} explicit={track.explicit}
> >
{#snippet leading()} {#snippet leading()}
<span class="w-5 shrink-0 text-center text-xs text-fg-2 tabular-nums" <span class="w-5 shrink-0 text-center text-xs text-fg-2 tabular-nums"
@@ -261,7 +208,9 @@
> >
{/snippet} {/snippet}
{#snippet trailing()} {#snippet trailing()}
<span class="shrink-0 text-xs text-fg-2 tabular-nums">{fmtTime(seconds)}</span> <span class="shrink-0 text-xs text-fg-2 tabular-nums"
>{fmtTime(track.duration)}</span
>
{/snippet} {/snippet}
</ListRow> </ListRow>
{:else} {:else}
@@ -269,6 +218,7 @@
{/each} {/each}
</div> </div>
</div> </div>
{/await}
</section> </section>
{/if} {/if}
@@ -281,7 +231,7 @@
href="/mixes/{mix.id}" href="/mixes/{mix.id}"
title={mix.title} title={mix.title}
subtitle={mix.subtitle} subtitle={mix.subtitle}
trackIds={mix.items.map((item) => item.trackId)} trackIds={mix.tracks.map((track) => track.id)}
onPlay={(event) => playMix(mix, event)} onPlay={(event) => playMix(mix, event)}
index={i} index={i}
/> />
@@ -296,45 +246,6 @@
{/if} {/if}
</section> </section>
<section>
<SectionHeading title="Populares esta semana" subtitle="Lo más escuchado de tu biblioteca" />
{#if popularTracks.length > 0}
<div class="grid grid-cols-1 gap-x-8.5 gap-y-1.5 lg:grid-cols-2">
{#each popularTracks as track, i (track.id)}
{@const isCurrent = player.current.id === track.id}
<ListRow
onclick={() => playPopular(i)}
oncontextmenu={(e) => openContextMenu(e, track)}
active={isCurrent}
size="sm"
title={track.title}
subtitle={track.artist}
subtitleHref={track.ownerUserId}
explicit={track.isExplicit}
trackId={track.id}
>
{#snippet leading()}
<span class="flex h-3.5 w-icon-md shrink-0 items-end justify-center">
{#if isCurrent}
<EqBars size={13} paused={!player.playing} />
{:else}
<span class="text-xs text-fg-2 tabular-nums">{i + 1}</span>
{/if}
</span>
{/snippet}
{#snippet trailing()}
<span class="hidden shrink-0 text-xs text-fg-2 tabular-nums sm:block">
{fmtPlays(track.listensCount)}
</span>
{/snippet}
</ListRow>
{/each}
</div>
{:else}
{@render noListeningHistory()}
{/if}
</section>
<section> <section>
<SectionHeading <SectionHeading
title="Tus playlists" title="Tus playlists"
@@ -384,9 +295,9 @@
]} ]}
playlistAction={{ playlistAction={{
action: '?/addTrack', action: '?/addTrack',
fields: { trackId: String(trackMenu.state.track.id) }, fields: { trackId: trackMenu.state.track.id },
label: 'Añadir a una playlist' label: 'Añadir a una playlist'
}} }}
{playlists} playlists={data.userPlaylists}
/> />
{/if} {/if}
@@ -1,10 +1,11 @@
import type { RequestHandler } from './$types'; import type { RequestHandler } from './$types';
import { json } from '@sveltejs/kit'; import { json } from '@sveltejs/kit';
import { createApiClient, unwrapOrError } from '$lib/server/api'; import { apiFor, unwrapOrError } from '$lib/server/api';
import { toPage, toTrack } from '$lib/server/mappers';
import { ALBUM_TRACKS_PAGE_SIZE } from '$lib/config'; import { ALBUM_TRACKS_PAGE_SIZE } from '$lib/config';
export const GET: RequestHandler = async ({ params, locals, fetch }) => { export const GET: RequestHandler = async ({ params, locals, fetch }) => {
const api = createApiClient({ fetch, accessToken: locals.accessToken ?? undefined }); const api = apiFor({ fetch, locals });
const result = await api.GET('/albums/{albumId}/tracks', { const result = await api.GET('/albums/{albumId}/tracks', {
params: { params: {
path: { albumId: params.id }, path: { albumId: params.id },
@@ -12,5 +13,5 @@ export const GET: RequestHandler = async ({ params, locals, fetch }) => {
} }
}); });
return json(unwrapOrError(result, 'No se pudo cargar el álbum.')); return json(toPage(unwrapOrError(result, 'No se pudo cargar el álbum.'), toTrack));
}; };
+8 -4
View File
@@ -1,13 +1,17 @@
import type { RequestHandler } from './$types'; import type { RequestHandler } from './$types';
import { json } from '@sveltejs/kit'; import { json } from '@sveltejs/kit';
import { createApiClient } from '$lib/server/api'; import { apiFor } from '$lib/server/api';
export const POST: RequestHandler = async ({ request, locals, fetch }) => { export const POST: RequestHandler = async ({ request, locals, fetch }) => {
if (!locals.accessToken) return json({ message: 'Inicia sesión.' }, { status: 401 }); if (!locals.accessToken) return json({ message: 'Inicia sesión.' }, { status: 401 });
const body = await request.json(); const payload = await request.json().catch(() => null);
const api = createApiClient({ fetch, accessToken: locals.accessToken }); const trackId = payload?.trackId;
const result = await api.POST('/likes/toggle', { body }); if (typeof trackId !== 'string' || trackId === '')
return json({ message: 'Canción inválida.' }, { status: 400 });
const api = apiFor({ fetch, locals });
const result = await api.POST('/likes/toggle', { body: { trackId } });
if (result.error || result.data === undefined) if (result.error || result.data === undefined)
return json({ message: 'No se pudo actualizar Me gusta.' }, { status: 502 }); return json({ message: 'No se pudo actualizar Me gusta.' }, { status: 502 });
@@ -1,15 +1,16 @@
import type { RequestHandler } from './$types'; import type { RequestHandler } from './$types';
import { json } from '@sveltejs/kit'; import { json } from '@sveltejs/kit';
import { createApiClient } from '$lib/server/api'; import { apiFor } from '$lib/server/api';
export const PUT: RequestHandler = async ({ params, request, locals, fetch }) => { export const PUT: RequestHandler = async ({ params, request, locals, fetch }) => {
if (!locals.accessToken) return json({ message: 'Inicia sesión.' }, { status: 401 }); if (!locals.accessToken) return json({ message: 'Inicia sesión.' }, { status: 401 });
const { playedSeconds } = (await request.json()) as { playedSeconds?: number }; const payload = (await request.json().catch(() => null)) as { playedSeconds?: unknown } | null;
const playedSeconds = payload?.playedSeconds;
if (typeof playedSeconds !== 'number' || !Number.isFinite(playedSeconds) || playedSeconds < 0) if (typeof playedSeconds !== 'number' || !Number.isFinite(playedSeconds) || playedSeconds < 0)
return json({ message: 'Tiempo inválido.' }, { status: 400 }); return json({ message: 'Tiempo inválido.' }, { status: 400 });
const api = createApiClient({ fetch, accessToken: locals.accessToken }); const api = apiFor({ fetch, locals });
const result = await api.PUT('/tracks/listens/{listenId}/progress', { const result = await api.PUT('/tracks/listens/{listenId}/progress', {
params: { path: { listenId: params.id } }, params: { path: { listenId: params.id } },
body: { playedSeconds } body: { playedSeconds }
@@ -0,0 +1,17 @@
import type { RequestHandler } from './$types';
import { json } from '@sveltejs/kit';
import { apiFor, unwrapOrError } from '$lib/server/api';
import { toPage, toTrack } from '$lib/server/mappers';
import { PLAYLIST_TRACKS_PAGE_SIZE } from '$lib/config';
export const GET: RequestHandler = async ({ params, locals, fetch }) => {
const api = apiFor({ fetch, locals });
const result = await api.GET('/playlists/{playlistId}/tracks', {
params: {
path: { playlistId: params.id },
query: { pageNumber: 1, pageSize: PLAYLIST_TRACKS_PAGE_SIZE }
}
});
return json(toPage(unwrapOrError(result, 'No se pudo cargar la playlist.'), toTrack));
};
+13 -5
View File
@@ -1,18 +1,26 @@
import type { RequestHandler } from './$types'; import type { RequestHandler } from './$types';
import { json } from '@sveltejs/kit'; import { json } from '@sveltejs/kit';
import { createApiClient, unwrapOrError } from '$lib/server/api'; import { apiFor, unwrapOrError } from '$lib/server/api';
import { EXPLORE_PAGE_SIZE } from '$lib/config'; import { toPage, toTrack } from '$lib/server/mappers';
import { EXPLORE_PAGE_SIZE, MAX_PAGE_SIZE } from '$lib/config';
export const GET: RequestHandler = async ({ url, locals, fetch }) => { export const GET: RequestHandler = async ({ url, locals, fetch }) => {
const name = url.searchParams.get('name')?.trim() || undefined; const name = url.searchParams.get('name')?.trim() || undefined;
const genre = url.searchParams.get('genre')?.trim() || undefined; const genre = url.searchParams.get('genre')?.trim() || undefined;
const pageNumber = Math.max(1, Number(url.searchParams.get('pageNumber')) || 1); const pageNumber = Math.max(1, Number(url.searchParams.get('pageNumber')) || 1);
const pageSize = Math.max(1, Number(url.searchParams.get('pageSize')) || EXPLORE_PAGE_SIZE); const pageSize = Math.min(
MAX_PAGE_SIZE,
Math.max(1, Number(url.searchParams.get('pageSize')) || EXPLORE_PAGE_SIZE)
);
const api = createApiClient({ fetch, accessToken: locals.accessToken ?? undefined }); const api = apiFor({ fetch, locals });
const result = await api.GET('/tracks', { const result = await api.GET('/tracks', {
params: { query: { name, genre, pageNumber, pageSize } } params: { query: { name, genre, pageNumber, pageSize } }
}); });
return json(unwrapOrError(result, 'No se pudieron cargar las canciones.')); return json(
toPage(unwrapOrError(result, 'No se pudieron cargar las canciones.'), (item) =>
toTrack(item.track)
)
);
}; };
@@ -1,9 +1,9 @@
import type { RequestHandler } from './$types'; import type { RequestHandler } from './$types';
import { json } from '@sveltejs/kit'; import { json } from '@sveltejs/kit';
import { createApiClient, unwrapOrError } from '$lib/server/api'; import { apiFor, unwrapOrError } from '$lib/server/api';
export const GET: RequestHandler = async ({ params, locals, fetch }) => { export const GET: RequestHandler = async ({ params, locals, fetch }) => {
const api = createApiClient({ fetch, accessToken: locals.accessToken ?? undefined }); const api = apiFor({ fetch, locals });
const result = await api.GET('/tracks/{id}/stream', { const result = await api.GET('/tracks/{id}/stream', {
params: { path: { id: params.id } } params: { path: { id: params.id } }
}); });
@@ -1,12 +1,13 @@
import type { RequestHandler } from './$types'; import type { RequestHandler } from './$types';
import { json } from '@sveltejs/kit'; import { json } from '@sveltejs/kit';
import { createApiClient, unwrapOrError } from '$lib/server/api'; import { apiFor, unwrapOrError } from '$lib/server/api';
import { toPage } from '$lib/server/mappers';
import { fetchFollowList } from '$lib/server/follows'; import { fetchFollowList } from '$lib/server/follows';
import { FOLLOW_LIST_PAGE_SIZE } from '$lib/config'; import { FOLLOW_LIST_PAGE_SIZE } from '$lib/config';
export const GET: RequestHandler = async ({ params, url, locals, fetch }) => { export const GET: RequestHandler = async ({ params, url, locals, fetch }) => {
const pageNumber = Math.max(1, Number(url.searchParams.get('pageNumber')) || 1); const pageNumber = Math.max(1, Number(url.searchParams.get('pageNumber')) || 1);
const api = createApiClient({ fetch, accessToken: locals.accessToken ?? undefined }); const api = apiFor({ fetch, locals });
const result = await fetchFollowList( const result = await fetchFollowList(
api, api,
params.list, params.list,
@@ -15,5 +16,10 @@ export const GET: RequestHandler = async ({ params, url, locals, fetch }) => {
FOLLOW_LIST_PAGE_SIZE FOLLOW_LIST_PAGE_SIZE
); );
return json(unwrapOrError(result, 'No se pudo cargar la lista.', result.response?.status)); return json(
toPage(
unwrapOrError(result, 'No se pudo cargar la lista.', result.response?.status),
(user) => user
)
);
}; };

Some files were not shown because too many files have changed in this diff Show More