Author SHA1 Message Date
Admin b011ccdd5b backend: expose track count and duration on playlist responses 2026-09-24 05:20:20 +02:00
Admin 1df7f6ccf6 web-player: share form limits between client and server and extract form parsers 2026-09-24 05:19:41 +02:00
Admin 2915737f03 web-player: centralize server helpers (apiFor, authedAction, form readers) and unify page sizes 2026-09-24 05:13:51 +02:00
Admin 5b2a0e4d46 web-player: normalize tracks, albums and playlists into single domain types at the server boundary 2026-09-24 05:09:37 +02:00
Admin 37fc229bf6 web-player: remove dead code and unused props 2026-09-24 05:04:09 +02:00
Admin bc43b4b6eb web-player: fix playlist ownership, sidebar counts, home spotlight and assorted page bugs 2026-09-24 05:02:26 +02:00
Admin dda8c30d45 backend: expose owner user id on playlist responses 2026-09-24 04:59:50 +02:00
Admin 906f38140a web-player: dedupe queue, stop at end of queue, open queue once and harden localStorage 2026-09-24 04:59:28 +02:00
Admin 3e546d4a1e web-player: fix open redirect via returnTo, require SESSION_SECRET and validate API inputs 2026-09-24 04:58:08 +02:00
Admin b2f450dcf7 web-player: add Vitest, unit tests and pure queue operations 2026-09-24 04:54:09 +02:00
NotS1mp 87c3f97529 Merge pull request 'Web player general changes' (#22) from web-player-general-changes into master
CI/CD / BackEnd (push) Successful in 1m17s
CI/CD / WebPlayer (push) Successful in 40s
CI/CD / Deploy (push) Successful in 2m4s
Reviewed-on: #22
2026-09-23 22:37:30 +00:00
Admin 302dd1e0f8 Merge master into web-player-general-changes
CI/CD / BackEnd (push) Canceled after 0s
CI/CD / WebPlayer (push) Canceled after 0s
CI/CD / BackEnd (pull_request) Successful in 1m23s
CI/CD / WebPlayer (pull_request) Successful in 40s
CI/CD / Deploy (pull_request) Skipped
CI/CD / Deploy (push) Canceled after 0s
2026-09-24 00:32:03 +02:00
NotS1mp 421fc929e8 Merge pull request 'Dynamic genres' (#20) from dynamic-genres into master
CI/CD / BackEnd (push) Successful in 1m23s
CI/CD / WebPlayer (push) Successful in 39s
CI/CD / Deploy (push) Successful in 1m38s
Reviewed-on: #20
2026-09-23 22:24:22 +00:00
Admin 430c711ff2 Refactor CI/CD workflow to improve database migration process and key generation
CI/CD / BackEnd (push) Successful in 1m18s
CI/CD / WebPlayer (push) Successful in 40s
CI/CD / Deploy (push) Skipped
CI/CD / BackEnd (pull_request) Successful in 1m27s
CI/CD / WebPlayer (pull_request) Successful in 39s
CI/CD / Deploy (pull_request) Skipped
2026-09-23 23:59:45 +02:00
Admin cfdc61a4b8 Scroll content inside the shell so the player shares the ambient backdrop
CI/CD / BackEnd (push) Successful in 1m17s
CI/CD / WebPlayer (push) Successful in 40s
CI/CD / Deploy (push) Skipped
The player dock used to sit fixed over the scrolling page, so it needed an
opaque background. The shell is now a fixed-height frame where only the
content column scrolls, letting the dock stay transparent like the sidebar
and queue. Scroll reset/restore on navigation moves to restoreScroll(), and
the stable scrollbar gutter moves from <html> to the content scroller.
2026-09-23 23:52:27 +02:00
Admin 0f28e19c1d Remove follow button from user rows
CI/CD / BackEnd (push) Successful in 1m24s
CI/CD / WebPlayer (push) Successful in 40s
CI/CD / Deploy (push) Skipped
2026-09-23 23:20:27 +02:00
Admin 7316e355ee Add a second greeting variant per time slot 2026-09-23 23:20:27 +02:00
Admin b39e55fd87 Restore master hero and spotlight gradients and sway them in place 2026-09-23 23:15:15 +02:00
Admin cb046bba9e Let users browse followers and following lists
Following lists are public; followers are visible to the user and to mutual followers.
Adds GET /users/{id}/followers and /following, exposes isFollowing and canViewFollowers
on the profile, and returns isFollowedByViewer from user search so the web player no
longer calls /is-following per user.
2026-09-23 22:55:14 +02:00
Admin b9a40186b1 Lighten active surface gradients in light mode
CI/CD / BackEnd (push) Successful in 1m15s
CI/CD / WebPlayer (push) Successful in 40s
CI/CD / Deploy (push) Skipped
2026-09-23 22:44:42 +02:00
Admin fd0a39b879 IsExplicit 2026-09-23 22:42:28 +02:00
Admin cc7afe4326 Add explicit flag to tracks and mark it on upload 2026-09-23 22:41:57 +02:00
Admin 72cbc18a48 Add follow button next to each user in search results
Extract FollowButton and shared follow/unfollow actions, reused by the user profile page.
2026-09-23 22:34:56 +02:00
Admin 84408b842b Semibold 2026-09-23 22:25:04 +02:00
Admin 339fcb56ad Semibold 2026-09-23 22:24:57 +02:00
Admin 74cf534be5 Animate queue drag and drop reordering 2026-09-23 22:18:21 +02:00
Admin 89820c1377 Fix gradient background syntax in Artwork component 2026-09-23 22:10:46 +02:00
Admin 2fe26b15dd Allow reordering upcoming tracks in the queue by drag and drop 2026-09-23 22:10:21 +02:00
Admin 2cb560fda4 Keep light mode gradients visible while drifting 2026-09-23 22:07:50 +02:00
Admin d8530b9209 Animate menu close with shared pop transition 2026-09-23 22:01:51 +02:00
Admin 5049b0ba91 Update global icon stroke width to 1.5 in layout.css 2026-09-23 21:59:24 +02:00
Admin 1b98a75c0e Added shadow 2026-09-23 21:59:13 +02:00
Admin cbbde95773 Set global icon stroke width and remove per-icon strokeWidth props 2026-09-23 21:55:00 +02:00
Admin 26bb8a50a3 Solid player bar, gradient sliders and smooth progress 2026-09-23 21:48:39 +02:00
Admin bf19bb31f9 Make accent hue crossfade between songs last 2 to 4 seconds 2026-09-23 21:40:22 +02:00
Admin 55cc803708 Drift accent gradients on buttons, hero, spotlight, active rows and modals 2026-09-23 21:37:41 +02:00
Admin 805e521b1d Animate ambient background, add mute toggle and smooth queue panel 2026-09-23 21:36:01 +02:00
Admin f8d431a0d7 Implement volume control with mute functionality; update PlayerState to manage muted state 2026-09-23 21:28:17 +02:00
Admin 7b72db8a24 Add ambient drift animation to app backdrop; adjust inset and handle reduced motion 2026-09-23 21:28:07 +02:00
147 changed files with 4568 additions and 1583 deletions
+14 -2
View File
@@ -86,11 +86,23 @@ jobs:
git fetch origin master git fetch origin master
git reset --hard origin/master git reset --hard origin/master
- name: Migrate database, generate keys - name: Generate stream-ticket keys
run: | run: |
set -euo pipefail set -euo pipefail
cd "$DEPLOY_PATH" cd "$DEPLOY_PATH"
$COMPOSE up -d wait-for-postgres keys-init migrate $COMPOSE run --rm --no-deps keys-init
# The migrations are baked into the migrator image, so it is rebuilt on
# every deploy (plain `up` would reuse the stale image and re-apply the
# old migrations). `run --rm` starts wait-for-postgres, blocks until the
# migration finishes and propagates its exit code, so a failed migration
# aborts the deploy before the new API is started against an old schema.
- name: Migrate database
run: |
set -euo pipefail
cd "$DEPLOY_PATH"
$COMPOSE build migrate
$COMPOSE run --rm migrate
# Both jobs verify themselves through the public auth./s3. URLs, served # Both jobs verify themselves through the public auth./s3. URLs, served
# by the shared edge nginx (Infrastructure's edge/), and are idempotent. # by the shared edge nginx (Infrastructure's edge/), and are idempotent.
@@ -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(
@@ -18,7 +18,8 @@ namespace Musify.Application.Tracks
string Title, string Title,
Guid PictureIntentId, Guid PictureIntentId,
Guid AudioIntentId, Guid AudioIntentId,
IReadOnlyCollection<Genre> Tags) IReadOnlyCollection<Genre> Tags,
bool IsExplicit = false)
: ICommand<ErrorOr<TrackApplicationResponse>>; : ICommand<ErrorOr<TrackApplicationResponse>>;
public class CreateTrackCommandHandler( public class CreateTrackCommandHandler(
@@ -83,6 +84,7 @@ namespace Musify.Application.Tracks
NormalizedTitle = request.Title.ToUpperInvariant(), NormalizedTitle = request.Title.ToUpperInvariant(),
OwnerUserId = user.Id, OwnerUserId = user.Id,
Owner = user, Owner = user,
IsExplicit = request.IsExplicit,
Pictures = new TrackPictures Pictures = new TrackPictures
{ {
OriginalName = pictureIntent.ObjectName, OriginalName = pictureIntent.ObjectName,
@@ -15,7 +15,8 @@ namespace Musify.Application.Tracks.Responses
DateTime CreatedAt, DateTime CreatedAt,
DateTime UpdatedAt, DateTime UpdatedAt,
[property: JsonConverter(typeof(LongAsStringConverter))] long OwnerUserId, [property: JsonConverter(typeof(LongAsStringConverter))] long OwnerUserId,
IReadOnlyCollection<Genre> Tags) IReadOnlyCollection<Genre> Tags,
bool IsExplicit)
{ {
public static TrackApplicationResponse FromEntity(Track track, int listensCount) => public static TrackApplicationResponse FromEntity(Track track, int listensCount) =>
new( new(
@@ -28,6 +29,7 @@ namespace Musify.Application.Tracks.Responses
track.CreatedAt, track.CreatedAt,
track.UpdatedAt, track.UpdatedAt,
track.OwnerUserId, track.OwnerUserId,
track.Tags.Select(tag => tag.Tag).ToList()); track.Tags.Select(tag => tag.Tag).ToList(),
track.IsExplicit);
} }
} }
@@ -0,0 +1,30 @@
using Microsoft.EntityFrameworkCore;
using Musify.Application.Contracts;
namespace Musify.Application.Users
{
public static class FollowVisibility
{
public static async Task<bool> CanViewFollowersAsync(
IDatabase database,
long? viewerId,
long userId,
CancellationToken cancellationToken)
{
if (viewerId is not { } viewer)
return false;
if (viewer == userId)
return true;
var mutualRows = await database.UserFollows
.AsNoTracking()
.CountAsync(f =>
(f.FollowerId == viewer && f.FollowedId == userId) ||
(f.FollowerId == userId && f.FollowedId == viewer),
cancellationToken);
return mutualRows == 2;
}
}
}
@@ -0,0 +1,46 @@
using ErrorOr;
using Mediator;
using Microsoft.EntityFrameworkCore;
using Musify.Application.Contracts;
using Musify.Application.Shared;
using Musify.Application.Users.Responses;
using X.PagedList.EF;
namespace Musify.Application.Users
{
public record GetUserFollowersQuery(long? ViewerId, long UserId, int PageNumber, int PageSize)
: IQuery<ErrorOr<PaginatedResponse<UserSummaryResponse>>>;
public class GetUserFollowersQueryHandler(IDatabase database)
: IQueryHandler<GetUserFollowersQuery, ErrorOr<PaginatedResponse<UserSummaryResponse>>>
{
public async ValueTask<ErrorOr<PaginatedResponse<UserSummaryResponse>>> Handle(GetUserFollowersQuery request, CancellationToken cancellationToken)
{
if (!await database.Users.AnyAsync(u => u.Id == request.UserId, cancellationToken))
return Error.NotFound();
if (!await FollowVisibility.CanViewFollowersAsync(database, request.ViewerId, request.UserId, cancellationToken))
return Error.Forbidden();
var followsQuery = database.UserFollows
.AsNoTracking()
.Where(f => f.FollowedId == request.UserId);
var totalCount = await followsQuery.CountAsync(cancellationToken);
var viewerId = request.ViewerId;
var page = await followsQuery
.OrderByDescending(f => f.CreatedAt)
.Select(f => new UserSummaryResponse(
f.Follower.Id,
f.Follower.Name,
f.Follower.FirstName,
f.Follower.SecondName,
f.Follower.ProfilePictureUrl,
viewerId != null && database.UserFollows.Any(v => v.FollowerId == viewerId && v.FollowedId == f.FollowerId)))
.ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken);
return PaginatedResponse<UserSummaryResponse>.FromPagedList(page);
}
}
}
@@ -0,0 +1,43 @@
using ErrorOr;
using Mediator;
using Microsoft.EntityFrameworkCore;
using Musify.Application.Contracts;
using Musify.Application.Shared;
using Musify.Application.Users.Responses;
using X.PagedList.EF;
namespace Musify.Application.Users
{
public record GetUserFollowingQuery(long? ViewerId, long UserId, int PageNumber, int PageSize)
: IQuery<ErrorOr<PaginatedResponse<UserSummaryResponse>>>;
public class GetUserFollowingQueryHandler(IDatabase database)
: IQueryHandler<GetUserFollowingQuery, ErrorOr<PaginatedResponse<UserSummaryResponse>>>
{
public async ValueTask<ErrorOr<PaginatedResponse<UserSummaryResponse>>> Handle(GetUserFollowingQuery request, CancellationToken cancellationToken)
{
if (!await database.Users.AnyAsync(u => u.Id == request.UserId, cancellationToken))
return Error.NotFound();
var followsQuery = database.UserFollows
.AsNoTracking()
.Where(f => f.FollowerId == request.UserId);
var totalCount = await followsQuery.CountAsync(cancellationToken);
var viewerId = request.ViewerId;
var page = await followsQuery
.OrderByDescending(f => f.CreatedAt)
.Select(f => new UserSummaryResponse(
f.Followed.Id,
f.Followed.Name,
f.Followed.FirstName,
f.Followed.SecondName,
f.Followed.ProfilePictureUrl,
viewerId != null && database.UserFollows.Any(v => v.FollowerId == viewerId && v.FollowedId == f.FollowedId)))
.ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken);
return PaginatedResponse<UserSummaryResponse>.FromPagedList(page);
}
}
}
@@ -7,7 +7,7 @@ using Musify.Application.Serialization;
namespace Musify.Application.Users namespace Musify.Application.Users
{ {
public record GetUserProfileQuery(long UserId) : IQuery<ErrorOr<UserProfileResponse>>; public record GetUserProfileQuery(long UserId, long? ViewerId = null) : IQuery<ErrorOr<UserProfileResponse>>;
public record UserProfileResponse( public record UserProfileResponse(
[property: JsonConverter(typeof(LongAsStringConverter))] long Id, [property: JsonConverter(typeof(LongAsStringConverter))] long Id,
@@ -17,7 +17,9 @@ namespace Musify.Application.Users
string? ProfilePictureUrl, string? ProfilePictureUrl,
DateTime CreatedAt, DateTime CreatedAt,
int FollowersCount, int FollowersCount,
int FollowingCount); int FollowingCount,
bool IsFollowing,
bool CanViewFollowers);
public class GetUserProfileQueryHandler(IDatabase database) public class GetUserProfileQueryHandler(IDatabase database)
: IQueryHandler<GetUserProfileQuery, ErrorOr<UserProfileResponse>> : IQueryHandler<GetUserProfileQuery, ErrorOr<UserProfileResponse>>
@@ -49,6 +51,14 @@ namespace Musify.Application.Users
.AsNoTracking() .AsNoTracking()
.CountAsync(f => f.FollowerId == request.UserId, cancellationToken); .CountAsync(f => f.FollowerId == request.UserId, cancellationToken);
var isFollowing = request.ViewerId is { } viewerId && viewerId != request.UserId &&
await database.UserFollows
.AsNoTracking()
.AnyAsync(f => f.FollowerId == viewerId && f.FollowedId == request.UserId, cancellationToken);
var canViewFollowers = await FollowVisibility.CanViewFollowersAsync(
database, request.ViewerId, request.UserId, cancellationToken);
return new UserProfileResponse( return new UserProfileResponse(
user.Id, user.Id,
user.Name, user.Name,
@@ -57,7 +67,9 @@ namespace Musify.Application.Users
user.ProfilePictureUrl, user.ProfilePictureUrl,
user.CreatedAt, user.CreatedAt,
followersCount, followersCount,
followingCount); followingCount,
isFollowing,
canViewFollowers);
} }
} }
} }
@@ -8,13 +8,13 @@ using Musify.Application.Contracts;
namespace Musify.Application.Users namespace Musify.Application.Users
{ {
public record GetUsersQuery(int PageNumber, int PageSize, string? UsernameSearch) public record GetUsersQuery(int PageNumber, int PageSize, string? UsernameSearch, long? ViewerId = null)
: IQuery<ErrorOr<PaginatedResponse<UserApplicationResponse>>>; : IQuery<ErrorOr<PaginatedResponse<UserSummaryResponse>>>;
public class GetUsersQueryHandler(IDatabase database) public class GetUsersQueryHandler(IDatabase database)
: IQueryHandler<GetUsersQuery, ErrorOr<PaginatedResponse<UserApplicationResponse>>> : IQueryHandler<GetUsersQuery, ErrorOr<PaginatedResponse<UserSummaryResponse>>>
{ {
public async ValueTask<ErrorOr<PaginatedResponse<UserApplicationResponse>>> Handle(GetUsersQuery request, CancellationToken cancellationToken) public async ValueTask<ErrorOr<PaginatedResponse<UserSummaryResponse>>> Handle(GetUsersQuery request, CancellationToken cancellationToken)
{ {
var usersQuery = database.Users var usersQuery = database.Users
.AsNoTracking() .AsNoTracking()
@@ -27,11 +27,19 @@ namespace Musify.Application.Users
} }
var totalCount = await usersQuery.CountAsync(cancellationToken); var totalCount = await usersQuery.CountAsync(cancellationToken);
var viewerId = request.ViewerId;
var pagedUsers = await usersQuery var pagedUsers = await usersQuery
.Select(u => UserApplicationResponse.FromEntity(u)) .OrderBy(u => u.Name)
.Select(u => new UserSummaryResponse(
u.Id,
u.Name,
u.FirstName,
u.SecondName,
u.ProfilePictureUrl,
viewerId != null && database.UserFollows.Any(f => f.FollowerId == viewerId && f.FollowedId == u.Id)))
.ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken); .ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken);
return PaginatedResponse<UserApplicationResponse>.FromPagedList(pagedUsers); return PaginatedResponse<UserSummaryResponse>.FromPagedList(pagedUsers);
} }
} }
} }
@@ -0,0 +1,13 @@
using System.Text.Json.Serialization;
using Musify.Application.Serialization;
namespace Musify.Application.Users.Responses
{
public record UserSummaryResponse(
[property: JsonConverter(typeof(LongAsStringConverter))] long Id,
string Name,
string? FirstName,
string? SecondName,
string? ProfilePictureUrl,
bool IsFollowedByViewer);
}
@@ -31,6 +31,9 @@ namespace Musify.Domain.Entities
[Required] [Required]
public LifeCycleStatus LifeCycleStatus { get; set; } = LifeCycleStatus.Active; public LifeCycleStatus LifeCycleStatus { get; set; } = LifeCycleStatus.Active;
[Required]
public bool IsExplicit { get; set; }
[Required] [Required]
public DateTime CreatedAt { get; set; } = DateTime.UtcNow; public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
@@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Musify.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class AddTrackIsExplicit : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "IsExplicit",
table: "Tracks",
type: "boolean",
nullable: false,
defaultValue: false);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "IsExplicit",
table: "Tracks");
}
}
}
@@ -436,6 +436,9 @@ namespace Musify.Infrastructure.Persistence.Migrations
b.Property<double>("DurationSeconds") b.Property<double>("DurationSeconds")
.HasColumnType("double precision"); .HasColumnType("double precision");
b.Property<bool>("IsExplicit")
.HasColumnType("boolean");
b.Property<int>("LifeCycleStatus") b.Property<int>("LifeCycleStatus")
.HasColumnType("integer"); .HasColumnType("integer");
@@ -6,4 +6,5 @@ public record CreateTrackRequest(
string Title, string Title,
Guid PictureIntentId, Guid PictureIntentId,
Guid AudioIntentId, Guid AudioIntentId,
IReadOnlyCollection<Genre> Tags); IReadOnlyCollection<Genre> Tags,
bool IsExplicit = false);
@@ -185,7 +185,7 @@ public static class TrackEndpoints
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
var result = await mediator.Send( var result = await mediator.Send(
new CreateTrackCommand(currentUser.RequiredId, request.Title, request.PictureIntentId, request.AudioIntentId, request.Tags), new CreateTrackCommand(currentUser.RequiredId, request.Title, request.PictureIntentId, request.AudioIntentId, request.Tags, request.IsExplicit),
cancellationToken); cancellationToken);
return result.ToCreatedResult(track => $"/tracks/{track.Id}"); return result.ToCreatedResult(track => $"/tracks/{track.Id}");
@@ -21,7 +21,7 @@ public static class UserEndpoints
group.MapGet("/", GetUsers) group.MapGet("/", GetUsers)
.WithName("GetUsers") .WithName("GetUsers")
.WithSummary("Get Paginated Users.") .WithSummary("Get Paginated Users.")
.Produces<PaginatedResponse<UserApplicationResponse>>(); .Produces<PaginatedResponse<UserSummaryResponse>>();
group.MapGet("/{id}", GetUserById) group.MapGet("/{id}", GetUserById)
.WithName("GetUserById") .WithName("GetUserById")
@@ -52,6 +52,19 @@ public static class UserEndpoints
.Produces<UserProfileResponse>() .Produces<UserProfileResponse>()
.Produces(StatusCodes.Status404NotFound); .Produces(StatusCodes.Status404NotFound);
group.MapGet("/{id}/followers", GetFollowers)
.WithName("GetUserFollowers")
.WithSummary("Get The Users Following A User. Visible To The User And To Mutual Followers.")
.Produces<PaginatedResponse<UserSummaryResponse>>()
.Produces(StatusCodes.Status403Forbidden)
.Produces(StatusCodes.Status404NotFound);
group.MapGet("/{id}/following", GetFollowing)
.WithName("GetUserFollowing")
.WithSummary("Get The Users A User Follows.")
.Produces<PaginatedResponse<UserSummaryResponse>>()
.Produces(StatusCodes.Status404NotFound);
group.MapGet("/{id}/is-following", IsFollowing) group.MapGet("/{id}/is-following", IsFollowing)
.WithName("IsFollowingUser") .WithName("IsFollowingUser")
.WithSummary("Check Whether The Current User Follows Another User.") .WithSummary("Check Whether The Current User Follows Another User.")
@@ -88,12 +101,13 @@ public static class UserEndpoints
private static async Task<IResult> GetUsers( private static async Task<IResult> GetUsers(
IMediator mediator, IMediator mediator,
CurrentUser currentUser,
CancellationToken cancellationToken, CancellationToken cancellationToken,
string? usernameSearch, string? usernameSearch,
int pageNumber = 1, int pageNumber = 1,
int pageSize = 10) int pageSize = 10)
{ {
var result = await mediator.Send(new GetUsersQuery(pageNumber, pageSize, usernameSearch), cancellationToken); var result = await mediator.Send(new GetUsersQuery(pageNumber, pageSize, usernameSearch, currentUser.Id), cancellationToken);
return result.ToHttpResult(); return result.ToHttpResult();
} }
@@ -133,10 +147,35 @@ public static class UserEndpoints
private static async Task<IResult> GetUserProfile( private static async Task<IResult> GetUserProfile(
IMediator mediator, IMediator mediator,
CurrentUser currentUser,
long id, long id,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
var result = await mediator.Send(new GetUserProfileQuery(id), cancellationToken); var result = await mediator.Send(new GetUserProfileQuery(id, currentUser.Id), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> GetFollowers(
IMediator mediator,
CurrentUser currentUser,
long id,
CancellationToken cancellationToken,
int pageNumber = 1,
int pageSize = 20)
{
var result = await mediator.Send(new GetUserFollowersQuery(currentUser.Id, id, pageNumber, pageSize), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> GetFollowing(
IMediator mediator,
CurrentUser currentUser,
long id,
CancellationToken cancellationToken,
int pageNumber = 1,
int pageSize = 20)
{
var result = await mediator.Send(new GetUserFollowingQuery(currentUser.Id, id, pageNumber, pageSize), cancellationToken);
return result.ToHttpResult(); return result.ToHttpResult();
} }
@@ -17,6 +17,17 @@ namespace Musify.Application.Tests.Shared
Assert.Equal("The Owner", response.Artist); Assert.Equal("The Owner", response.Artist);
Assert.Equal(owner.Id, response.OwnerUserId); Assert.Equal(owner.Id, response.OwnerUserId);
Assert.Equal(3, response.ListensCount); Assert.Equal(3, response.ListensCount);
Assert.False(response.IsExplicit);
}
[Fact]
public void FromEntity_CopiesExplicitFlag()
{
var track = TestEntities.Track(TestEntities.User(), isExplicit: true);
var response = TrackApplicationResponse.FromEntity(track, listensCount: 0);
Assert.True(response.IsExplicit);
} }
} }
} }
@@ -56,7 +56,8 @@ namespace Musify.Application.Tests.TestSupport
TrackPictures? pictures = null, TrackPictures? pictures = null,
TrackAudio? audio = null, TrackAudio? audio = null,
LifeCycleStatus lifeCycleStatus = LifeCycleStatus.Active, LifeCycleStatus lifeCycleStatus = LifeCycleStatus.Active,
IEnumerable<Genre>? tags = null) IEnumerable<Genre>? tags = null,
bool isExplicit = false)
{ {
var track = new Track var track = new Track
{ {
@@ -64,6 +65,7 @@ namespace Musify.Application.Tests.TestSupport
NormalizedTitle = title.ToUpperInvariant(), NormalizedTitle = title.ToUpperInvariant(),
DurationSeconds = durationSeconds, DurationSeconds = durationSeconds,
LifeCycleStatus = lifeCycleStatus, LifeCycleStatus = lifeCycleStatus,
IsExplicit = isExplicit,
OwnerUserId = owner.Id, OwnerUserId = owner.Id,
Owner = owner, Owner = owner,
Pictures = pictures ?? ProcessedPictures(), Pictures = pictures ?? ProcessedPictures(),
@@ -58,6 +58,26 @@ namespace Musify.Application.Tests.Tracks
Assert.Equal(ProcessingStatus.Pending, stored.Audio.TranscodeStatus); Assert.Equal(ProcessingStatus.Pending, stored.Audio.TranscodeStatus);
} }
[Theory]
[InlineData(true)]
[InlineData(false)]
public async Task Handle_StoresExplicitFlag(bool isExplicit)
{
var user = TestEntities.User();
var (picture, audio) = SeedIntents(user.Id);
await SeedAsync(user, picture, audio);
var command = new CreateTrackCommand(user.Id, "My Song", picture.Id, audio.Id, [Genre.Pop], isExplicit);
var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken);
Assert.False(result.IsError);
Assert.Equal(isExplicit, result.Value.IsExplicit);
var stored = await Database.Tracks.FindAsync([result.Value.Id], TestContext.Current.CancellationToken);
Assert.NotNull(stored);
Assert.Equal(isExplicit, stored.IsExplicit);
}
[Fact] [Fact]
public async Task Handle_NoTags_ReturnsValidationError() public async Task Handle_NoTags_ReturnsValidationError()
{ {
@@ -0,0 +1,95 @@
using ErrorOr;
using Musify.Application.Shared;
using Musify.Application.Tests.TestSupport;
using Musify.Application.Users;
using Musify.Application.Users.Responses;
using Musify.Domain.Entities;
using Xunit;
namespace Musify.Application.Tests.Users
{
public sealed class GetUserFollowersQueryHandlerTests : HandlerTestBase
{
private GetUserFollowersQueryHandler CreateHandler() => new(Database);
private async Task<ErrorOr<PaginatedResponse<UserSummaryResponse>>> Send(long? viewerId, long userId) =>
await CreateHandler().Handle(new GetUserFollowersQuery(viewerId, userId, 1, 20), TestContext.Current.CancellationToken);
[Fact]
public async Task Handle_OwnProfile_ReturnsFollowers()
{
var user = TestEntities.User(1, "user");
var follower = TestEntities.User(2, "follower");
await SeedAsync(user, follower, new UserFollow { FollowerId = follower.Id, FollowedId = user.Id });
var result = await Send(user.Id, user.Id);
Assert.False(result.IsError);
var item = Assert.Single(result.Value.Items);
Assert.Equal(follower.Id, item.Id);
Assert.False(item.IsFollowedByViewer);
}
[Fact]
public async Task Handle_MutualFollow_ReturnsFollowersWithViewerFollowState()
{
var viewer = TestEntities.User(1, "viewer");
var user = TestEntities.User(2, "user");
var other = TestEntities.User(3, "other");
await SeedAsync(
viewer, user, other,
new UserFollow { FollowerId = viewer.Id, FollowedId = user.Id },
new UserFollow { FollowerId = user.Id, FollowedId = viewer.Id },
new UserFollow { FollowerId = other.Id, FollowedId = user.Id },
new UserFollow { FollowerId = viewer.Id, FollowedId = other.Id });
var result = await Send(viewer.Id, user.Id);
Assert.False(result.IsError);
Assert.Equal(2, result.Value.TotalItemCount);
Assert.All(result.Value.Items, item => Assert.Equal(item.Id == other.Id, item.IsFollowedByViewer));
}
[Fact]
public async Task Handle_ViewerFollowsButNotFollowedBack_ReturnsForbidden()
{
var viewer = TestEntities.User(1, "viewer");
var user = TestEntities.User(2, "user");
await SeedAsync(viewer, user, new UserFollow { FollowerId = viewer.Id, FollowedId = user.Id });
var result = await Send(viewer.Id, user.Id);
Assert.Equal(ErrorType.Forbidden, result.FirstError.Type);
}
[Fact]
public async Task Handle_FollowedByUserButNotFollowingBack_ReturnsForbidden()
{
var viewer = TestEntities.User(1, "viewer");
var user = TestEntities.User(2, "user");
await SeedAsync(viewer, user, new UserFollow { FollowerId = user.Id, FollowedId = viewer.Id });
var result = await Send(viewer.Id, user.Id);
Assert.Equal(ErrorType.Forbidden, result.FirstError.Type);
}
[Fact]
public async Task Handle_Anonymous_ReturnsForbidden()
{
await SeedAsync(TestEntities.User(1, "user"));
var result = await Send(null, 1);
Assert.Equal(ErrorType.Forbidden, result.FirstError.Type);
}
[Fact]
public async Task Handle_UserMissing_ReturnsNotFound()
{
var result = await Send(1, 404);
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
}
}
}
@@ -0,0 +1,75 @@
using ErrorOr;
using Musify.Application.Tests.TestSupport;
using Musify.Application.Users;
using Musify.Domain.Entities;
using Xunit;
namespace Musify.Application.Tests.Users
{
public sealed class GetUserFollowingQueryHandlerTests : HandlerTestBase
{
private GetUserFollowingQueryHandler CreateHandler() => new(Database);
[Fact]
public async Task Handle_Anonymous_ReturnsFollowedUsers()
{
var user = TestEntities.User(1, "user");
var followedA = TestEntities.User(2, "followedA");
var followedB = TestEntities.User(3, "followedB");
await SeedAsync(
user, followedA, followedB,
new UserFollow { FollowerId = user.Id, FollowedId = followedA.Id },
new UserFollow { FollowerId = user.Id, FollowedId = followedB.Id });
var result = await CreateHandler().Handle(new GetUserFollowingQuery(null, user.Id, 1, 20), TestContext.Current.CancellationToken);
Assert.False(result.IsError);
Assert.Equal(2, result.Value.TotalItemCount);
Assert.All(result.Value.Items, item => Assert.False(item.IsFollowedByViewer));
}
[Fact]
public async Task Handle_Viewer_MarksUsersTheViewerFollows()
{
var viewer = TestEntities.User(1, "viewer");
var user = TestEntities.User(2, "user");
var followedA = TestEntities.User(3, "followedA");
var followedB = TestEntities.User(4, "followedB");
await SeedAsync(
viewer, user, followedA, followedB,
new UserFollow { FollowerId = user.Id, FollowedId = followedA.Id },
new UserFollow { FollowerId = user.Id, FollowedId = followedB.Id },
new UserFollow { FollowerId = viewer.Id, FollowedId = followedA.Id });
var result = await CreateHandler().Handle(new GetUserFollowingQuery(viewer.Id, user.Id, 1, 20), TestContext.Current.CancellationToken);
Assert.False(result.IsError);
Assert.All(result.Value.Items, item => Assert.Equal(item.Id == followedA.Id, item.IsFollowedByViewer));
}
[Fact]
public async Task Handle_Pagination_ReturnsRequestedPage()
{
await SeedAsync(
TestEntities.User(1, "user"), TestEntities.User(2, "a"), TestEntities.User(3, "b"), TestEntities.User(4, "c"),
new UserFollow { FollowerId = 1, FollowedId = 2 },
new UserFollow { FollowerId = 1, FollowedId = 3 },
new UserFollow { FollowerId = 1, FollowedId = 4 });
var result = await CreateHandler().Handle(new GetUserFollowingQuery(null, 1, 2, 2), TestContext.Current.CancellationToken);
Assert.False(result.IsError);
Assert.Single(result.Value.Items);
Assert.Equal(3, result.Value.TotalItemCount);
Assert.False(result.Value.HasNextPage);
}
[Fact]
public async Task Handle_UserMissing_ReturnsNotFound()
{
var result = await CreateHandler().Handle(new GetUserFollowingQuery(null, 404, 1, 20), TestContext.Current.CancellationToken);
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
}
}
}
@@ -30,6 +30,46 @@ namespace Musify.Application.Tests.Users
Assert.Equal(1, result.Value.FollowingCount); Assert.Equal(1, result.Value.FollowingCount);
} }
[Fact]
public async Task Handle_MutualFollow_ViewerCanSeeFollowersAndIsFollowing()
{
var viewer = TestEntities.User(1, "viewer");
var user = TestEntities.User(2, "user");
await SeedAsync(
viewer, user,
new UserFollow { FollowerId = viewer.Id, FollowedId = user.Id },
new UserFollow { FollowerId = user.Id, FollowedId = viewer.Id });
var result = await CreateHandler().Handle(new GetUserProfileQuery(user.Id, viewer.Id), TestContext.Current.CancellationToken);
Assert.True(result.Value.IsFollowing);
Assert.True(result.Value.CanViewFollowers);
}
[Fact]
public async Task Handle_OneWayFollow_ViewerCannotSeeFollowers()
{
var viewer = TestEntities.User(1, "viewer");
var user = TestEntities.User(2, "user");
await SeedAsync(viewer, user, new UserFollow { FollowerId = viewer.Id, FollowedId = user.Id });
var result = await CreateHandler().Handle(new GetUserProfileQuery(user.Id, viewer.Id), TestContext.Current.CancellationToken);
Assert.True(result.Value.IsFollowing);
Assert.False(result.Value.CanViewFollowers);
}
[Fact]
public async Task Handle_OwnProfile_CanSeeFollowersButIsNotFollowing()
{
await SeedAsync(TestEntities.User(1, "user"));
var result = await CreateHandler().Handle(new GetUserProfileQuery(1, 1), TestContext.Current.CancellationToken);
Assert.False(result.Value.IsFollowing);
Assert.True(result.Value.CanViewFollowers);
}
[Fact] [Fact]
public async Task Handle_UserMissing_ReturnsNotFound() public async Task Handle_UserMissing_ReturnsNotFound()
{ {
@@ -1,5 +1,6 @@
using Musify.Application.Tests.TestSupport; using Musify.Application.Tests.TestSupport;
using Musify.Application.Users; using Musify.Application.Users;
using Musify.Domain.Entities;
using Xunit; using Xunit;
namespace Musify.Application.Tests.Users namespace Musify.Application.Tests.Users
@@ -30,5 +31,18 @@ namespace Musify.Application.Tests.Users
var user = Assert.Single(result.Value.Items); var user = Assert.Single(result.Value.Items);
Assert.Equal(1, user.Id); Assert.Equal(1, user.Id);
} }
[Fact]
public async Task Handle_Viewer_MarksFollowedUsers()
{
await SeedAsync(
TestEntities.User(1, "viewer"), TestEntities.User(2, "alice"), TestEntities.User(3, "bob"),
new UserFollow { FollowerId = 1, FollowedId = 2 });
var result = await CreateHandler().Handle(new GetUsersQuery(PageNumber: 1, PageSize: 10, UsernameSearch: null, ViewerId: 1), TestContext.Current.CancellationToken);
Assert.False(result.IsError);
Assert.All(result.Value.Items, item => Assert.Equal(item.Id == 2, item.IsFollowedByViewer));
}
} }
} }
+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
+9 -5
View File
@@ -42,7 +42,7 @@ npm run lint:tokens # scripts/check-tokens.mjs, the design system guardrail
- `media/` for music domain display components (Artwork, TrackList, MediaCard, PlayButton, and so on). - `media/` for music domain display components (Artwork, TrackList, MediaCard, PlayButton, and so on).
- `forms/` for composite entity forms (AlbumForm, PlaylistForm, CoverForm, ImageDropzone). - `forms/` for composite entity forms (AlbumForm, PlaylistForm, CoverForm, ImageDropzone).
- `layout/` for page level scaffolding (Page, PageHeader, SectionHeading). This is different from `components/layout/` above, which is the global app chrome. - `layout/` for page level scaffolding (Page, PageHeader, SectionHeading). This is different from `components/layout/` above, which is the global app chrome.
- Auth routes live under `src/routes/auth/`: `+page.svelte` is the login screen, and `login/`, `logout/`, `callback/` are the OIDC endpoints. The public user profile route is `src/routes/(app)/user/[id]/`. - Auth routes live under `src/routes/auth/`: `+page.svelte` is the login screen, and `login/`, `logout/`, `callback/` are the OIDC endpoints. The public user profile route is `src/routes/(app)/user/[id]/`, and its followers/following lists share one route, `user/[id]/[list=followList]/` (matcher in `src/params/followList.ts`).
## Styles ## Styles
@@ -50,20 +50,24 @@ 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.
- Reach for these base components before creating a new one: `Artwork`, `Avatar`, `ListRow`, `MediaIdentity`, `TrackList`, `MediaCard`, `PageHeader`, `ContextMenu`, `ConfirmDialog`, `PlayButton`, `Slider`, `Logo`, `Chip`, `SegmentedControl`, `CoverForm`. - Reach for these base components before creating a new one: `Artwork`, `Avatar`, `ListRow`, `MediaIdentity`, `TrackList`, `MediaCard`, `PageHeader`, `ContextMenu`, `ConfirmDialog`, `PlayButton`, `Slider`, `Logo`, `Chip`, `SegmentedControl`, `CoverForm`.
- Known and accepted gap (documented in `scripts/check-tokens.mjs`): icon `strokeWidth={n}` isn't unified yet. There are 13 different values spread across almost every component, and migrating them to `stroke-thin/regular/bold` recipes safely would require visually confirming that `lucide-svelte` actually respects `stroke-width` through CSS, so that was left out of the last pass. - **Icon stroke:** one global rule (`svg.lucide { stroke-width: 1.5 }` in `layout.css`) sets the stroke for every icon. Never pass `strokeWidth` to an icon, since any CSS rule overrides the SVG attribute and it would do nothing. For a different stroke on one icon, use a Tailwind `stroke-*` utility.
## App shell
The shell in `src/routes/+layout.svelte` is a fixed `h-dvh` frame: Sidebar | (content column over PlayerDock) | Queue. Only the content column (`app-scroll`) scrolls, so nothing ever passes under the dock and the chrome can stay transparent over `app-backdrop`. Because the window never scrolls, scroll reset/restore on navigation is handled by `restoreScroll()` in `$lib/state/navigation.svelte.ts`; don't use `window.scrollTo`/`scrollY`.
## Motion ## Motion
Every animation follows one scale of three durations and one easing curve, all defined in `theme.css`. Fast (`--mf-motion-fast`, 150ms) is for hover, focus and press feedback: color, opacity and small scale changes. Plain `transition` or `transition-colors` already uses it because it is the Tailwind default, so never write `duration-150`. Base (`--mf-motion-base`, 200ms) is for things that appear on top of the page or move inside a control: modals, menus, the queue panel, `animate-pop`, `animate-fade` and the segmented control thumb. Slow (`--mf-motion-slow`, 300ms) is for content entering the page and larger movement: `animate-enter`, cover zoom on hover and the player dock padding. Every animation follows one scale of three durations and one easing curve, all defined in `theme.css`. Fast (`--mf-motion-fast`, 150ms) is for hover, focus and press feedback: color, opacity and small scale changes. Plain `transition` or `transition-colors` already uses it because it is the Tailwind default, so never write `duration-150`. Base (`--mf-motion-base`, 200ms) is for things that appear on top of the page or move inside a control: modals, menus, the queue panel, `animate-pop`, `animate-fade` and the segmented control thumb. Slow (`--mf-motion-slow`, 300ms) is for content entering the page and larger movement: `animate-enter`, cover zoom on hover.
Entrances and movement always use `--mf-ease` (`ease-snappy` in Tailwind, `expoOut` in Svelte transitions), which feels immediate because most of the motion happens at the start. Color transitions keep the Tailwind default curve. Overlays must feel instant: the modal backdrop uses `transition:fade={{ duration: 200 }}` and the panel uses a local `pop` transition (opacity + scale + translateY, `expoOut`, 200ms) mirroring `animate-pop`'s keyframes — both directives so the modal fades and pops on close too, not just on open, and the whole thing settles in 200ms. Staggered lists use `--i` with 40ms steps capped at ten items. Entrances and movement always use `--mf-ease` (`ease-snappy` in Tailwind, `expoOut` in Svelte transitions), which feels immediate because most of the motion happens at the start. Color transitions keep the Tailwind default curve. Overlays must feel instant: the modal backdrop uses `transition:fade={{ duration: 200 }}` and the panel uses a local `pop` transition (opacity + scale + translateY, `expoOut`, 200ms) mirroring `animate-pop`'s keyframes — both directives so the modal fades and pops on close too, not just on open, and the whole thing settles in 200ms. Staggered lists use `--i` with 40ms steps capped at ten items.
The only exceptions are looping indicators (equalizer bars, pulse, spin, sheen) and the accent hue crossfade in `+layout.svelte` (600ms), which is ambient and meant to be slow. Do not add new durations or curves; if something needs a different feel, pick the closest step of the scale. `prefers-reduced-motion` disables everything globally in `layout.css`. The only exceptions are looping indicators (equalizer bars, pulse, spin, sheen) and the accent hue crossfade in `+layout.svelte` (a random 2 to 4 seconds per song change), which is ambient and meant to be slow. Do not add new durations or curves; if something needs a different feel, pick the closest step of the scale. `prefers-reduced-motion` disables everything globally in `layout.css`.
## Auth ## Auth
+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 -1
View File
@@ -1,5 +1,5 @@
<!doctype html> <!doctype html>
<html lang="en" class="scrollbar-stable"> <html lang="en">
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
+133 -1
View File
@@ -658,6 +658,40 @@ export interface paths {
patch?: never; patch?: never;
trace?: never; trace?: never;
}; };
"/users/{id}/followers": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Get The Users Following A User. Visible To The User And To Mutual Followers. */
get: operations["GetUserFollowers"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/users/{id}/following": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Get The Users A User Follows. */
get: operations["GetUserFollowing"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/users/{id}/is-following": { "/users/{id}/is-following": {
parameters: { parameters: {
query?: never; query?: never;
@@ -773,6 +807,8 @@ export interface components {
/** Format: uuid */ /** Format: uuid */
audioIntentId: string; audioIntentId: string;
tags: components["schemas"]["Genre"][]; tags: components["schemas"]["Genre"][];
/** @default false */
isExplicit: boolean;
}; };
CreateUserRequest: { CreateUserRequest: {
/** Format: int64 */ /** Format: int64 */
@@ -865,6 +901,19 @@ export interface components {
hasNextPage: boolean; hasNextPage: boolean;
hasPreviousPage: boolean; hasPreviousPage: boolean;
}; };
PaginatedResponseOfUserSummaryResponse: {
items: components["schemas"]["UserSummaryResponse"][];
/** Format: int32 */
pageNumber: number | string;
/** Format: int32 */
pageSize: number | string;
/** Format: int32 */
pageCount: number | string;
/** Format: int32 */
totalItemCount: number | string;
hasNextPage: boolean;
hasPreviousPage: boolean;
};
PaginatedResponseOfUserApplicationResponse: { PaginatedResponseOfUserApplicationResponse: {
items: components["schemas"]["UserApplicationResponse"][]; items: components["schemas"]["UserApplicationResponse"][];
/** Format: int32 */ /** Format: int32 */
@@ -891,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;
@@ -959,6 +1009,7 @@ export interface components {
updatedAt: string; updatedAt: string;
ownerUserId: string; ownerUserId: string;
tags: components["schemas"]["Genre"][]; tags: components["schemas"]["Genre"][];
isExplicit: boolean;
}; };
TrackSearchItemResponse: { TrackSearchItemResponse: {
track: components["schemas"]["TrackApplicationResponse"]; track: components["schemas"]["TrackApplicationResponse"];
@@ -1039,6 +1090,16 @@ export interface components {
followersCount: number | string; followersCount: number | string;
/** Format: int32 */ /** Format: int32 */
followingCount: number | string; followingCount: number | string;
isFollowing: boolean;
canViewFollowers: boolean;
};
UserSummaryResponse: {
id: string;
name: string;
firstName: null | string;
secondName: null | string;
profilePictureUrl: null | string;
isFollowedByViewer: boolean;
}; };
}; };
responses: never; responses: never;
@@ -2428,7 +2489,7 @@ export interface operations {
[name: string]: unknown; [name: string]: unknown;
}; };
content: { content: {
"application/json": components["schemas"]["PaginatedResponseOfUserApplicationResponse"]; "application/json": components["schemas"]["PaginatedResponseOfUserSummaryResponse"];
}; };
}; };
}; };
@@ -2611,6 +2672,77 @@ export interface operations {
}; };
}; };
}; };
GetUserFollowers: {
parameters: {
query?: {
pageNumber?: number | string;
pageSize?: number | string;
};
header?: never;
path: {
id: number | string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["PaginatedResponseOfUserSummaryResponse"];
};
};
/** @description Forbidden */
403: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description Not Found */
404: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
GetUserFollowing: {
parameters: {
query?: {
pageNumber?: number | string;
pageSize?: number | string;
};
header?: never;
path: {
id: number | string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["PaginatedResponseOfUserSummaryResponse"];
};
};
/** @description Not Found */
404: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
IsFollowingUser: { IsFollowingUser: {
parameters: { parameters: {
query?: never; query?: never;
@@ -44,7 +44,7 @@
</AccountMenu> </AccountMenu>
{:else} {:else}
<IconButton href="/auth/login" reload label="Iniciar sesión" shape="round" size="md"> <IconButton href="/auth/login" reload label="Iniciar sesión" shape="round" size="md">
<LogIn class="size-icon-lg" strokeWidth={2} /> <LogIn class="size-icon-lg" />
</IconButton> </IconButton>
{/if} {/if}
</header> </header>
@@ -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);
@@ -31,7 +36,7 @@
</script> </script>
<aside <aside
class="hidden shrink-0 flex-col border-r border-hairline p-5 lg:flex" class="hidden min-h-0 shrink-0 flex-col border-r border-hairline p-5 lg:flex"
style="width:var(--mf-sidebar-w)" style="width:var(--mf-sidebar-w)"
> >
<a href="/" class="mb-4"> <a href="/" class="mb-4">
@@ -48,7 +53,7 @@
? 'bg-surface-2 text-fg' ? 'bg-surface-2 text-fg'
: 'text-fg-2 hover:bg-hover hover:text-fg'}" : 'text-fg-2 hover:bg-hover hover:text-fg'}"
> >
<link.icon class="size-icon-md shrink-0" strokeWidth={1.5} /> <link.icon class="size-icon-md shrink-0" />
<span class="flex-1 truncate">{link.label}</span> <span class="flex-1 truncate">{link.label}</span>
{#if link.count} {#if link.count}
<span class="text-xs text-fg-2 tabular-nums">{link.count}</span> <span class="text-xs text-fg-2 tabular-nums">{link.count}</span>
@@ -61,20 +66,20 @@
<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()}
> >
<Plus class="size-icon-sm text-fg-2" strokeWidth={1.25} /> <Plus class="size-icon-sm text-fg-2" />
</IconButton> </IconButton>
{/if} {/if}
</div> </div>
<div class="flex 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,9 +96,9 @@
<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" strokeWidth={1.8} /> <Plus class="size-3.5" />
Crear tu primera lista Crear tu primera lista
</button> </button>
{/each} {/each}
@@ -18,7 +18,7 @@
</script> </script>
<nav <nav
class="pointer-events-auto flex items-center justify-around glass-bar px-4 py-2 lg:hidden {klass}" class="pointer-events-auto flex items-center justify-around px-4 py-2 glass-bar lg:hidden {klass}"
style="padding-bottom:calc(0.4375rem + var(--mf-safe-b))" style="padding-bottom:calc(0.4375rem + var(--mf-safe-b))"
> >
{#each links as link (link.href)} {#each links as link (link.href)}
@@ -30,7 +30,7 @@
? 'text-fg' ? 'text-fg'
: 'text-fg-2'}" : 'text-fg-2'}"
> >
<link.icon class="size-4" strokeWidth={1.6} /> <link.icon class="size-4" />
<span class="text-xs font-medium">{link.label}</span> <span class="text-xs font-medium">{link.label}</span>
</a> </a>
{/each} {/each}
@@ -54,22 +54,15 @@
</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" <ChevronLeft class="size-icon-sm" />
shape="round"
tone="subtle"
surface
size="xs"
onclick={() => history.back()}
>
<ChevronLeft class="size-icon-sm" strokeWidth={1.9} />
</IconButton> </IconButton>
<form onsubmit={onSubmit} class="max-w-search min-w-0 flex-1"> <form onsubmit={onSubmit} class="max-w-search min-w-0 flex-1">
<label <label
class="flex h-9.5 w-full items-center gap-2.5 rounded-control bg-surface-2 px-3.5 transition focus-within:bg-surface-hover" class="flex h-9.5 w-full items-center gap-2.5 rounded-control bg-surface-2 px-3.5 transition focus-within:bg-surface-hover"
> >
<Search class="size-4 shrink-0 text-fg-2" strokeWidth={1.8} /> <Search class="size-4 shrink-0 text-fg-2" />
<input <input
type="text" type="text"
bind:this={searchInput} bind:this={searchInput}
@@ -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 })}
@@ -6,16 +6,18 @@
import { player } from '$lib/player/player.svelte'; import { player } from '$lib/player/player.svelte';
import { queuePanel } from '$lib/player/queuePanel.svelte'; import { queuePanel } from '$lib/player/queuePanel.svelte';
import { fmtTime } from '$lib/utils/format'; import { fmtTime } from '$lib/utils/format';
import Volume1 from '@lucide/svelte/icons/volume-1';
import Volume2 from '@lucide/svelte/icons/volume-2'; import Volume2 from '@lucide/svelte/icons/volume-2';
import VolumeX from '@lucide/svelte/icons/volume-x';
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>
<div <div
class="animate-enter pointer-events-auto flex items-center gap-icon-md glass-bar px-4 py-3 sm:hidden" class="animate-enter pointer-events-auto flex items-center gap-icon-md px-4 py-3 glass-bar sm:hidden"
> >
<div class="min-w-0 flex-1"> <div class="min-w-0 flex-1">
<TrackInfo compact /> <TrackInfo compact />
@@ -24,7 +26,7 @@
</div> </div>
<div <div
class="animate-enter pointer-events-auto hidden items-center gap-5 glass-bar px-4 py-3 sm:flex" class="animate-enter pointer-events-auto hidden items-center gap-5 px-4 py-3 glass-bar sm:flex"
> >
<div class="w-(--mf-player-side-w) min-w-0 shrink-0"> <div class="w-(--mf-player-side-w) min-w-0 shrink-0">
<TrackInfo /> <TrackInfo />
@@ -37,20 +39,33 @@
</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">
<Volume2 class="size-icon-sm shrink-0 text-fg-2" strokeWidth={1.8} /> <IconButton
label={player.muted ? 'Activar sonido' : 'Silenciar'}
plain
size="xs"
onclick={() => player.toggleMute()}
>
{#if player.muted || player.volume === 0}
<VolumeX class="size-icon-sm" />
{:else if player.volume < 50}
<Volume1 class="size-icon-sm" />
{:else}
<Volume2 class="size-icon-sm" />
{/if}
</IconButton>
<Slider <Slider
value={player.volume} value={player.muted ? 0 : player.volume}
max={100} max={100}
label="Volumen" label="Volumen"
oninput={(value) => player.setVolume(value)} oninput={(value) => player.setVolume(value)}
@@ -58,12 +73,12 @@
/> />
<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()}
> >
<ListMusic class="size-icon-sm" strokeWidth={1.8} /> <ListMusic class="size-icon-sm" />
</IconButton> </IconButton>
</div> </div>
</div> </div>
@@ -13,9 +13,7 @@
const hasTrack = $derived(player.currentId !== null); const hasTrack = $derived(player.currentId !== null);
</script> </script>
<div <div class="flex shrink-0 flex-col">
class="pointer-events-none fixed inset-x-0 bottom-0 z-(--z-sticky) flex flex-col lg:left-(--mf-sidebar-w)"
>
{#if hasTrack} {#if hasTrack}
<PlayerBar /> <PlayerBar />
{/if} {/if}
@@ -5,37 +5,63 @@
import ListRow from '$lib/components/ui/media/ListRow.svelte'; import ListRow from '$lib/components/ui/media/ListRow.svelte';
import IconButton from '$lib/components/ui/primitives/IconButton.svelte'; import IconButton from '$lib/components/ui/primitives/IconButton.svelte';
import { fmtTime } from '$lib/utils/format'; import { fmtTime } from '$lib/utils/format';
import X from '@lucide/svelte/icons/x'; import { flip } from 'svelte/animate';
import { fly } from 'svelte/transition';
import { expoOut } from 'svelte/easing'; import { expoOut } from 'svelte/easing';
import X from '@lucide/svelte/icons/x';
const upcoming = $derived.by(() => { const upcoming = $derived.by(() => {
const idx = player.tracks.findIndex((t) => t.id === player.currentId); const idx = player.tracks.findIndex((t) => t.id === player.currentId);
return idx === -1 ? player.tracks : player.tracks.slice(idx + 1); return idx === -1 ? player.tracks : player.tracks.slice(idx + 1);
}); });
let dragFrom = $state<number | null>(null);
let dropAt = $state<number | null>(null);
function onDragOver(event: DragEvent, index: number) {
if (dragFrom === null) return;
event.preventDefault();
if (event.dataTransfer) event.dataTransfer.dropEffect = 'move';
const rect = (event.currentTarget as HTMLElement).getBoundingClientRect();
dropAt = event.clientY < rect.top + rect.height / 2 ? index : index + 1;
}
function onDrop(event: DragEvent) {
event.preventDefault();
if (dragFrom !== null && dropAt !== null) player.moveQueueItem(dragFrom, dropAt);
endDrag();
}
function endDrag() {
dragFrom = null;
dropAt = null;
}
</script> </script>
{#if queuePanel.open} <div
class="queue-shell max-sm:hidden"
data-open={queuePanel.open ? '' : undefined}
inert={!queuePanel.open}
>
<aside <aside
transition:fly={{ x: 24, duration: 200, easing: expoOut }} class="flex min-h-0 shrink-0 flex-col border-l border-hairline px-4 pt-5 pb-5"
class="hidden shrink-0 flex-col border-l border-hairline px-4 pt-5 sm:flex" style="width:var(--mf-queue-w)"
style="width:var(--mf-queue-w); padding-bottom:calc(var(--mf-player-h) + 2rem)"
> >
<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 rounded-control surface-active p-2.5"> <div class="mb-6 surface-active rounded-control p-2.5">
<MediaIdentity <MediaIdentity
trackId={player.current.id} trackId={player.current.id}
title={player.current.title} title={player.current.title}
artist={player.current.artist || '—'} artist={player.current.artist || '—'}
ownerUserId={player.current.ownerUserId} ownerUserId={player.current.ownerUserId}
explicit={player.current.explicit}
active active
/> />
</div> </div>
@@ -54,27 +80,66 @@
{/if} {/if}
</div> </div>
<div class="flex flex-1 flex-col gap-px overflow-y-auto"> <!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="flex min-h-0 flex-1 flex-col gap-px overflow-y-auto"
ondragover={(event) => dragFrom !== null && event.preventDefault()}
ondrop={onDrop}
>
{#each upcoming as track, i (track.id)} {#each upcoming as track, i (track.id)}
{@const index = player.tracks.length - upcoming.length + i} {@const index = player.tracks.length - upcoming.length + i}
<ListRow <!-- svelte-ignore a11y_no_static_element_interactions -->
onclick={() => player.playQueueIndex(index)} <div
size="sm" draggable="true"
title={track.title} ondragstart={(event) => {
subtitle={track.artist || '—'} dragFrom = index;
subtitleHref={track.ownerUserId} if (event.dataTransfer) event.dataTransfer.effectAllowed = 'move';
trackId={track.id} }}
class="p-2" ondragover={(event) => onDragOver(event, index)}
ondrop={onDrop}
ondragend={endDrag}
class="queue-row"
data-dragging={dragFrom === index ? '' : undefined}
data-drop={dragFrom === null
? undefined
: dropAt === index
? 'before'
: dropAt === index + 1 && index === player.tracks.length - 1
? 'after'
: undefined}
animate:flip={{ duration: 300, easing: expoOut }}
> >
{#snippet trailing()} {#if dropAt === index && dragFrom !== null}
{#if track.duration} <div
<span class="shrink-0 text-xs text-fg-2 tabular-nums">{fmtTime(track.duration)}</span> class="pointer-events-none absolute inset-x-2 -top-7 h-0.5 rounded-full bg-accent"
{/if} ></div>
{/snippet} {:else if dropAt === index + 1 && dropAt === player.tracks.length && dragFrom !== null}
</ListRow> <div
class="pointer-events-none absolute inset-x-2 -bottom-7 h-0.5 rounded-full bg-accent"
></div>
{/if}
<ListRow
onclick={() => player.playQueueIndex(index)}
size="sm"
title={track.title}
subtitle={track.artist || '—'}
subtitleHref={track.ownerUserId}
explicit={track.explicit}
trackId={track.id}
class="p-2"
>
{#snippet trailing()}
{#if track.duration}
<span class="shrink-0 text-xs text-fg-2 tabular-nums"
>{fmtTime(track.duration)}</span
>
{/if}
{/snippet}
</ListRow>
</div>
{:else} {:else}
<p class="px-2 py-6 text-center text-xs text-fg-2">No hay más canciones en la cola.</p> <p class="px-2 py-6 text-center text-xs text-fg-2">No hay más canciones en la cola.</p>
{/each} {/each}
</div> </div>
</aside> </aside>
{/if} </div>
@@ -13,54 +13,47 @@
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}
<div class="size-cover-sm shrink-0 rounded-art bg-surface md:size-cover-md"></div> <div class="size-cover-sm shrink-0 rounded-art bg-surface md:size-cover-md"></div>
{/if} {/if}
<div class="min-w-0 flex-1"> <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="xs" size="md"
pressed={isLiked} pressed={isLiked}
onclick={toggleLike} onclick={toggleLike}
> >
<Heart class="size-icon-md" fill={isLiked ? 'currentColor' : 'none'} strokeWidth={1.7} /> <Heart class="size-icon-md" fill={isLiked ? 'currentColor' : 'none'} />
</IconButton> </IconButton>
{/if} {/if}
</div> </div>
@@ -26,7 +26,7 @@
aria-label="Aleatorio" aria-label="Aleatorio"
class="transition-colors hover:text-fg {player.shuffle ? 'text-accent' : 'text-fg-2'}" class="transition-colors hover:text-fg {player.shuffle ? 'text-accent' : 'text-fg-2'}"
> >
<Shuffle class="size-4" strokeWidth={1.8} /> <Shuffle class="size-4" />
</button> </button>
{/if} {/if}
@@ -58,7 +58,7 @@
aria-label="Repetir" aria-label="Repetir"
class="transition-colors hover:text-fg {player.repeat ? 'text-accent' : 'text-fg-2'}" class="transition-colors hover:text-fg {player.repeat ? 'text-accent' : 'text-fg-2'}"
> >
<Repeat class="size-4" strokeWidth={1.8} /> <Repeat class="size-4" />
</button> </button>
{/if} {/if}
</div> </div>
@@ -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
@@ -68,6 +75,6 @@
class="h-full w-full object-cover" class="h-full w-full object-cover"
/> />
{:else} {:else}
<Icon class="size-icon-xl {gradient ? 'text-on-art/70' : 'text-fg-2'}" strokeWidth={1.3} /> <Icon class="size-icon-xl {gradient ? 'text-on-art/70' : 'text-fg-2'}" />
{/if} {/if}
</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"
@@ -5,7 +5,7 @@
title: string; title: string;
eyebrow?: string; eyebrow?: string;
description?: string; description?: string;
meta?: string; meta?: string | Snippet;
cover?: Snippet; cover?: Snippet;
actions?: Snippet; actions?: Snippet;
align?: 'center' | 'end'; align?: 'center' | 'end';
@@ -45,8 +45,10 @@
{#if description} {#if description}
<p class="mt-3 max-w-2xl text-body text-fg-2">{description}</p> <p class="mt-3 max-w-2xl text-body text-fg-2">{description}</p>
{/if} {/if}
{#if meta} {#if typeof meta === 'string'}
<p class="mt-3 text-sm text-fg-2">{meta}</p> <p class="mt-3 text-sm text-fg-2">{meta}</p>
{:else if meta}
<p class="mt-3 text-sm text-fg-2">{@render meta()}</p>
{/if} {/if}
</div> </div>
{#if actions && !cover} {#if actions && !cover}
@@ -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:var(--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>
@@ -7,7 +7,7 @@
</script> </script>
<span <span
class="grid size-4 shrink-0 place-items-center rounded-tag bg-fg-2 text-xs leading-4 font-bold text-bg {klass}" class="grid size-4 shrink-0 place-items-center rounded-tag border border-accent/30 bg-accent-tint pt-px text-xs leading-none font-normal text-accent-soft {klass}"
title="Contenido explícito" title="Contenido explícito"
aria-label="Contenido explícito">E</span aria-label="Contenido explícito">E</span
> >
@@ -0,0 +1,40 @@
<script lang="ts">
import { untrack } from 'svelte';
import { enhance } from '$app/forms';
import Button from '../primitives/Button.svelte';
interface Props {
following: boolean;
invalidateAll?: boolean;
class?: string;
}
let { following: initial, invalidateAll = false, class: klass = '' }: Props = $props();
let following = $state(untrack(() => initial));
let submitting = $state(false);
</script>
<form
method="POST"
action={following ? '?/unfollow' : '?/follow'}
class={klass}
use:enhance={() => {
submitting = true;
return async ({ result, update }) => {
if (result.type === 'success' && typeof result.data?.following === 'boolean')
following = result.data.following;
await update({ reset: false, invalidateAll });
submitting = false;
};
}}
>
<Button
type="submit"
size="sm"
variant={following ? 'secondary' : 'primary'}
disabled={submitting}
>
{following ? 'Dejar de seguir' : 'Seguir'}
</Button>
</form>
@@ -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' },
@@ -95,7 +86,7 @@
style="--mf-track-cols:{gridColumns}; --mf-track-cols-mobile:{gridColumnsMobile}" style="--mf-track-cols:{gridColumns}; --mf-track-cols-mobile:{gridColumnsMobile}"
> >
{#if index} {#if index}
<Hash size={16} strokeWidth={1.5} /> <Hash size={16} />
{/if} {/if}
<span>Título</span> <span>Título</span>
{#each columns as column (column)} {#each columns as column (column)}
@@ -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" />
@@ -0,0 +1,20 @@
<script lang="ts">
import ListRow from './ListRow.svelte';
import Avatar from './Avatar.svelte';
interface Props {
user: {
id: string;
name: string;
profilePictureUrl: string | null;
};
}
let { user }: Props = $props();
</script>
<ListRow title={user.name} href="/user/{user.id}" size="lg" class="min-w-0 flex-1">
{#snippet art()}
<Avatar name={user.name} src={user.profilePictureUrl} size="md" />
{/snippet}
</ListRow>
@@ -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}
@@ -24,7 +24,7 @@
> >
{#if tone === 'danger'} {#if tone === 'danger'}
<span class="mb-4 grid size-10 place-items-center rounded-full bg-danger/12 text-danger-fg"> <span class="mb-4 grid size-10 place-items-center rounded-full bg-danger/12 text-danger-fg">
<CircleAlert class="size-icon-md" strokeWidth={2} /> <CircleAlert class="size-icon-md" />
</span> </span>
{/if} {/if}
<h2 class="text-display-3 text-fg">{title}</h2> <h2 class="text-display-3 text-fg">{title}</h2>
@@ -1,6 +1,8 @@
<script lang="ts"> <script lang="ts">
import type { Snippet } from 'svelte'; import type { Snippet } from 'svelte';
import { pop } from '$lib/utils/transitions';
interface Props { interface Props {
class?: string; class?: string;
style?: string; style?: string;
@@ -12,9 +14,8 @@
</script> </script>
<div <div
class="glass-panel animate-pop rounded-panel p-1.5 {border transition:pop|global
? 'border border-hairline' class="glass-panel rounded-panel p-1.5 {border ? 'border border-hairline' : ''} {klass}"
: ''} {klass}"
{style} {style}
> >
{@render children()} {@render children()}
@@ -3,8 +3,7 @@
import X from '@lucide/svelte/icons/x'; import X from '@lucide/svelte/icons/x';
import IconButton from '../primitives/IconButton.svelte'; import IconButton from '../primitives/IconButton.svelte';
import { fade } from 'svelte/transition'; import { fade } from 'svelte/transition';
import { expoOut } from 'svelte/easing'; import { pop } from '$lib/utils/transitions';
import type { TransitionConfig } from 'svelte/transition';
interface Props { interface Props {
open: boolean; open: boolean;
@@ -48,15 +47,6 @@
); );
} }
function pop(node: Element, { duration = 200 }: { duration?: number } = {}): TransitionConfig {
return {
duration,
easing: expoOut,
css: (t) =>
`opacity: ${t}; transform: scale(${0.98 + 0.02 * t}) translateY(${0.25 * (1 - t)}rem);`
};
}
function onWindowKeydown(event: KeyboardEvent) { function onWindowKeydown(event: KeyboardEvent) {
if (!open) return; if (!open) return;
if (event.key === 'Escape') { if (event.key === 'Escape') {
@@ -90,7 +80,7 @@
<div <div
bind:this={panel} bind:this={panel}
transition:pop={{ duration: 200 }} transition:pop={{ duration: 200 }}
class="glass-panel max-h-[85dvh] w-full overflow-y-auto {maxWidth} rounded-panel p-5 outline-none sm:p-6 {panelClass}" class="glass-panel max-h-[85dvh] w-full glow-drift overflow-y-auto {maxWidth} rounded-panel p-5 outline-none sm:p-6 {panelClass}"
role="dialog" role="dialog"
aria-modal="true" aria-modal="true"
aria-label={title} aria-label={title}
@@ -22,7 +22,6 @@
/> />
<Check <Check
class="pointer-events-none absolute size-3.5 text-ink opacity-0 transition peer-checked:opacity-100" class="pointer-events-none absolute size-3.5 text-ink opacity-0 transition peer-checked:opacity-100"
strokeWidth={3}
/> />
</span> </span>
<span class="cursor-pointer">{@render children()}</span> <span class="cursor-pointer">{@render children()}</span>
@@ -15,7 +15,7 @@
<div class="flex flex-col items-center rounded-panel px-6 py-16 text-center {klass}"> <div class="flex flex-col items-center rounded-panel px-6 py-16 text-center {klass}">
{#if Icon} {#if Icon}
<Icon class="size-icon-xl text-fg-2" strokeWidth={1.25} /> <Icon class="size-icon-xl text-fg-2" />
{/if} {/if}
{#if title} {#if title}
<h3 class="mt-6 text-display-3 text-fg"> <h3 class="mt-6 text-display-3 text-fg">
@@ -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>
@@ -24,7 +24,7 @@
oninput={handleInput} oninput={handleInput}
aria-label={label} aria-label={label}
class="mf-slider mf-slider-{variant}" class="mf-slider mf-slider-{variant}"
style="background:linear-gradient(to right, var(--mf-text-2) {percent}%, var(--mf-track) {percent}%)" style="--p:{percent}%"
/> />
<style> <style>
@@ -35,6 +35,17 @@
border-radius: 100px; border-radius: 100px;
outline: none; outline: none;
cursor: pointer; cursor: pointer;
background-image:
linear-gradient(
to right,
transparent var(--p),
color-mix(in srgb, var(--mf-text) 12%, var(--mf-bg)) var(--p)
),
var(--mf-slider-fill);
background-size:
100% 100%,
300% 100%;
animation: gradient-drift 10s linear infinite alternate;
} }
.mf-slider-seek { .mf-slider-seek {
width: 100%; width: 100%;
+8 -3
View File
@@ -1,21 +1,26 @@
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;
export const FOLLOW_LIST_PAGE_SIZE = 30;
export const MENU_WIDTH = 240; export const MENU_WIDTH = 240;
export const SUBMENU_WIDTH = 256; export const SUBMENU_WIDTH = 256;
+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;
} }
} }
} }
+96 -180
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,35 +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 ? 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);
@@ -130,26 +64,24 @@ 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;
const audio = new Audio(); const audio = new Audio();
audio.preload = 'auto'; audio.preload = 'auto';
audio.volume = this.volume / 100; audio.volume = this.volume / 100;
audio.muted = this.muted;
audio.addEventListener('durationchange', () => this.#syncDuration(audio.duration)); audio.addEventListener('durationchange', () => this.#syncDuration(audio.duration));
audio.addEventListener('loadedmetadata', () => this.#syncDuration(audio.duration)); audio.addEventListener('loadedmetadata', () => this.#syncDuration(audio.duration));
audio.addEventListener('timeupdate', () => { audio.addEventListener('timeupdate', () => {
@@ -180,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', () => {
@@ -197,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(() => {}));
@@ -210,23 +153,28 @@ 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' }]
}); });
} }
#tickProgress = () => { #clockTime = 0;
#clockStamp = 0;
#tickProgress = (now: number) => {
const audio = this.#audio; const audio = this.#audio;
if (!audio) return; if (!audio) return;
this.progress = audio.currentTime; if (audio.currentTime !== this.#clockTime) {
this.#clockTime = audio.currentTime;
this.#clockStamp = now;
}
const elapsed = Math.min(0.25, (now - this.#clockStamp) / 1000);
const smooth = this.#clockTime + (audio.paused ? 0 : elapsed * audio.playbackRate);
this.progress = Math.min(smooth, Number.isFinite(audio.duration) ? audio.duration : smooth);
this.#rafId = requestAnimationFrame(this.#tickProgress); this.#rafId = requestAnimationFrame(this.#tickProgress);
}; };
@@ -251,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;
} }
@@ -295,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
);
} }
} }
@@ -348,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() {
@@ -434,16 +341,22 @@ class PlayerState {
else audio.pause(); else audio.pause();
} }
toggleMute() {
this.#applyMuted(!this.muted);
if (!this.muted && this.volume === 0) this.setVolume(50);
}
#applyMuted(value: boolean) {
this.muted = value;
const audio = this.#audioEl();
if (audio) audio.muted = 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();
} }
@@ -466,8 +379,12 @@ class PlayerState {
this.#loadCurrent(); this.#loadCurrent();
} }
moveQueueItem(from: number, insertAt: number) {
this.tracks = move(this.tracks, from, insertAt);
}
clearUpcoming() { clearUpcoming() {
this.tracks = this.currentId === null ? [] : [this.current]; this.tracks = this.current ? [this.current] : [];
} }
previous() { previous() {
@@ -478,25 +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);
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 type ApiClient = ReturnType<typeof createApiClient>;
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)}`);
}
export function requireUser(locals: App.Locals, url: URL): SessionUser { export function requireUser(locals: App.Locals, url: URL): SessionUser {
if (!locals.user) if (!locals.user) loginRedirect(url);
redirect(302, `/auth?returnTo=${encodeURIComponent(url.pathname + url.search)}`);
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);
@@ -0,0 +1,24 @@
import { fail } from '@sveltejs/kit';
import { authedAction } from '$lib/server/api';
const setFollowing = (follow: boolean) =>
authedAction(async ({ api, params }) => {
const id = params.id ?? '';
if (!id) return fail(400, { message: 'Falta el usuario.' });
const options = { params: { path: { id } } };
const { error } = follow
? await api.POST('/users/{id}/follow', options)
: await api.DELETE('/users/{id}/follow', options);
if (error) {
return fail(502, {
message: follow ? 'No se pudo seguir al usuario.' : 'No se pudo dejar de seguir al usuario.'
});
}
return { following: follow, userId: id };
});
export const followUserAction = setFollowing(true);
export const unfollowUserAction = setFollowing(false);
+15
View File
@@ -0,0 +1,15 @@
import type { ApiClient } from '$lib/server/api';
import type { FollowList } from '$lib/types';
export function fetchFollowList(
api: ApiClient,
list: FollowList,
id: string,
pageNumber: number,
pageSize: number
) {
const options = { params: { path: { id }, query: { pageNumber, pageSize } } };
return list === 'followers'
? api.GET('/users/{id}/followers', options)
: api.GET('/users/{id}/following', options);
}
@@ -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);
});
});
+55 -51
View File
@@ -17,69 +17,72 @@ const BIRTHDAY_MESSAGE = 'Hoy toca celebrar. Tú eliges la música.';
const HOUR_SLOTS = [6, 9, 12, 15, 18, 21, 24]; const HOUR_SLOTS = [6, 9, 12, 15, 18, 21, 24];
const WEEKLY_SCHEDULE: string[][] = [ const WEEKLY_SCHEDULE: [string, string][][] = [
[ [
'Domingo de madrugada.', ['Domingo de madrugada.', 'Madrugada de domingo. Todo va más lento.'],
'Buenos días. Tómate el domingo con calma.', ['Buenos días. Tómate el domingo con calma.', 'Buenos días. Hoy nadie tiene prisa.'],
'Domingo. Empieza despacio.', ['Domingo. Empieza despacio.', 'Un domingo así pide algo tranquilo.'],
'Una mañana tranquila merece buena música.', ['Una mañana tranquila merece buena música.', 'Domingo al mediodía. Pon algo que abrace.'],
'Domingo por la tarde. Hora de bajar el ritmo.', [
'La semana empieza a despedirse.', 'Domingo por la tarde. Hora de bajar el ritmo.',
'Una última canción antes de volver a empezar.' 'La tarde del domingo se disfruta con calma.'
],
['La semana empieza a despedirse.', 'El domingo se va apagando. Deja que suene.'],
['Una última canción antes de volver a empezar.', 'Cierra el domingo con algo que te guste.']
], ],
[ [
'La semana acaba de empezar.', ['La semana acaba de empezar.', 'Ya es lunes, aunque aún sea de noche.'],
'Buenos días. Empezamos otra semana.', ['Buenos días. Empezamos otra semana.', 'Lunes por la mañana. Arranca con buen pie.'],
'Lunes. Dale una banda sonora al día.', ['Lunes. Dale una banda sonora al día.', 'El lunes se lleva mejor con música.'],
'Una pausa musical para este lunes.', ['Una pausa musical para este lunes.', 'Mediodía de lunes. Te has ganado un respiro.'],
'El lunes todavía no ha terminado.', ['El lunes todavía no ha terminado.', 'Media tarde de lunes. Aguanta un poco más.'],
'El lunes ya empieza a quedar atrás.', ['El lunes ya empieza a quedar atrás.', 'Casi lo has conseguido. Un lunes menos.'],
'Sobrevive al lunes con una última canción.' ['Sobrevive al lunes con una última canción.', 'Lunes superado. Ahora, lo que tú quieras.']
], ],
[ [
'La noche sigue sonando.', ['La noche sigue sonando.', 'Martes de madrugada. La música no duerme.'],
'Buenos días. Martes en marcha.', ['Buenos días. Martes en marcha.', 'Martes. Ya estás más despierto que ayer.'],
'La semana ya está cogiendo ritmo.', ['La semana ya está cogiendo ritmo.', 'El martes ya tiene su propio compás.'],
'Martes al mediodía. ¿Qué escuchamos?', ['Martes al mediodía. ¿Qué escuchamos?', 'A mitad del martes. Una canción de por medio.'],
'Una tarde cualquiera necesita música.', ['Una tarde cualquiera necesita música.', 'Tarde de martes. Ponle algo con ritmo.'],
'El día empieza a bajar el ritmo.', ['El día empieza a bajar el ritmo.', 'Cae la tarde. Es buen momento para poner algo.'],
'Un martes más llega a su fin.' ['Un martes más llega a su fin.', 'El martes se acaba. Ciérralo con música.']
], ],
[ [
'Mitad de semana, mitad de noche.', ['Mitad de semana, mitad de noche.', 'Miércoles de madrugada. Justo en el centro.'],
'Buenos días. Ya es miércoles.', ['Buenos días. Ya es miércoles.', 'Miércoles. La semana ya está de tu lado.'],
'Ya estamos en el ecuador de la semana.', ['Ya estamos en el ecuador de la semana.', 'Ecuador de la semana. Lo peor ya pasó.'],
'Miércoles al mediodía. Un poco de música.', ['Miércoles al mediodía. Un poco de música.', 'Un descanso en pleno miércoles.'],
'La semana ya va cuesta abajo.', ['La semana ya va cuesta abajo.', 'Miércoles por la tarde. Todo empieza a rodar.'],
'Unas canciones para terminar el miércoles.', ['Unas canciones para terminar el miércoles.', 'El miércoles se despide. Ponle banda sonora.'],
'Mitad de semana superada.' ['Mitad de semana superada.', 'Ya solo queda bajar la cuesta.']
], ],
[ [
'Una noche más antes del fin de semana.', ['Una noche más antes del fin de semana.', 'Jueves de madrugada. Queda menos.'],
'Buenos días. Ya casi estamos.', ['Buenos días. Ya casi estamos.', 'Jueves. Hoy se huele el viernes.'],
'Jueves. El fin de semana se acerca.', ['Jueves. El fin de semana se acerca.', 'Un jueves con buena música se pasa volando.'],
'Una pausa musical antes de seguir.', ['Una pausa musical antes de seguir.', 'Mediodía de jueves. Toca recargar pilas.'],
'Queda poco para desconectar.', ['Queda poco para desconectar.', 'Tarde de jueves. El fin de semana asoma.'],
'Ya se empieza a notar el viernes.', ['Ya se empieza a notar el viernes.', 'El jueves ya está de salida.'],
'Una noche más y llega el fin de semana.' ['Una noche más y llega el fin de semana.', 'Última noche de la semana laboral. Que suene.']
], ],
[ [
'La semana ha terminado. La noche continúa.', ['La semana ha terminado. La noche continúa.', 'Viernes de madrugada. Aún queda noche.'],
'Buenos días. Es viernes.', ['Buenos días. Es viernes.', 'Viernes. Hoy ya se nota el ambiente.'],
'Viernes. Hoy suena diferente.', ['Viernes. Hoy suena diferente.', 'Es viernes. Sube el volumen.'],
'Ya huele a fin de semana.', ['Ya huele a fin de semana.', 'Mediodía de viernes. Casi libre.'],
'Últimas horas de la semana.', ['Últimas horas de la semana.', 'Tarde de viernes. La semana ya es historia.'],
'Se acabó la semana. Dale al play.', ['Se acabó la semana. Dale al play.', 'Empieza el fin de semana. Que empiece con música.'],
'Viernes por la noche. Tú eliges la banda sonora.' ['Viernes por la noche. Tú eliges la banda sonora.', 'Noche de viernes. Que no falte música.']
], ],
[ [
'La noche es joven. Que siga sonando.', ['La noche es joven. Que siga sonando.', 'Sábado de madrugada. La fiesta no acaba.'],
'Buenos días. Hoy no hay prisa.', ['Buenos días. Hoy no hay prisa.', 'Sábado por la mañana. Sin alarma y con música.'],
'Sábado. Empieza el día a tu ritmo.', ['Sábado. Empieza el día a tu ritmo.', 'Hoy manda el sábado. Tú pones el ritmo.'],
'Mediodía de sábado. ¿Qué ponemos?', ['Mediodía de sábado. ¿Qué ponemos?', 'Sábado al mediodía. Hora de elegir banda sonora.'],
'Una tarde de sábado pide música.', ['Una tarde de sábado pide música.', 'Tarde de sábado. Sin planes también se disfruta.'],
'El sábado todavía tiene mucho por delante.', ['El sábado todavía tiene mucho por delante.', 'Cae el sábado y aún queda mucho por sonar.'],
'Esta noche puede sonar cualquier cosa.' ['Esta noche puede sonar cualquier cosa.', 'Noche de sábado. Todo vale, elige tú.']
] ]
]; ];
@@ -99,5 +102,6 @@ export function pickGreeting(date: Date, birthday?: Birthday | null): string {
const hour = date.getHours(); const hour = date.getHours();
const slot = HOUR_SLOTS.findIndex((until) => hour < until); const slot = HOUR_SLOTS.findIndex((until) => hour < until);
return WEEKLY_SCHEDULE[date.getDay()][slot === -1 ? 0 : slot]; const variant = Math.floor(date.getTime() / 86_400_000) % 2;
return WEEKLY_SCHEDULE[date.getDay()][slot === -1 ? 0 : slot][variant];
} }
+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;
} }
}; };
+21 -1
View File
@@ -1,4 +1,4 @@
import { afterNavigate } from '$app/navigation'; import { afterNavigate, beforeNavigate } from '$app/navigation';
import type { LucideIcon } from '@lucide/svelte'; import type { LucideIcon } from '@lucide/svelte';
import Home from '@lucide/svelte/icons/house'; import Home from '@lucide/svelte/icons/house';
import Compass from '@lucide/svelte/icons/compass'; import Compass from '@lucide/svelte/icons/compass';
@@ -55,6 +55,26 @@ export function trackNavigation() {
}); });
} }
const scrollPositions = new Map<number, number>();
export function restoreScroll(getScroller: () => HTMLElement | undefined) {
let current: number | null = null;
beforeNavigate(() => {
const scroller = getScroller();
if (current !== null && scroller) scrollPositions.set(current, scroller.scrollTop);
});
afterNavigate((nav) => {
current = historyIndex();
const scroller = getScroller();
if (!scroller || nav.type === 'enter' || nav.to?.url.hash) return;
const saved =
nav.type === 'popstate' && current !== null ? scrollPositions.get(current) : undefined;
scroller.scrollTop = saved ?? 0;
});
}
export function previousPage(): PageRef | null { export function previousPage(): PageRef | null {
return visited.get(index - 1) ?? null; return visited.get(index - 1) ?? 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 */
}
} }
} }

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