Author SHA1 Message Date
Admin 35d6314ce8 Point the IDE dev settings at the shared Infrastructure stack
CI/CD / BackEnd (push) Successful in 1m18s
CI/CD / WebPlayer (push) Successful in 41s
CI/CD / Deploy (push) Skipped
appsettings.Development.json still targeted Musify's old own Postgres
(port 59000) with the superuser, the default RabbitMQ vhost as guest and
the admin S3 keys. They now use Infrastructure's dev ports and Musify's
own dev credentials (role, vhost and S3 identity), the same defaults as
deploy/.env.example.

The API's non-secret Zitadel settings (issuer, metadata, endpoints) move
there too, so user-secrets only need the per-machine client id.
2026-09-24 17:11:50 +02:00
Admin f99f74f4a1 Use Musify's own credentials on the shared services
Infrastructure now gives each project its own Postgres role, RabbitMQ
user and vhost, S3 identity limited to its bucket and a Zitadel service
account that owns only its project, instead of handing out the admin
accounts.

- Postgres, RabbitMQ and S3 settings default to Musify's own (musify).
- MassTransit connects to the `musify` vhost (RABBITMQ_VHOST, taken from
  the address path); RABBITMQ_DEFAULT_USER/PASS become RABBITMQ_USER and
  RABBITMQ_PASSWORD.
- zitadel-init mounts ZITADEL_PAT_DIR (Infrastructure's
  zitadel/.output/musify) and uses that PAT. It no longer creates the
  Zitadel project, which that account can't do: Infrastructure does.
2026-09-24 17:05:23 +02:00
Admin f2765e2e7b Call the API by container name from the web player
In prod every project shares infra-net, where the `api` service name
can resolve to another project's container as well.
2026-09-24 15:06:25 +02:00
Admin 7b8f3f1222 Use a reverse-domain redirect scheme for the native app
- Android/iOS now return to cat.ikerdemo.musify:/callback instead of
  musify://callback: a private-use scheme based on a domain we own
  (RFC 8252 section 7.1), so another app is unlikely to claim it.
- The Musify Native app no longer needs devMode: loopback and custom
  scheme redirects are both valid for a native app.
- zitadel-init also updates an existing app's config, so the change
  reaches instances created before it (the client id is kept).
- iOS takes the scheme from everything before the first ':', since the
  new URI has a single slash.
2026-09-24 15:06:25 +02:00
Admin c33b0e8bfd Stop disabling Zitadel Login V2 from zitadel-init
The shared Infrastructure stack now runs the Login V2 container, and
turning the feature off here would switch every project on the shared
instance back to the old login.
2026-09-24 15:06:25 +02:00
167 changed files with 2624 additions and 3573 deletions
@@ -1,86 +0,0 @@
using ErrorOr;
using Mediator;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Musify.Application.Contracts;
using Musify.Application.PlayLists.Responses;
using Musify.Domain.Entities;
namespace Musify.Application.PlayLists
{
public record AddAlbumToPlayListCommand(long UserId, Guid PlayListId, Guid AlbumId)
: ICommand<ErrorOr<AddAlbumToPlayListResponse>>;
public class AddAlbumToPlayListCommandHandler(
IDatabase database,
ILogger<AddAlbumToPlayListCommandHandler> logger)
: ICommandHandler<AddAlbumToPlayListCommand, ErrorOr<AddAlbumToPlayListResponse>>
{
public async ValueTask<ErrorOr<AddAlbumToPlayListResponse>> Handle(AddAlbumToPlayListCommand request, CancellationToken cancellationToken)
{
var playList = await database.PlayLists
.SingleOrDefaultAsync(pl => pl.Id == request.PlayListId, cancellationToken);
if (playList == null)
{
logger.LogInformation("Playlist {PlayListId} not found", request.PlayListId);
return Error.NotFound();
}
if (playList.UserId != request.UserId)
{
logger.LogWarning("User {UserId} is not the owner of playlist {PlayListId}", request.UserId, request.PlayListId);
return Error.Unauthorized();
}
var albumExists = await database.Albums
.AsNoTracking()
.AnyAsync(album => album.Id == request.AlbumId, cancellationToken);
if (!albumExists)
{
logger.LogInformation("Album {AlbumId} not found", request.AlbumId);
return Error.NotFound(description: $"Album {request.AlbumId} not found");
}
var albumTrackIds = await database.AlbumHasTracks
.AsNoTracking()
.Where(albumTrack => albumTrack.AlbumId == request.AlbumId)
.OrderBy(albumTrack => albumTrack.TrackNumber)
.Select(albumTrack => albumTrack.TrackId)
.ToListAsync(cancellationToken);
var playListTracksQuery = database.PlayListHasTracks
.Where(plt => plt.PlayListId == request.PlayListId);
var existingTrackIds = (await playListTracksQuery
.Select(plt => plt.TrackId)
.ToListAsync(cancellationToken)).ToHashSet();
var newTrackIds = albumTrackIds
.Where(trackId => !existingTrackIds.Contains(trackId))
.ToList();
if (newTrackIds.Count == 0)
return new AddAlbumToPlayListResponse(0);
var nextPosition = existingTrackIds.Count > 0
? await playListTracksQuery.MaxAsync(plt => plt.Position, cancellationToken) + 1
: 0;
await database.PlayListHasTracks.AddRangeAsync(
newTrackIds.Select((trackId, index) => new PlayListHasTrack
{
PlayListId = request.PlayListId,
TrackId = trackId,
Position = nextPosition + index
}),
cancellationToken);
await database.SaveChangesAsync(cancellationToken);
logger.LogInformation("Added {Count} tracks from album {AlbumId} to playlist {PlayListId}",
newTrackIds.Count, request.AlbumId, request.PlayListId);
return new AddAlbumToPlayListResponse(newTrackIds.Count);
}
}
}
@@ -21,8 +21,6 @@ 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)
@@ -38,7 +36,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.TrackCount, entry.DurationSeconds, entry.CoverTrackIds); return PlayListApplicationResponse.FromEntity(entry.PlayList, entry.CoverTrackIds);
} }
} }
} }
@@ -25,8 +25,6 @@ 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 +34,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.TrackCount, entry.DurationSeconds, entry.CoverTrackIds)), pagedEntities.Select(entry => PlayListApplicationResponse.FromEntity(entry.PlayList, 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,8 +46,6 @@ 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)
@@ -57,7 +55,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.TrackCount, entry.DurationSeconds, entry.CoverTrackIds)), pagedEntities.Select(entry => PlayListApplicationResponse.FromEntity(entry.PlayList, 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,4 +0,0 @@
namespace Musify.Application.PlayLists.Responses
{
public record AddAlbumToPlayListResponse(int AddedCount);
}
@@ -1,5 +1,3 @@
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;
@@ -12,10 +10,7 @@ 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)
@@ -24,8 +19,6 @@ 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(
@@ -35,10 +28,7 @@ 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,22 +112,7 @@ namespace Musify.Application.PlayLists
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
logger.LogInformation("Updated playlist {PlayListId}", playListEntity.Id); logger.LogInformation("Updated playlist {PlayListId}", playListEntity.Id);
var totals = await database.PlayListHasTracks return PlayListApplicationResponse.FromEntity(playListEntity, coverTrackIds);
.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(
@@ -94,14 +94,6 @@ public static class PlayListEndpoints
.Produces(StatusCodes.Status404NotFound) .Produces(StatusCodes.Status404NotFound)
.Produces(StatusCodes.Status409Conflict); .Produces(StatusCodes.Status409Conflict);
group.MapPost("/{playlistId}/albums/{albumId}", AddAlbumToPlayList)
.WithName("AddAlbumToPlayList")
.WithSummary("Add All The Tracks Of An Album To One Of Your PlayLists, Skipping Those Already In It.")
.RequireAuthorization()
.Produces<AddAlbumToPlayListResponse>()
.Produces(StatusCodes.Status401Unauthorized)
.Produces(StatusCodes.Status404NotFound);
group.MapDelete("/{playlistId}/tracks/{trackId}", RemoveTrackFromPlayList) group.MapDelete("/{playlistId}/tracks/{trackId}", RemoveTrackFromPlayList)
.WithName("RemoveTrackFromPlayList") .WithName("RemoveTrackFromPlayList")
.WithSummary("Remove A Track From A PlayList.") .WithSummary("Remove A Track From A PlayList.")
@@ -242,20 +234,6 @@ public static class PlayListEndpoints
return trackResult.ToCreatedResult(track => $"/tracks/{track.Id}"); return trackResult.ToCreatedResult(track => $"/tracks/{track.Id}");
} }
private static async Task<IResult> AddAlbumToPlayList(
IMediator mediator,
CurrentUser currentUser,
Guid playlistId,
Guid albumId,
CancellationToken cancellationToken)
{
var result = await mediator.Send(
new AddAlbumToPlayListCommand(currentUser.RequiredId, playlistId, albumId),
cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> RemoveTrackFromPlayList( private static async Task<IResult> RemoveTrackFromPlayList(
IMediator mediator, IMediator mediator,
CurrentUser currentUser, CurrentUser currentUser,
@@ -6,21 +6,26 @@
} }
}, },
"Authentication": { "Authentication": {
"MetadataAddress": "http://host.docker.internal:8080/.well-known/openid-configuration",
"IssuerAddress": "http://host.docker.internal:8080",
"AuthorizationEndpoint": "http://host.docker.internal:8080/oauth/v2/authorize?prompt=login",
"TokenEndpoint": "http://host.docker.internal:8080/oauth/v2/token",
"ScalarRedirectUri": "http://localhost:5111/scalar/",
"Scopes": [ "openid", "profile", "email" ], "Scopes": [ "openid", "profile", "email" ],
"RequireHttpsMetadata": false "RequireHttpsMetadata": false
}, },
"Database": { "Database": {
"ConnectionString": "Host=localhost;Port=59000;Database=musify_db;Username=postgres;Password=postgres" "ConnectionString": "Host=localhost;Port=5432;Database=musify_db;Username=musify;Password=musify"
}, },
"MassTransit": { "MassTransit": {
"Address": "rabbitmq://localhost:5672", "Address": "rabbitmq://localhost:5672/musify",
"Username": "guest", "Username": "musify",
"Password": "guest" "Password": "musify"
}, },
"InfrastructureStorage": { "InfrastructureStorage": {
"Address": "http://localhost:8333", "Address": "http://localhost:8333",
"AccessKey": "admin_access_key", "AccessKey": "musify_access_key",
"SecretAccessKey": "admin_secret_key", "SecretAccessKey": "musify_secret_key",
"ForcePathStyle": true, "ForcePathStyle": true,
"UseHttp": true "UseHttp": true
}, },
@@ -6,20 +6,20 @@
} }
}, },
"Database": { "Database": {
"ConnectionString": "Host=localhost;Port=59000;Database=musify_db;Username=postgres;Password=postgres" "ConnectionString": "Host=localhost;Port=5432;Database=musify_db;Username=musify;Password=musify"
}, },
"MassTransit": { "MassTransit": {
"Address": "rabbitmq://localhost:5672", "Address": "rabbitmq://localhost:5672/musify",
"Username": "guest", "Username": "musify",
"Password": "guest" "Password": "musify"
}, },
"OpenTelemetry": { "OpenTelemetry": {
"OtlpEndpoint": "http://localhost:4317" "OtlpEndpoint": "http://localhost:4317"
}, },
"InfrastructureStorage": { "InfrastructureStorage": {
"Address": "http://localhost:8333", "Address": "http://localhost:8333",
"AccessKey": "admin_access_key", "AccessKey": "musify_access_key",
"SecretAccessKey": "admin_secret_key", "SecretAccessKey": "musify_secret_key",
"ForcePathStyle": true, "ForcePathStyle": true,
"UseHttp": true "UseHttp": true
}, },
@@ -1,91 +0,0 @@
using ErrorOr;
using Microsoft.EntityFrameworkCore;
using Musify.Application.PlayLists;
using Musify.Application.Tests.TestSupport;
using Musify.Domain.Entities;
using Xunit;
namespace Musify.Application.Tests.PlayLists
{
public sealed class AddAlbumToPlayListCommandHandlerTests : HandlerTestBase
{
private AddAlbumToPlayListCommandHandler CreateHandler() => new(Database, NoOpLogger<AddAlbumToPlayListCommandHandler>());
[Fact]
public async Task Handle_AddsAlbumTracksInAlbumOrder()
{
var owner = TestEntities.User();
var playList = TestEntities.PlayList(owner.Id);
var album = TestEntities.Album(owner.Id);
var first = TestEntities.Track(owner, "First");
var second = TestEntities.Track(owner, "Second");
await SeedAsync(
owner, playList, album, first, second,
new AlbumHasTrack { AlbumId = album.Id, TrackId = second.Id, TrackNumber = 2 },
new AlbumHasTrack { AlbumId = album.Id, TrackId = first.Id, TrackNumber = 1 });
var result = await CreateHandler().Handle(new AddAlbumToPlayListCommand(owner.Id, playList.Id, album.Id), TestContext.Current.CancellationToken);
Assert.False(result.IsError);
Assert.Equal(2, result.Value.AddedCount);
var links = await Database.PlayListHasTracks.OrderBy(link => link.Position).ToListAsync(TestContext.Current.CancellationToken);
Assert.Equal([first.Id, second.Id], links.Select(link => link.TrackId));
Assert.Equal([0, 1], links.Select(link => link.Position));
}
[Fact]
public async Task Handle_SkipsTracksAlreadyInPlayList_AndAppendsAfterThem()
{
var owner = TestEntities.User();
var playList = TestEntities.PlayList(owner.Id);
var album = TestEntities.Album(owner.Id);
var existing = TestEntities.Track(owner, "Existing");
var fresh = TestEntities.Track(owner, "Fresh");
await SeedAsync(
owner, playList, album, existing, fresh,
new AlbumHasTrack { AlbumId = album.Id, TrackId = existing.Id, TrackNumber = 1 },
new AlbumHasTrack { AlbumId = album.Id, TrackId = fresh.Id, TrackNumber = 2 },
new PlayListHasTrack { PlayListId = playList.Id, TrackId = existing.Id, Position = 4 });
var result = await CreateHandler().Handle(new AddAlbumToPlayListCommand(owner.Id, playList.Id, album.Id), TestContext.Current.CancellationToken);
Assert.Equal(1, result.Value.AddedCount);
var link = await Database.PlayListHasTracks.SingleAsync(l => l.TrackId == fresh.Id, TestContext.Current.CancellationToken);
Assert.Equal(5, link.Position);
}
[Fact]
public async Task Handle_PlayListMissing_ReturnsNotFound()
{
var result = await CreateHandler().Handle(new AddAlbumToPlayListCommand(1, Guid.NewGuid(), Guid.NewGuid()), TestContext.Current.CancellationToken);
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
}
[Fact]
public async Task Handle_NotOwner_ReturnsUnauthorized()
{
var owner = TestEntities.User(1, "owner");
var stranger = TestEntities.User(2, "stranger");
var playList = TestEntities.PlayList(owner.Id);
var album = TestEntities.Album(owner.Id);
await SeedAsync(owner, stranger, playList, album);
var result = await CreateHandler().Handle(new AddAlbumToPlayListCommand(stranger.Id, playList.Id, album.Id), TestContext.Current.CancellationToken);
Assert.Equal(ErrorType.Unauthorized, result.FirstError.Type);
}
[Fact]
public async Task Handle_AlbumMissing_ReturnsNotFound()
{
var owner = TestEntities.User();
var playList = TestEntities.PlayList(owner.Id);
await SeedAsync(owner, playList);
var result = await CreateHandler().Handle(new AddAlbumToPlayListCommand(owner.Id, playList.Id, Guid.NewGuid()), TestContext.Current.CancellationToken);
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
}
}
}
+21 -15
View File
@@ -5,8 +5,10 @@
# #
# Requires ../../../Infrastructure running first (Postgres, Zitadel, # Requires ../../../Infrastructure running first (Postgres, Zitadel,
# SeaweedFS, RabbitMQ, Jaeger). See ../../../Infrastructure/README.md. # SeaweedFS, RabbitMQ, Jaeger). See ../../../Infrastructure/README.md.
# Everything marked "must match Infrastructure's .env" below has to be the # Musify has its own credentials on every shared service (role, vhost, S3
# same value as the one in that file. # identity, Zitadel service account), provisioned by Infrastructure. Every
# value marked "= Infrastructure's MUSIFY_*" below has to be the same as that
# setting in Infrastructure's .env.
DOTNET_ENVIRONMENT=Development DOTNET_ENVIRONMENT=Development
@@ -21,17 +23,18 @@ PUBLIC_STREAM_URL=http://localhost:8081
PUBLIC_AUTH_URL=http://host.docker.internal:8080 PUBLIC_AUTH_URL=http://host.docker.internal:8080
# --- PostgreSQL (Infrastructure's shared instance, Musify's own database) --- # --- PostgreSQL (Infrastructure's shared instance, Musify's own database) ---
# POSTGRES_USER / POSTGRES_PASSWORD must match Infrastructure's .env. # Musify's own role, owner of musify_db only.
POSTGRES_HOST=host.docker.internal POSTGRES_HOST=host.docker.internal
POSTGRES_USER=postgres POSTGRES_USER=musify
POSTGRES_PASSWORD=postgres # = Infrastructure's MUSIFY_DB_PASSWORD
POSTGRES_PASSWORD=musify
POSTGRES_DB=musify_db POSTGRES_DB=musify_db
# --- Zitadel (OIDC, shared instance) ----------------------------------------- # --- Zitadel (OIDC, shared instance) -----------------------------------------
# Path to the admin service-account PAT Infrastructure's Zitadel writes on # Folder with Musify's own Zitadel PAT (a service account that owns the
# init. Adjust if your Infrastructure checkout lives somewhere else relative # Musify project only), written by Infrastructure's `provision`. Adjust if
# to this one. # your Infrastructure checkout lives somewhere else relative to this one.
ZITADEL_ADMIN_PAT_DIR=../../../Infrastructure/zitadel/.output ZITADEL_PAT_DIR=../../../Infrastructure/zitadel/.output/musify
# Allows the OIDC apps this project creates to use plain-http redirect URIs # Allows the OIDC apps this project creates to use plain-http redirect URIs
# (dev only). # (dev only).
ZITADEL_DEV_MODE=true ZITADEL_DEV_MODE=true
@@ -43,16 +46,19 @@ WEB_CLIENT_ID=
WEB_CLIENT_SECRET= WEB_CLIENT_SECRET=
# --- RabbitMQ (shared instance, MassTransit) -------------------------------- # --- RabbitMQ (shared instance, MassTransit) --------------------------------
# Must match Infrastructure's .env. # Musify's own user, only on its own vhost.
RABBITMQ_HOST=host.docker.internal RABBITMQ_HOST=host.docker.internal
RABBITMQ_DEFAULT_USER=guest RABBITMQ_VHOST=musify
RABBITMQ_DEFAULT_PASS=guest RABBITMQ_USER=musify
# = Infrastructure's MUSIFY_RABBITMQ_PASSWORD
RABBITMQ_PASSWORD=musify
# --- SeaweedFS (S3, shared instance, Musify's own bucket) -------------------- # --- SeaweedFS (S3, shared instance, Musify's own bucket) --------------------
# S3_ACCESS_KEY / S3_SECRET_KEY must match Infrastructure's .env. # Musify's own identity, limited to S3_BUCKET.
SEAWEEDFS_HOST=host.docker.internal SEAWEEDFS_HOST=host.docker.internal
S3_ACCESS_KEY=admin_access_key # = Infrastructure's MUSIFY_S3_ACCESS_KEY / MUSIFY_S3_SECRET_KEY
S3_SECRET_KEY=admin_secret_key S3_ACCESS_KEY=musify_access_key
S3_SECRET_KEY=musify_secret_key
S3_BUCKET=webapi-storage S3_BUCKET=webapi-storage
S3_PUBLIC_URL=http://host.docker.internal:8333 S3_PUBLIC_URL=http://host.docker.internal:8333
S3_INTERNAL_URL=http://host.docker.internal:8333 S3_INTERNAL_URL=http://host.docker.internal:8333
+10 -5
View File
@@ -28,14 +28,16 @@ PUBLIC_AUTH_URL=https://auth.example.com
S3_PUBLIC_URL=https://s3.example.com S3_PUBLIC_URL=https://s3.example.com
# --- PostgreSQL (Infrastructure's shared instance, Musify's own database) --- # --- PostgreSQL (Infrastructure's shared instance, Musify's own database) ---
# POSTGRES_USER / POSTGRES_PASSWORD must match Infrastructure's .env.prod. # Musify's own role, owner of musify_db only.
POSTGRES_HOST=postgres POSTGRES_HOST=postgres
POSTGRES_USER=postgres POSTGRES_USER=musify
# = Infrastructure's MUSIFY_DB_PASSWORD
POSTGRES_PASSWORD=CHANGE_ME POSTGRES_PASSWORD=CHANGE_ME
POSTGRES_DB=musify_db POSTGRES_DB=musify_db
# --- Zitadel (OIDC, shared instance) ----------------------------------------- # --- Zitadel (OIDC, shared instance) -----------------------------------------
ZITADEL_ADMIN_PAT_DIR=../../../Infrastructure/zitadel/.output # Folder with Musify's own Zitadel PAT, written by Infrastructure's `provision`.
ZITADEL_PAT_DIR=../../../Infrastructure/zitadel/.output/musify
ZITADEL_DEV_MODE=false ZITADEL_DEV_MODE=false
# OIDC clients: the zitadel-init one-shot service (see compose.yml) fills # OIDC clients: the zitadel-init one-shot service (see compose.yml) fills
@@ -46,11 +48,14 @@ WEB_CLIENT_SECRET=
# --- RabbitMQ (shared instance, MassTransit) -------------------------------- # --- RabbitMQ (shared instance, MassTransit) --------------------------------
RABBITMQ_HOST=rabbitmq RABBITMQ_HOST=rabbitmq
RABBITMQ_DEFAULT_USER=musify RABBITMQ_VHOST=musify
RABBITMQ_DEFAULT_PASS=CHANGE_ME RABBITMQ_USER=musify
# = Infrastructure's MUSIFY_RABBITMQ_PASSWORD
RABBITMQ_PASSWORD=CHANGE_ME
# --- SeaweedFS (S3, shared instance, Musify's own bucket) -------------------- # --- SeaweedFS (S3, shared instance, Musify's own bucket) --------------------
SEAWEEDFS_HOST=seaweedfs SEAWEEDFS_HOST=seaweedfs
# = Infrastructure's MUSIFY_S3_ACCESS_KEY / MUSIFY_S3_SECRET_KEY
S3_ACCESS_KEY=CHANGE_ME S3_ACCESS_KEY=CHANGE_ME
S3_SECRET_KEY=CHANGE_ME S3_SECRET_KEY=CHANGE_ME
S3_BUCKET=webapi-storage S3_BUCKET=webapi-storage
+11 -7
View File
@@ -68,13 +68,17 @@ dotnet run --project backend/Hosts/Musify.Worker # background
npm --prefix web-player run dev # :5173 npm --prefix web-player run dev # :5173
``` ```
When running the apps from the IDE, `Musify.Api` reads `Authentication:*` When running the apps from the IDE, neither the .NET hosts nor `web-player`
from user-secrets and `web-player` reads its own `web-player/.env`, neither look at `deploy/.env`. The hosts' `appsettings.Development.json` already
looks at `deploy/.env`. Copy the four values `zitadel-init` wrote point at Infrastructure's dev stack with Musify's own dev credentials (the
(`AUTH_CLIENT_ID`, `WEB_CLIENT_ID`, `WEB_CLIENT_SECRET`, `NATIVE_CLIENT_ID`) defaults of its `MUSIFY_*` settings) and at the shared Zitadel. Only the
into `dotnet user-secrets set Authentication:ClientId <value> --project OIDC client ids are per-machine: copy the values `zitadel-init` wrote once,
backend/Hosts/Musify.Api` and into `web-player/.env` once. They stay valid `AUTH_CLIENT_ID` into both `Authentication:ClientId` and
until Infrastructure's Zitadel volume is wiped. `Authentication:AudienceAddress` (`dotnet user-secrets set <key> <value>
--project backend/Hosts/Musify.Api`), `WEB_CLIENT_ID` and
`WEB_CLIENT_SECRET` into `web-player/.env`, and `NATIVE_CLIENT_ID` into
`mobile/gradle.properties`. They stay valid until Infrastructure's Zitadel
volume is wiped.
### Ports ### Ports
+42 -33
View File
@@ -26,9 +26,10 @@
# already do. # already do.
x-backend-env: &backend-env x-backend-env: &backend-env
Database__ConnectionString: "Host=${POSTGRES_HOST};Port=5432;Database=${POSTGRES_DB};Username=${POSTGRES_USER};Password=${POSTGRES_PASSWORD}" Database__ConnectionString: "Host=${POSTGRES_HOST};Port=5432;Database=${POSTGRES_DB};Username=${POSTGRES_USER};Password=${POSTGRES_PASSWORD}"
MassTransit__Address: "rabbitmq://${RABBITMQ_HOST}:5672" # Musify's own vhost and user on the shared broker (the path is the vhost).
MassTransit__Username: ${RABBITMQ_DEFAULT_USER} MassTransit__Address: "rabbitmq://${RABBITMQ_HOST}:5672/${RABBITMQ_VHOST}"
MassTransit__Password: ${RABBITMQ_DEFAULT_PASS} MassTransit__Username: ${RABBITMQ_USER}
MassTransit__Password: ${RABBITMQ_PASSWORD}
# The public S3 URL, not the internal one: the same address is used to sign # The public S3 URL, not the internal one: the same address is used to sign
# the presigned upload/download URLs the browser has to be able to reach. # the presigned upload/download URLs the browser has to be able to reach.
InfrastructureStorage__Address: ${S3_PUBLIC_URL} InfrastructureStorage__Address: ${S3_PUBLIC_URL}
@@ -135,7 +136,7 @@ services:
PUBLIC_WEB_URL: ${PUBLIC_WEB_URL} PUBLIC_WEB_URL: ${PUBLIC_WEB_URL}
ZITADEL_DEV_MODE: ${ZITADEL_DEV_MODE:-false} ZITADEL_DEV_MODE: ${ZITADEL_DEV_MODE:-false}
volumes: volumes:
- ${ZITADEL_ADMIN_PAT_DIR}:/output:ro - ${ZITADEL_PAT_DIR}:/output:ro
entrypoint: ["/bin/sh", "-c"] entrypoint: ["/bin/sh", "-c"]
command: command:
- | - |
@@ -163,13 +164,15 @@ services:
fi fi
} }
echo "==> waiting for the Zitadel service-account PAT" # Musify's own service account, owner of the Musify project only
# (created by Infrastructure's `provision`, see its README).
echo "==> waiting for Musify's Zitadel PAT"
deadline=$$(( $$(date +%s) + 180 )) deadline=$$(( $$(date +%s) + 180 ))
while [ ! -f /output/admin-sa.pat ]; do while [ ! -s /output/zitadel.pat ]; do
[ "$$(date +%s)" -lt "$$deadline" ] || { echo "PAT not found under /output. Is Infrastructure's Zitadel running? Check 'docker logs infra-zitadel'." >&2; exit 1; } [ "$$(date +%s)" -lt "$$deadline" ] || { echo "no zitadel.pat in ZITADEL_PAT_DIR. Is Infrastructure up, and does ZITADEL_PAT_DIR point at its zitadel/.output/musify? Check 'docker logs infra-provision'." >&2; exit 1; }
sleep 3 sleep 3
done done
PAT=$$(tr -d '\r\n' </output/admin-sa.pat) PAT=$$(tr -d '\r\n' </output/zitadel.pat)
MGMT="$$PUBLIC_AUTH_URL/management/v1" MGMT="$$PUBLIC_AUTH_URL/management/v1"
echo "==> waiting for Zitadel to answer at $$PUBLIC_AUTH_URL" echo "==> waiting for Zitadel to answer at $$PUBLIC_AUTH_URL"
@@ -188,21 +191,13 @@ services:
} }
name_query() { printf '{"queries":[{"nameQuery":{"name":"%s","method":"TEXT_QUERY_METHOD_EQUALS"}}]}' "$$1"; } name_query() { printf '{"queries":[{"nameQuery":{"name":"%s","method":"TEXT_QUERY_METHOD_EQUALS"}}]}' "$$1"; }
# `latest` images require the standalone Login UI v2 container, which
# this stack does not run; opting out keeps the built-in /ui/login.
curl -fsS -X PUT "$$PUBLIC_AUTH_URL/v2/features/instance" \
-H "Authorization: Bearer $$PAT" -H "Content-Type: application/json" \
-d '{"loginV2":{"required":false}}' >/dev/null 2>&1 || echo "warning: could not disable Login UI v2"
echo "==> project" echo "==> project"
PROJECT_NAME=Musify PROJECT_NAME=Musify
# Created by Infrastructure's `provision`: this account can't create
# projects, only manage this one.
PROJECT_ID=$$(api_call POST /projects/_search "$$(name_query "$$PROJECT_NAME")" | jq -r --arg n "$$PROJECT_NAME" '.result // [] | map(select(.name==$$n)) | first | .id // empty') PROJECT_ID=$$(api_call POST /projects/_search "$$(name_query "$$PROJECT_NAME")" | jq -r --arg n "$$PROJECT_NAME" '.result // [] | map(select(.name==$$n)) | first | .id // empty')
if [ -z "$$PROJECT_ID" ]; then [ -n "$$PROJECT_ID" ] || { echo "Zitadel project $$PROJECT_NAME not found. Is musify in Infrastructure's x-projects?" >&2; exit 1; }
PROJECT_ID=$$(api_call POST /projects "$$(printf '{"name":"%s"}' "$$PROJECT_NAME")" | jq -r '.id') echo " found (id $$PROJECT_ID)"
echo " created (id $$PROJECT_ID)"
else
echo " reused (id $$PROJECT_ID)"
fi
find_app() { api_call POST "/projects/$$PROJECT_ID/apps/_search" "$$(name_query "$$1")" | jq -r --arg n "$$1" '.result // [] | map(select(.name==$$n)) | first | .id // empty'; } find_app() { api_call POST "/projects/$$PROJECT_ID/apps/_search" "$$(name_query "$$1")" | jq -r --arg n "$$1" '.result // [] | map(select(.name==$$n)) | first | .id // empty'; }
@@ -265,27 +260,38 @@ services:
fi fi
echo "==> native app (KMP client: desktop loopback + mobile custom scheme)" echo "==> native app (KMP client: desktop loopback + mobile custom scheme)"
# Loopback (desktop) and a reverse-domain custom scheme (Android/iOS,
# RFC 8252 §7.1) are both valid for a NATIVE app without devMode, so
# dev and prod share the same config. Unlike the other apps, an
# existing one is also updated, so a change here reaches instances
# created before it.
NATIVE_APP_NAME="Musify Native" NATIVE_APP_NAME="Musify Native"
NATIVE_APP_ID=$$(find_app "$$NATIVE_APP_NAME") NATIVE_CONFIG='{
if [ -n "$$NATIVE_APP_ID" ]; then "redirectUris": ["http://127.0.0.1:17453/callback", "cat.ikerdemo.musify:/callback"],
NATIVE_CLIENT_ID=$$(api_call GET "/projects/$$PROJECT_ID/apps/$$NATIVE_APP_ID" | jq -r '.app.oidcConfig.clientId') "postLogoutRedirectUris": ["http://127.0.0.1:17453/logout", "cat.ikerdemo.musify:/logout"],
echo " reused (clientId $$NATIVE_CLIENT_ID)"
else
NATIVE_CLIENT_ID=$$(api_call POST "/projects/$$PROJECT_ID/apps/oidc" '{
"name": "Musify Native",
"redirectUris": ["http://127.0.0.1:17453/callback", "musify://callback"],
"postLogoutRedirectUris": ["http://127.0.0.1:17453/logout", "musify://logout"],
"responseTypes": ["OIDC_RESPONSE_TYPE_CODE"], "responseTypes": ["OIDC_RESPONSE_TYPE_CODE"],
"grantTypes": ["OIDC_GRANT_TYPE_AUTHORIZATION_CODE", "OIDC_GRANT_TYPE_REFRESH_TOKEN"], "grantTypes": ["OIDC_GRANT_TYPE_AUTHORIZATION_CODE", "OIDC_GRANT_TYPE_REFRESH_TOKEN"],
"appType": "OIDC_APP_TYPE_NATIVE", "appType": "OIDC_APP_TYPE_NATIVE",
"authMethodType": "OIDC_AUTH_METHOD_TYPE_NONE", "authMethodType": "OIDC_AUTH_METHOD_TYPE_NONE",
"version": "OIDC_VERSION_1_0", "devMode": false,
"devMode": true,
"accessTokenType": "OIDC_TOKEN_TYPE_JWT", "accessTokenType": "OIDC_TOKEN_TYPE_JWT",
"accessTokenRoleAssertion": true, "accessTokenRoleAssertion": true,
"idTokenRoleAssertion": true, "idTokenRoleAssertion": true,
"idTokenUserinfoAssertion": true "idTokenUserinfoAssertion": true
}' | jq -r '.clientId') }'
NATIVE_APP_ID=$$(find_app "$$NATIVE_APP_NAME")
if [ -n "$$NATIVE_APP_ID" ]; then
NATIVE_CLIENT_ID=$$(api_call GET "/projects/$$PROJECT_ID/apps/$$NATIVE_APP_ID" | jq -r '.app.oidcConfig.clientId')
# Zitadel answers 400 "No changes" when the config already matches.
RESULT=$$(curl -sS -X PUT "$$MGMT/projects/$$PROJECT_ID/apps/$$NATIVE_APP_ID/oidc_config" \
-H "Authorization: Bearer $$PAT" -H "Content-Type: application/json" -d "$$NATIVE_CONFIG")
case "$$RESULT" in
*'"details"'*'"changeDate"'*) echo " reused (clientId $$NATIVE_CLIENT_ID, config updated)" ;;
*COMMAND-1m88i*) echo " reused (clientId $$NATIVE_CLIENT_ID)" ;;
*) echo "could not update the native app config: $$RESULT" >&2; exit 1 ;;
esac
else
NATIVE_CLIENT_ID=$$(api_call POST "/projects/$$PROJECT_ID/apps/oidc" "$$(echo "$$NATIVE_CONFIG" | jq --arg name "$$NATIVE_APP_NAME" '. + {name: $$name, version: "OIDC_VERSION_1_0"}')" | jq -r '.clientId')
echo " created (clientId $$NATIVE_CLIENT_ID)" echo " created (clientId $$NATIVE_CLIENT_ID)"
fi fi
@@ -393,7 +399,10 @@ services:
AUTH_POST_LOGOUT_URI: "${PUBLIC_WEB_URL}/" AUTH_POST_LOGOUT_URI: "${PUBLIC_WEB_URL}/"
AUTH_ALLOW_INSECURE_HTTP: ${AUTH_ALLOW_INSECURE_HTTP} AUTH_ALLOW_INSECURE_HTTP: ${AUTH_ALLOW_INSECURE_HTTP}
SESSION_SECRET: ${SESSION_SECRET} SESSION_SECRET: ${SESSION_SECRET}
API_BASE_URL: http://api:5111 # The container name, not the `api` service name: in prod every project
# shares infra-net, where a generic name can resolve to another
# project's container.
API_BASE_URL: http://musify-api:5111
# Presigned PUTs are signed for the public S3 host; the container sends # Presigned PUTs are signed for the public S3 host; the container sends
# them straight to SeaweedFS instead of going back out through the edge. # them straight to SeaweedFS instead of going back out through the edge.
S3_UPLOAD_ENDPOINT: ${S3_INTERNAL_URL} S3_UPLOAD_ENDPOINT: ${S3_INTERNAL_URL}
+3 -3
View File
@@ -54,7 +54,7 @@ al patrón que ya usa Scalar para probar la API — ver `deploy/compose.yml` (`z
Redirect URIs registrados: Redirect URIs registrados:
- Desktop: `http://127.0.0.1:17453/callback` (RFC 8252, loopback con puerto fijo). - Desktop: `http://127.0.0.1:17453/callback` (RFC 8252, loopback con puerto fijo).
- Android / iOS: `musify://callback` (custom scheme). - Android / iOS: `cat.ikerdemo.musify:/callback` (custom scheme con dominio invertido, RFC 8252 §7.1).
`mobile/gradle.properties` controla a qué backend apunta Android/Desktop: `mobile/gradle.properties` controla a qué backend apunta Android/Desktop:
@@ -125,13 +125,13 @@ Kotlin/Native (`iosMain/`) sigue las APIs estándar de interop (`AVFoundation`,
1. Genera un proyecto Xcode con el asistente "Kotlin Multiplatform" de Android Studio, o crea uno vacío 1. Genera un proyecto Xcode con el asistente "Kotlin Multiplatform" de Android Studio, o crea uno vacío
e importa `composeApp` como framework (`./gradlew :composeApp:embedAndSignAppleFrameworkForXcode`). e importa `composeApp` como framework (`./gradlew :composeApp:embedAndSignAppleFrameworkForXcode`).
2. Registra el scheme `musify` en `Info.plist`: 2. Registra el scheme `cat.ikerdemo.musify` en `Info.plist`:
```xml ```xml
<key>CFBundleURLTypes</key> <key>CFBundleURLTypes</key>
<array> <array>
<dict> <dict>
<key>CFBundleURLSchemes</key> <key>CFBundleURLSchemes</key>
<array><string>musify</string></array> <array><string>cat.ikerdemo.musify</string></array>
</dict> </dict>
</array> </array>
``` ```
@@ -18,7 +18,9 @@
</intent-filter> </intent-filter>
</activity> </activity>
<!-- Catches the `musify://callback` redirect Zitadel sends after login/register. <!-- Catches the `cat.ikerdemo.musify:/callback` redirect Zitadel sends after
login/register. The URI has no host, so the filter matches on the scheme
alone.
See BrowserAuthLauncher.android.kt and AppConfig.nativeRedirectUri. --> See BrowserAuthLauncher.android.kt and AppConfig.nativeRedirectUri. -->
<activity <activity
android:name=".auth.AuthCallbackActivity" android:name=".auth.AuthCallbackActivity"
@@ -29,7 +31,7 @@
<action android:name="android.intent.action.VIEW" /> <action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" /> <category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" /> <category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="musify" android:host="callback" /> <data android:scheme="cat.ikerdemo.musify" />
</intent-filter> </intent-filter>
</activity> </activity>
</application> </application>
@@ -9,5 +9,5 @@ actual object AppConfig {
// Matches the AuthCallbackActivity intent-filter in AndroidManifest.xml and // Matches the AuthCallbackActivity intent-filter in AndroidManifest.xml and
// the redirect URI registered for the "Musify Native" app in Zitadel. // the redirect URI registered for the "Musify Native" app in Zitadel.
actual val nativeRedirectUri: String = "musify://callback" actual val nativeRedirectUri: String = "cat.ikerdemo.musify:/callback"
} }
@@ -5,7 +5,7 @@ import android.content.Intent
import android.os.Bundle import android.os.Bundle
/** /**
* Declared in AndroidManifest.xml with an intent-filter for `musify://callback`. * Declared in AndroidManifest.xml with an intent-filter for `cat.ikerdemo.musify:/callback`.
* Chrome Custom Tabs redirects here once Zitadel finishes the login/register * Chrome Custom Tabs redirects here once Zitadel finishes the login/register
* flow; this activity just forwards the URL to [AndroidAuthBridge] and closes. * flow; this activity just forwards the URL to [AndroidAuthBridge] and closes.
*/ */
@@ -9,7 +9,8 @@ package com.musify.app
* `nativeRedirectUri` is the OAuth redirect registered for the "Musify * `nativeRedirectUri` is the OAuth redirect registered for the "Musify
* Native" Zitadel app (see deploy/compose.yml, zitadel-init): a fixed * Native" Zitadel app (see deploy/compose.yml, zitadel-init): a fixed
* loopback port on Desktop (RFC 8252 native-app loopback flow), and the * loopback port on Desktop (RFC 8252 native-app loopback flow), and the
* `musify://callback` custom scheme on Android/iOS. * `cat.ikerdemo.musify:/callback` reverse-domain custom scheme on Android/iOS
* (RFC 8252 §7.1: based on a domain we own, so no other app plausibly claims it).
*/ */
expect object AppConfig { expect object AppConfig {
val apiBaseUrl: String val apiBaseUrl: String
@@ -12,7 +12,7 @@ package com.musify.app.auth
* - Desktop: a one-shot loopback HTTP server on the fixed port registered in * - Desktop: a one-shot loopback HTTP server on the fixed port registered in
* Zitadel (RFC 8252 §7.3), opened via the OS default browser. * Zitadel (RFC 8252 §7.3), opened via the OS default browser.
* - Android: Chrome Custom Tabs; the redirect is caught by * - Android: Chrome Custom Tabs; the redirect is caught by
* [AuthCallbackActivity] via the `musify://callback` intent filter. * [AuthCallbackActivity] via the `cat.ikerdemo.musify:/callback` intent filter.
* - iOS: `ASWebAuthenticationSession`, which owns the whole round trip. * - iOS: `ASWebAuthenticationSession`, which owns the whole round trip.
*/ */
expect class BrowserAuthLauncher() { expect class BrowserAuthLauncher() {
@@ -12,5 +12,5 @@ actual object AppConfig {
// Matches CFBundleURLTypes in Info.plist and the redirect URI registered // Matches CFBundleURLTypes in Info.plist and the redirect URI registered
// for the "Musify Native" app in Zitadel. // for the "Musify Native" app in Zitadel.
actual val nativeRedirectUri: String = "musify://callback" actual val nativeRedirectUri: String = "cat.ikerdemo.musify:/callback"
} }
@@ -23,14 +23,15 @@ private class PresentationContextProvider : NSObject(), ASWebAuthenticationPrese
/** /**
* `ASWebAuthenticationSession` owns the whole round trip: it opens a system * `ASWebAuthenticationSession` owns the whole round trip: it opens a system
* browser sheet at [authorizeUrl] (Zitadel's hosted login, with its * browser sheet at [authorizeUrl] (Zitadel's hosted login, with its
* "Regístrate" link) and intercepts the `musify://` redirect itself, no app * "Regístrate" link) and intercepts the `cat.ikerdemo.musify:` redirect itself, no app
* delegate / `onOpenURL` wiring required. * delegate / `onOpenURL` wiring required.
*/ */
@OptIn(ExperimentalForeignApi::class) @OptIn(ExperimentalForeignApi::class)
actual class BrowserAuthLauncher actual constructor() { actual class BrowserAuthLauncher actual constructor() {
actual suspend fun launch(authorizeUrl: String, redirectUri: String): String = actual suspend fun launch(authorizeUrl: String, redirectUri: String): String =
suspendCancellableCoroutine { continuation -> suspendCancellableCoroutine { continuation ->
val scheme = redirectUri.substringBefore("://") // `scheme:/path` (RFC 8252 private-use scheme): everything before the first ':'.
val scheme = redirectUri.substringBefore(":")
val contextProvider = PresentationContextProvider() val contextProvider = PresentationContextProvider()
val session = ASWebAuthenticationSession( val session = ASWebAuthenticationSession(
+2 -3
View File
@@ -11,9 +11,8 @@ 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. Required, at least # Secret used to derive the session cookie encryption key (any long random string)
# 32 characters (e.g. `openssl rand -base64 48`). SESSION_SECRET=change-me-to-a-long-random-string
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
+15 -36
View File
@@ -15,8 +15,7 @@ npm run dev # dev server (port 5173)
npm run build npm run build
npm run check # svelte-check, must exit 0 errors before closing a change npm run check # svelte-check, must exit 0 errors before closing a change
npm run format # prettier --write . npm run format # prettier --write .
npm run test # vitest run (pure functions, mappers, form parsers) npm run lint # prettier --check . && eslint . && npm run lint:tokens
npm run lint # prettier --check . && eslint . && npm run lint:tokens && npm run test
npm run lint:tokens # scripts/check-tokens.mjs, the design system guardrail (see below) npm run lint:tokens # scripts/check-tokens.mjs, the design system guardrail (see below)
``` ```
@@ -24,7 +23,6 @@ npm run lint:tokens # scripts/check-tokens.mjs, the design system guardrail
- **No comments** in code unless essential. - **No comments** in code unless essential.
- `npm run lint` and `npm run check` must exit clean before closing a change. - `npm run lint` and `npm run check` must exit clean before closing a change.
- **Imports:** always `$lib/...`, never `../` (ESLint enforces it). Non-null assertions are forbidden too.
- **Always Svelte 5 runes:** `$props()` with `interface Props`, `$state`, `$derived`, `$effect`. No `export let` or `$:`. `{#each}` always keyed. - **Always Svelte 5 runes:** `$props()` with `interface Props`, `$state`, `$derived`, `$effect`. No `export let` or `$:`. `{#each}` always keyed.
- **Server only code goes under `$lib/server/`**, and it's never imported from client code. - **Server only code goes under `$lib/server/`**, and it's never imported from client code.
- SSR data comes through `load` in `+page.server.ts`/`+layout.server.ts` and lands in the `data` prop. - SSR data comes through `load` in `+page.server.ts`/`+layout.server.ts` and lands in the `data` prop.
@@ -33,38 +31,26 @@ npm run lint:tokens # scripts/check-tokens.mjs, the design system guardrail
## Folder structure ## Folder structure
- `src/lib/types.ts` holds the domain types (`Track`, `Album`, `Playlist`, `Mix`, `Paged<T>`). Pages and the player only ever see these, never raw API DTOs. - `src/lib/state/` holds reactive rune based state (`*.svelte.ts` files that don't have a more specific home). Player state stays in `$lib/player/`, theme mode in `$lib/theme/`.
- `src/lib/player/` is the player: `player.svelte.ts` (state and orchestration), `queue.ts` (pure queue operations), `stream.ts`, `mediaSession.ts`, `progressClock.ts`, `listenTracker.ts`, `liked.svelte.ts`, and `actions.ts` (`playAllOrToggle`, `playShuffled`, `isQueueCurrent`). - `src/lib/data/` holds client side domain helpers: albums, collections, genres, mixes, recentlyPlayed, search.
- `src/lib/state/` holds reactive rune state: `panels.svelte.ts` (queue panel, create playlist modal), `history.svelte.ts` (previous page for `BackLink`), `menu.svelte.ts` (`createMenu<T>()`), `pagedList.svelte.ts` (`createPagedList`), `dialog.svelte.ts`, and `scroll.ts`. Nothing in `state/` imports from `components/`. - `src/lib/utils/` holds generic utilities with no domain knowledge, like `format.ts`.
- `src/lib/theme/` holds the theme: `accent.svelte.ts` (accent hue per track and the hue fade), `mode.svelte.ts`, `palette.ts`, `tokens.ts`. - `src/lib/components/layout/` holds the app chrome: Sidebar, TopBar, TabsBar, MobileHeader.
- `src/lib/data/` holds client side domain helpers: albums, genres, recentlyPlayed, search.
- `src/lib/utils/` holds generic utilities: `format.ts`, `collections.ts`, `hrefs.ts` (search, genre and cover URLs), `menuPosition.ts`, `storage.ts`, `transitions.ts`.
- `src/lib/validation.ts` holds the form limits (`LIMITS`) shared by client forms and server parsers.
- `src/lib/server/` is server only code (see below).
- `src/lib/components/layout/` holds the app chrome: Sidebar, TopBar, TabsBar, MobileHeader, and `navLinks.ts`.
- `src/lib/components/player/` holds the player UI: PlayerBar, PlayerDock, Queue, TrackInfo, TransportControls. - `src/lib/components/player/` holds the player UI: PlayerBar, PlayerDock, Queue, TrackInfo, TransportControls.
- `src/lib/components/ui/` holds the design system components, split into five categories: - `src/lib/components/ui/` holds the design system components, split into five categories:
- `primitives/` for generic controls with no music domain knowledge (Button, Input, Chip, Slider, and so on). - `primitives/` for generic controls with no music domain knowledge (Button, Input, Chip, Slider, and so on).
- `overlay/` for anything that floats above the content (Modal, ContextMenu, TrackContextMenu, AlbumContextMenu, AccountMenu, and so on). - `overlay/` for anything that floats above the content (Modal, ContextMenu, AccountMenu, and so on).
- `media/` for music domain display components (Artwork, TrackList, MediaCard, PlayButton, PlayAllButton, 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.
- Everything the user sees after signing in lives under `src/routes/(app)/`, including Home (`(app)/+page.svelte`). Auth routes live under `src/routes/auth/`: `+page.svelte` is the login screen, and `login/`, `logout/`, `callback/` are the OIDC endpoints. The public user profile route is `src/routes/(app)/user/[id]/`, and its followers/following lists share one route, `user/[id]/[list=followList]/` (matcher in `src/params/followList.ts`). - Auth routes live under `src/routes/auth/`: `+page.svelte` is the login screen, and `login/`, `logout/`, `callback/` are the OIDC endpoints. The public user profile route is `src/routes/(app)/user/[id]/`, and its followers/following lists share one route, `user/[id]/[list=followList]/` (matcher in `src/params/followList.ts`).
## Naming conventions and glossary
- Variant maps are module level constants in upper case (`SIZE`, `VARIANT`).
- Errors: destructure as `const { data, error } = ...` and name the caught value `err` in `catch (err)`.
- Play handlers: `playFrom(index)` and `playAll()`. Form actions: `create`, `edit`, `delete`, `addTrack`, `removeTrack`.
- UI copy: "Playlist" (never "Lista"), "Mezcla" (never "Mix"), "Modo claro". Total durations always go through `fmtDurationLong`.
## Styles ## Styles
The design system lives in `src/routes/layout.css` (`@theme` tokens plus `@utility` recipes) and in `src/lib/theme/` (`theme.css` for the static neutrals, `tokens.ts` for the dynamic tokens that depend on the user's hue). `scripts/check-tokens.mjs` (run as `npm run lint:tokens`, part of `lint`) fails on a stock radius, an `h-N w-N` pair, fractional quarter spacing, a literal hex/`rgba()`, or an arbitrary `-[…]` value outside its whitelist, so check that script before adding one. The design system lives in `src/routes/layout.css` (`@theme` tokens plus `@utility` recipes) and in `src/lib/theme/` (`theme.css` for the static neutrals, `tokens.ts` for the dynamic tokens that depend on the user's hue). `scripts/check-tokens.mjs` (run as `npm run lint:tokens`, part of `lint`) fails on a stock radius, an `h-N w-N` pair, fractional quarter spacing, a literal hex/`rgba()`, or an arbitrary `-[…]` value outside its whitelist, so check that script before adding one.
- **Radii:** always `rounded-tag/thumb/control/art/art-lg/panel/panel-lg` (or `rounded-full` for pills and circles). Never `rounded-sm/md/lg/xl/2xl/3xl` or a bare `rounded`. - **Radii:** always `rounded-tag/thumb/control/art/art-lg/panel/panel-lg` (or `rounded-full` for pills and circles). Never `rounded-sm/md/lg/xl/2xl/3xl` or a bare `rounded`.
- **Square sizes:** use `size-*` instead of repeating `h-N w-N` (icons: `size-icon-xs/sm/md/lg/xl/2xl/3xl`; 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`, 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`, `text-count`, 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.
@@ -91,19 +77,12 @@ Environment variables live in `.env` (see `.env.example`): `ZITADEL_*`, `AUTH_RE
## Backend API ## Backend API
Typed client via `openapi-fetch` over the `Musify.Api` OpenAPI spec. Inside a server `load` or action use the helpers in `$lib/server/api.ts`: Typed client via `openapi-fetch` over the `Musify.Api` OpenAPI spec. Inside a server `load` or action:
```ts ```ts
const api = apiFor(event); // or apiFor({ fetch, locals }); adds the bearer token when there is a session import { createApiClient } from '$lib/server/api';
const { data, error } = await api.GET('/tracks'); const api = createApiClient({ fetch, accessToken: locals.accessToken ?? undefined });
const { data, error: err } = await api.GET('/tracks');
``` ```
It returns `{ data, error }` (ErrorOr style) and never throws except on network errors. The types live in `src/lib/api/schema.d.ts` and get regenerated with `npm run gen:api` (this needs the dev API running at `API_BASE_URL`, which only exposes `/openapi/v1.json` in Development). It returns `{ data, error }` (ErrorOr style) and never throws. The types live in `src/lib/api/schema.d.ts` and get regenerated with `npm run gen:api` (this needs the dev API running at `API_BASE_URL`, which only exposes `/openapi/v1.json` in Development).
Server helpers:
- `mappers.ts` (`toTrack`, `toAlbum`, `toPlaylist`, `toMix`, `toPage`, ...) turn DTOs into the domain types. Every load and `/api` endpoint that returns tracks, albums or playlists goes through them, so `Number()` conversions never reach the client.
- `authedAction(handler)` wraps a form action: it returns 401 without a token, reads the form and hands `{ api, form, params, locals, url }` to the handler. Use `formString`/`formFile` to read fields, `failOnError(result, message)` to turn an API error into a `fail(...)`, and `requireData(result, message)` when you also need the response data narrowed.
- `forms/` holds the form parsers (`parseAlbumForm`, `parsePlaylistForm`, `parseTrackUploadForm`); they validate with `LIMITS` from `$lib/validation`. `uploadOptionalCover` handles the optional cover upload.
- `genres.ts` caches the genre list; `playbackConfig.ts` caches the anonymous listening setting.
- Page sizes come from `$lib/config`; do not hardcode them.
+112 -34
View File
@@ -1,50 +1,128 @@
# Musify, page guide # Musify, page guide
Living document describing what each page of the web frontend contains. The design system (tokens, recipes, components) lives in the `musify-web` skill and in `CLAUDE.md`; here we describe **content and structure**, not styling. Living document describing what each page of the web frontend should contain. It works as a map for prioritizing features. The design system (tokens, recipes, components) lives in the `musify-web` skill; here we describe **content and structure**, not styling.
Status legend: ✅ done · 🟡 partial / mockup · ⬜ pending.
> **Note (app redesign):** the `Musify.dc.html` prototype is being ported over (an app with a sidebar, views, and a persistent player with a dynamic accent per track). The marketing landing page was removed. The sections below still describe the older design and are pending an update to match the app views (Home, Search, Library, Upload, Import, Downloads, Playlists). The **player** is already built as components in `$lib/components/player/`, with its theme in `$lib/theme/` (`theme.css` for the palette, `color.ts` for the accent and gradient).
## Home (app) `/` 🟡
- **Hero**: a greeting plus "Your music. No limits." with a color wash taken from the current track's hue.
- **Quick access, meaning recently played** (`GET /users/{id}/listening-history` merged with `player.recentlyPlayed` via `mergeRecentlyPlayed`). Empty state with an icon and an "Explore" CTA.
- **Your playlists**: mosaic cards for the user's playlists (sample data for now). Has an empty state.
- **Resume on load**: the root layout fetches `GET /users/{id}/last-listened-track` and hydrates the player (`player.hydrate`) with it, paused, so the player bar isn't empty on a fresh session. Only applies when nothing is already loaded (won't override an in-progress session).
### Pending backend work (ASP.NET) for Home
- **Playlists by last listen**: add `lastPlayedAt` to the playlist (the schema currently only has `createdAt`/`updatedAt`) and allow sorting `GET /playlists/users/{userId}` by that field (for example `?sort=lastPlayed`), to show "your most recently played" playlists.
- Tracks will need an **artist** and a **color/hue** (or a real cover to extract one from) for the artwork and the dynamic accent. Right now `TrackApplicationResponse` carries neither artist nor color.
## Route structure ## Route structure
Everything the user sees after signing in lives under the `(app)` group (the group doesn't change the URL). `auth/` and `api/` sit outside it. Routes are grouped to separate the public experience from the authenticated one (the groups don't change the URL):
``` ```
src/routes/ src/routes/
(app)/ (marketing)/ Public, no session required
+page.svelte / Home +page.svelte / Landing ✅
explore/ /explore Search and genre discovery (app)/ The app experience
liked/ /liked Liked songs explore/ /explore Explore / search ✅
library/ /library Your uploaded songs upload/ /upload Upload music 🟡 mockup
upload/ /upload Upload a song library/ /library Your library ⬜
albums/ /albums Your albums playlists/ /playlists Your playlists ⬜
[id]/ /albums/:id Album detail [id]/ /playlists/:id Playlist detail ⬜
playlists/ /playlists Your playlists auth auth/login auth/logout auth/callback Auth (page + endpoints) ✅
[id]/ /playlists/:id Playlist detail api/tracks/[id]/stream Stream proxy ✅
mixes/[id]/ /mixes/:id Generated mix
user/[id]/ /user/:id Public profile
[list=followList]/ /user/:id/followers|following
auth/ auth/login auth/logout auth/callback Login screen and OIDC endpoints
api/ Same origin JSON endpoints used by the client
``` ```
Global chrome on every app page: sidebar (desktop) or tab bar (mobile), top bar with search and account menu, the persistent player dock, and the queue panel. Global components (present on every page): **Navbar**, **Footer**, and **PlayerBar** (the fixed playback bar, shown while something is playing).
## Pages ---
- **Home `/`**: a greeting, weekly listening stats, "continue listening" (recently played merged with the in-session history), the featured playlist with its first tracks (play and shuffle load the full list on demand), the user's mixes and playlists. Anonymous visitors are redirected to `/explore`. ## 1. Landing `/` ✅ (marketing)
- **Explore `/explore`**: genre tiles when there is no query, otherwise search results grouped by songs, albums, users and playlists with filter chips and counts taken from the API totals. Songs paginate with infinite scroll. Track and album context menus offer play next, add to queue and add to playlist.
- **Liked `/liked`**: the songs the user liked, newest first, with unlike per row.
- **Library `/library`**: the songs the user uploaded, with a confirmed delete.
- **Upload `/upload`**: audio, cover, title, genres (incompatible genres are blocked) and explicit flag. The server reserves presigned URLs, uploads both files and creates the track; processing happens asynchronously in the backend.
- **Albums `/albums`, `/albums/:id`**: list and create albums. The owner can edit, delete and add or remove tracks from their library.
- **Playlists `/playlists`, `/playlists/:id`**: list and create playlists. The owner can edit, delete and remove tracks; everyone else gets a read only view.
- **Mix `/mixes/:id`**: a generated mix with play, shuffle and a track menu.
- **Profile `/user/:id`**: public playlists, follow button and links to the followers and following lists, which respect the profile's privacy.
- **Auth `/auth`**: the login screen. Its buttons lead to `/auth/login`, which starts the Zitadel OIDC flow; `?mode=register` opens the sign up variant and `?returnTo=` (local paths only) sends the user back afterwards. `/auth/logout` ends the session and `/auth/callback` creates the encrypted `mf_session` cookie.
## Data flow The entry page for logged out visitors. Its job is to sell the product and drive people to sign up or explore.
Each page loads its data in `+page.server.ts` through the typed API client, and normalizes it with the mappers in `$lib/server/mappers.ts` so the client only ever sees the domain types in `$lib/types.ts`. Mutations are form actions built with `authedAction`. Client side pagination goes through the `/api/*` endpoints. - **Hero**: a status badge (beta), a title, a subtitle, a primary CTA (Start for free, leading to sign up) and a secondary one (Explore songs).
- **Features**: three cards covering listening, playlists, and uploading.
- **Suggested next steps**: a screenshots or demo section, social proof, a short FAQ.
## Pending backend work ## 2. Login / Auth ✅ (endpoints)
- Sorting playlists by last listen (`lastPlayedAt`) for a "recently played playlists" shelf. `/auth` is Musify's login screen; its buttons lead to `/auth/login`, which kicks off the Zitadel OIDC flow and redirects. `?mode=register` starts on the sign up variant, and `?returnTo=` sends the user back to the route they came from once they're in. `/auth/logout` ends the session. `/auth/callback` exchanges the code and creates the encrypted `mf_session` cookie. The credentials UI itself is served by Zitadel, not by Musify.
- An endpoint returning the most listened tracks, to bring back a "popular" section on Home.
## 3. Explore / Search `/explore` ✅ (app)
Discovery and search over the catalog.
- **Search box**: a text input (`?q=`) that filters by name on the backend.
- **Results count** and a **track grid** (2 to 4 columns): cover, title, date, and a play/pause button on hover that queues and plays through the global player.
- **Pagination**: previous/next (`?page=`, 24 per page).
- **Empty state**: no results, or an empty catalog.
- **Suggested next steps**: filters (most recent, by user), loading skeletons, a real track cover (the listing API doesn't currently return the image key).
## 4. Upload music `/upload` 🟡 mockup (app)
Adding a new song. The real backend flow, for when it gets wired up:
1. `POST /tracks/upload-urls` returns presigned URLs for cover and audio, plus `intentId`s.
2. `PUT` each file to its presigned URL (a direct upload to storage).
3. `POST /tracks` with `title`, `pictureIntentId`, `audioIntentId`.
4. The backend processes the audio asynchronously (transcoding it to DASH).
What the page needs:
- **Audio zone**: drag and drop or a file picker, showing the file's name and size, with type (audio) and size validation.
- **Cover**: an image picker with a square preview.
- **Metadata**: title (required), with room for future fields like a description.
- **Visible pipeline**: the Upload, Process, Publish steps and their status.
- **Progress bar** for the upload and the processing state.
- **States**: empty, file selected, uploading, processing, published, error.
- **Requires a session** (once wired up: redirect to `/auth?returnTo=/upload`).
## 5. Your library `/library` ⬜ (app)
The authenticated user's personal panel, everything of theirs in one place.
- **Tabs / sections**: "Your songs" (`GET /tracks/users/{userId}`) and "Your playlists" (`GET /playlists/users/{userId}`).
- **Your songs**: a list with play, plus management actions (delete via `DELETE /tracks/{id}`, add to a playlist).
- **Your playlists**: a grid of covers with a "Create playlist" button.
- **Empty state** with a CTA to upload or create.
- **Requires a session.**
## 6. Playlists `/playlists` ⬜ (app)
A listing of the user's playlists.
- **Grid** of playlists (cover, name, track count), linking to the detail page.
- **Create playlist**: a modal or page with a name, description, and cover (`POST /playlists`, cover via `POST /playlists/upload-picture`).
- **Empty state** with a CTA.
- **Requires a session.**
## 7. Playlist detail `/playlists/[id]` ⬜ (app)
- **Header**: a large cover, name, description, track count, and a "Play all" button.
- **Track list** (`GET /playlists/{id}/tracks`, paginated): index, title, date, play, and remove from the playlist (`DELETE /playlists/{id}/tracks/{trackId}`).
- **Owner actions**: edit (`PUT`), delete (`DELETE`) the playlist, reorder (a future addition).
- **Add tracks**: from explore or the library (`POST /playlists/{id}/tracks/{trackId}`).
## 8. User profile `/user/[id]` ⬜ (future)
A public view of a user: avatar, name, their public songs and playlists (`GET /users/{id}`, `GET /tracks/users/{id}`, `GET /playlists/users/{id}`).
---
## Global player (PlayerBar) ✅
A fixed bottom bar, visible while something is playing. Shows track info, transport controls (previous, play/pause, next), a progress bar, and volume control. Queues lists from any grid (`player.playQueue`). Plays DASH through the `/api/tracks/:id/stream` proxy, which requests a manifest and a signed ticket from the backend.
## Available backend (summary)
| Resource | Endpoints |
| --------- | ----------------------------------------------------------------- |
| Tracks | list/search, detail, by user, stream, create, delete, upload-urls |
| Playlists | CRUD, playlist tracks, add/remove track, upload-picture |
| Users | list/search, detail, create |
Typed client in `$lib/server/api.ts` (`openapi-fetch`, ErrorOr style). Use it inside `load`/server actions, never from the client. Types live in `src/lib/api/schema.d.ts`.
-13
View File
@@ -19,19 +19,6 @@ export default ts.config(
'error', 'error',
{ argsIgnorePattern: '^_', varsIgnorePattern: '^_' } { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }
], ],
'@typescript-eslint/no-non-null-assertion': 'error',
'no-restricted-imports': [
'error',
{
patterns: [
{
group: ['../*'],
message: 'Use $lib/... instead of relative parent imports.'
}
]
}
],
'svelte/no-unused-props': 'error',
'svelte/no-navigation-without-resolve': 'off', 'svelte/no-navigation-without-resolve': 'off',
'svelte/prefer-svelte-reactivity': 'off' 'svelte/prefer-svelte-reactivity': 'off'
} }
+32 -300
View File
@@ -36,8 +36,7 @@
"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": { "node_modules/@emnapi/core": {
@@ -52,6 +51,17 @@
"tslib": "^2.4.0" "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": { "node_modules/@emnapi/runtime": {
"version": "1.11.3", "version": "1.11.3",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz",
@@ -64,12 +74,11 @@
} }
}, },
"node_modules/@emnapi/wasi-threads": { "node_modules/@emnapi/wasi-threads": {
"version": "1.2.3", "version": "1.2.2",
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.3.tgz", "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz",
"integrity": "sha512-ELEBe8PsLvvJ6QMr0zLt8ffvOHW/dc1m3CEzNMg7aJUv3bMaoDtw2TXyDAwkYBuroxxuHEwhRTLJSe5sya547g==", "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==",
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"peer": true,
"dependencies": { "dependencies": {
"tslib": "^2.4.0" "tslib": "^2.4.0"
} }
@@ -334,9 +343,9 @@
} }
}, },
"node_modules/@jridgewell/sourcemap-codec": { "node_modules/@jridgewell/sourcemap-codec": {
"version": "1.6.0", "version": "1.5.5",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
"integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@jridgewell/trace-mapping": { "node_modules/@jridgewell/trace-mapping": {
@@ -359,24 +368,21 @@
} }
}, },
"node_modules/@napi-rs/wasm-runtime": { "node_modules/@napi-rs/wasm-runtime": {
"version": "1.2.4", "version": "1.1.6",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.4.tgz", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz",
"integrity": "sha512-AJxoUD2/15ESHbvpcyjU274nsAPLuOtPHCk0vKJM5pj//Fg/B1FXNWjPnXTT9PymCYYiHo4zPj0ZomXBKhoy7g==", "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==",
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"dependencies": { "dependencies": {
"@tybys/wasm-util": "^0.10.3" "@tybys/wasm-util": "^0.10.3"
}, },
"engines": {
"node": "^20.19.0 || ^22.13.0 || >=23.5.0"
},
"funding": { "funding": {
"type": "github", "type": "github",
"url": "https://github.com/sponsors/Brooooooklyn" "url": "https://github.com/sponsors/Brooooooklyn"
}, },
"peerDependencies": { "peerDependencies": {
"@emnapi/core": "^1.7.1 || ^2.0.0-alpha.4", "@emnapi/core": "^1.7.1",
"@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.4" "@emnapi/runtime": "^1.7.1"
} }
}, },
"node_modules/@oxc-project/types": { "node_modules/@oxc-project/types": {
@@ -625,16 +631,6 @@
"tslib": "^2.4.0" "tslib": "^2.4.0"
} }
}, },
"node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/wasi-threads": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz",
"integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==",
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@rolldown/binding-win32-arm64-msvc": { "node_modules/@rolldown/binding-win32-arm64-msvc": {
"version": "1.1.4", "version": "1.1.4",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.4.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.4.tgz",
@@ -1560,39 +1556,21 @@
} }
}, },
"node_modules/@tybys/wasm-util": { "node_modules/@tybys/wasm-util": {
"version": "0.10.4", "version": "0.10.3",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.4.tgz", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz",
"integrity": "sha512-W3c4gRigFS0T/Ma4qIYF3GDAc5AQdHb1yL5znJT1Zv1YaD9Kitx656wBjvr19qbiosmZT8lWDM5BEMynUqX65A==", "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==",
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"dependencies": { "dependencies": {
"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",
@@ -1865,64 +1843,6 @@
"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",
@@ -1971,16 +1891,6 @@
"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",
@@ -2013,16 +1923,6 @@
"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",
@@ -2163,13 +2063,6 @@
"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",
@@ -2430,16 +2323,6 @@
"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",
@@ -3124,9 +3007,9 @@
} }
}, },
"node_modules/obug": { "node_modules/obug": {
"version": "2.2.1", "version": "2.1.3",
"resolved": "https://registry.npmjs.org/obug/-/obug-2.2.1.tgz", "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.3.tgz",
"integrity": "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==", "integrity": "sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==",
"funding": [ "funding": [
"https://github.com/sponsors/sxzz", "https://github.com/sponsors/sxzz",
"https://opencollective.com/debug" "https://opencollective.com/debug"
@@ -3247,9 +3130,9 @@
"license": "ISC" "license": "ISC"
}, },
"node_modules/picomatch": { "node_modules/picomatch": {
"version": "4.0.7", "version": "4.0.5",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz",
"integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=12" "node": ">=12"
@@ -3690,13 +3573,6 @@
"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",
@@ -3720,20 +3596,6 @@
"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",
@@ -3898,26 +3760,6 @@
"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",
@@ -4134,99 +3976,6 @@
} }
} }
}, },
"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",
@@ -4243,23 +3992,6 @@
"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",
+2 -5
View File
@@ -11,11 +11,9 @@
"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 && npm run test" "lint": "prettier --check . && eslint . && npm run lint:tokens"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^10.0.1", "@eslint/js": "^10.0.1",
@@ -35,8 +33,7 @@
"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",
-15
View File
@@ -24,16 +24,12 @@ const PAIRED_HW = /\b(?:([a-z0-9-]+:))?h-([\d.]+)\s+\1w-\2\b/g;
const QUARTER_STEP_SPACING = /\b[a-z-]+-\d+\.(25|75)\b/g; const QUARTER_STEP_SPACING = /\b[a-z-]+-\d+\.(25|75)\b/g;
const HEX_COLOR = /#[0-9a-fA-F]{3,8}\b/g; const HEX_COLOR = /#[0-9a-fA-F]{3,8}\b/g;
const RGBA_LITERAL = /\brgba?\(/g; const RGBA_LITERAL = /\brgba?\(/g;
const LUCIDE_IMPORT = /import (\w+) from '@lucide\/svelte\/icons\/[\w-]+'/g;
const ICON_PIXEL_SIZE = /\bsize=\{\d+\}/;
const ICON_RAW_SIZE = /\bclass="[^"]*\bsize-\d[\w.]*/;
let violations = []; let violations = [];
for (const file of files) { for (const file of files) {
const content = readFileSync(file, 'utf8'); const content = readFileSync(file, 'utf8');
const lines = content.split('\n'); const lines = content.split('\n');
const icons = [...content.matchAll(LUCIDE_IMPORT)].map((m) => m[1]);
lines.forEach((line, i) => { lines.forEach((line, i) => {
const lineNo = i + 1; const lineNo = i + 1;
@@ -44,17 +40,6 @@ for (const file of files) {
} }
} }
for (const icon of icons) {
const tag = line.match(new RegExp(`<${icon}\\s[^>]*`));
if (!tag) continue;
if (ICON_PIXEL_SIZE.test(tag[0])) {
violations.push(`${file}:${lineNo} — icono con size={N}: usa una clase size-icon-*`);
}
if (ICON_RAW_SIZE.test(tag[0])) {
violations.push(`${file}:${lineNo} — icono con size-N: usa una clase size-icon-*`);
}
}
for (const match of line.matchAll(RAW_ROUNDED)) { for (const match of line.matchAll(RAW_ROUNDED)) {
violations.push(`${file}:${lineNo} — radio de Tailwind sin tokenizar: \`${match[0]}\``); violations.push(`${file}:${lineNo} — radio de Tailwind sin tokenizar: \`${match[0]}\``);
} }
-17
View File
@@ -1,17 +0,0 @@
import type { Action } from 'svelte/action';
export const onEscape: Action<HTMLElement, (() => void) | undefined> = (_node, handler) => {
function onKeydown(event: KeyboardEvent) {
if (event.key === 'Escape') handler?.();
}
window.addEventListener('keydown', onKeydown);
return {
update(next) {
handler = next;
},
destroy() {
window.removeEventListener('keydown', onKeydown);
}
};
};
-63
View File
@@ -331,23 +331,6 @@ export interface paths {
patch?: never; patch?: never;
trace?: never; trace?: never;
}; };
"/playlists/{playlistId}/albums/{albumId}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** Add All The Tracks Of An Album To One Of Your PlayLists, Skipping Those Already In It. */
post: operations["AddAlbumToPlayList"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/playlists/{playlistId}/tracks": { "/playlists/{playlistId}/tracks": {
parameters: { parameters: {
query?: never; query?: never;
@@ -748,10 +731,6 @@ export interface paths {
export type webhooks = Record<string, never>; export type webhooks = Record<string, never>;
export interface components { export interface components {
schemas: { schemas: {
AddAlbumToPlayListResponse: {
/** Format: int32 */
addedCount: number | string;
};
AddPlayListTrackRequest: { AddPlayListTrackRequest: {
/** Format: uuid */ /** Format: uuid */
trackId: string; trackId: string;
@@ -961,12 +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: int32 */
trackCount: number | string;
/** Format: double */
durationSeconds: number | string;
/** Format: date-time */ /** Format: date-time */
createdAt: string; createdAt: string;
/** Format: date-time */ /** Format: date-time */
@@ -1913,43 +1887,6 @@ export interface operations {
}; };
}; };
}; };
AddAlbumToPlayList: {
parameters: {
query?: never;
header?: never;
path: {
playlistId: string;
albumId: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["AddAlbumToPlayListResponse"];
};
};
/** @description Unauthorized */
401: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description Not Found */
404: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
AddPlayListTrack: { AddPlayListTrack: {
parameters: { parameters: {
query?: never; query?: never;
@@ -3,10 +3,11 @@
import Upload from '@lucide/svelte/icons/upload'; import Upload from '@lucide/svelte/icons/upload';
import Folder from '@lucide/svelte/icons/folder'; import Folder from '@lucide/svelte/icons/folder';
import LogIn from '@lucide/svelte/icons/log-in'; import LogIn from '@lucide/svelte/icons/log-in';
import MenuItem from '$lib/components/ui/overlay/MenuItem.svelte'; import MenuItem from '../ui/overlay/MenuItem.svelte';
import AccountMenu from '$lib/components/ui/overlay/AccountMenu.svelte'; import AccountMenu from '../ui/overlay/AccountMenu.svelte';
import IconButton from '$lib/components/ui/primitives/IconButton.svelte'; import Avatar from '../ui/media/Avatar.svelte';
import Logo from '$lib/components/ui/primitives/Logo.svelte'; import IconButton from '../ui/primitives/IconButton.svelte';
import Logo from '../ui/primitives/Logo.svelte';
interface Props { interface Props {
user: SessionUser | null; user: SessionUser | null;
@@ -25,6 +26,17 @@
{#if user} {#if user}
<AccountMenu {user} {accountUrl} width="md"> <AccountMenu {user} {accountUrl} width="md">
{#snippet trigger({ toggle, open })}
<button
type="button"
onclick={toggle}
aria-label="Tu cuenta"
aria-expanded={open}
class="flex items-center rounded-full"
>
<Avatar name={user.name} src={user.picture} size="sm" />
</button>
{/snippet}
{#snippet extraItems({ close })} {#snippet extraItems({ close })}
<MenuItem icon={Folder} label="Canciones subidas" href="/library" onclick={close} /> <MenuItem icon={Folder} label="Canciones subidas" href="/library" onclick={close} />
<MenuItem icon={Upload} label="Subir música" href="/upload" onclick={close} /> <MenuItem icon={Upload} label="Subir música" href="/upload" onclick={close} />
@@ -1,39 +1,38 @@
<script lang="ts"> <script lang="ts">
import { page } from '$app/state'; import { page } from '$app/state';
import { appNavLinks, isNavActive } from './navLinks'; import { isSectionActive, appNavLinks } from '$lib/state/navigation.svelte';
import { playlistCover } from '$lib/utils/hrefs';
import { liked } from '$lib/player/liked.svelte'; import { liked } from '$lib/player/liked.svelte';
import { createPlaylistModal } from '$lib/state/panels.svelte'; import { createPlaylistModal } from '$lib/state/playlists.svelte';
import type { SessionUser } from '$lib/types'; import type { SessionUser } from '$lib/types';
import ListRow from '$lib/components/ui/media/ListRow.svelte'; import ListRow from '../ui/media/ListRow.svelte';
import IconButton from '$lib/components/ui/primitives/IconButton.svelte'; import IconButton from '../ui/primitives/IconButton.svelte';
import Plus from '@lucide/svelte/icons/plus'; import Plus from '@lucide/svelte/icons/plus';
import Logo from '$lib/components/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;
coverTrackIds: string[]; coverTrackIds: (string | number)[];
updatedAt: string; updatedAt: string;
} }
interface Props { interface Props {
user: SessionUser | null; user: SessionUser | null;
playlists: SidebarPlaylist[]; playlists: SidebarPlaylist[];
playlistCount: number;
} }
let { user, playlists, playlistCount }: Props = $props(); let { user, playlists }: Props = $props();
const visiblePlaylists = $derived(playlists.slice(0, SIDEBAR_PLAYLISTS_LIMIT)); const navLinks = $derived(appNavLinks(user, { playlists: playlists.length, liked: liked.count }));
const navLinks = $derived(appNavLinks(user, { playlists: playlistCount, liked: liked.count })); function isActive(href: string) {
return isSectionActive(href, page.url.pathname, page.data.section);
}
</script> </script>
<aside <aside
class="hidden min-h-0 w-(--mf-sidebar-w) 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)"
> >
<a href="/" class="mb-4"> <a href="/" class="mb-4">
<Logo size="sm" /> <Logo size="sm" />
@@ -41,7 +40,7 @@
<nav class="flex flex-col gap-1"> <nav class="flex flex-col gap-1">
{#each navLinks as link (link.href)} {#each navLinks as link (link.href)}
{@const active = isNavActive(link.href)} {@const active = isActive(link.href)}
<a <a
href={link.href} href={link.href}
aria-current={active ? 'page' : undefined} aria-current={active ? 'page' : undefined}
@@ -62,10 +61,10 @@
<div class="mx-3 mt-6 mb-3.5 h-px bg-line"></div> <div class="mx-3 mt-6 mb-3.5 h-px bg-line"></div>
<div class="flex items-center justify-between px-3 pb-1.5"> <div class="flex items-center justify-between px-3 pb-1.5">
<span class="text-eyebrow text-fg-2">Tus playlists</span> <span class="text-eyebrow text-fg-2">Tus playlists</span>
{#if visiblePlaylists.length > 0} {#if playlists.length > 0}
<IconButton <IconButton
label="Crear playlist" label="Crear playlist"
plain tone="plain"
size="xs" size="xs"
onclick={() => createPlaylistModal.show()} onclick={() => createPlaylistModal.show()}
> >
@@ -74,15 +73,17 @@
{/if} {/if}
</div> </div>
<div class="flex min-h-0 flex-1 flex-col gap-px overflow-y-auto pb-4.5"> <div class="flex min-h-0 flex-1 flex-col gap-px overflow-y-auto pb-icon-md">
{#each visiblePlaylists as playlist (playlist.id)} {#each playlists 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}"
{active} {active}
size="xs" size="xs"
title={playlist.name} title={playlist.name}
coverSrc={playlistCover(playlist, 'small')} coverSrc="/api/playlists/{playlist.id}/cover?size=small&v={encodeURIComponent(
playlist.updatedAt
)}"
trackIds={playlist.coverTrackIds} trackIds={playlist.coverTrackIds}
class="px-3 py-2" class="px-3 py-2"
/> />
@@ -90,10 +91,10 @@
<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" 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"
> >
<Plus class="size-icon-xs" /> <Plus class="size-3.5" />
Crear tu primera playlist Crear tu primera lista
</button> </button>
{/each} {/each}
</div> </div>
@@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import { appNavLinks, isNavActive } from './navLinks'; import { page } from '$app/state';
import { isSectionActive, appNavLinks } from '$lib/state/navigation.svelte';
import type { SessionUser } from '$lib/types'; import type { SessionUser } from '$lib/types';
interface Props { interface Props {
@@ -10,6 +11,10 @@
let { user, class: klass = '' }: Props = $props(); let { user, class: klass = '' }: Props = $props();
const links = $derived(appNavLinks(user).filter((link) => link.primary)); const links = $derived(appNavLinks(user).filter((link) => link.primary));
function isActive(href: string) {
return isSectionActive(href, page.url.pathname, page.data.section);
}
</script> </script>
<nav <nav
@@ -17,7 +22,7 @@
style="padding-bottom:calc(0.4375rem + var(--mf-safe-b))" style="padding-bottom:calc(0.4375rem + var(--mf-safe-b))"
> >
{#each links as link (link.href)} {#each links as link (link.href)}
{@const active = isNavActive(link.href)} {@const active = isActive(link.href)}
<a <a
href={link.href} href={link.href}
aria-current={active ? 'page' : undefined} aria-current={active ? 'page' : undefined}
@@ -25,7 +30,7 @@
? 'text-fg' ? 'text-fg'
: 'text-fg-2'}" : 'text-fg-2'}"
> >
<link.icon class="size-icon-sm" /> <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}
@@ -3,13 +3,14 @@
import { page } from '$app/state'; import { page } from '$app/state';
import type { SessionUser } from '$lib/types'; import type { SessionUser } from '$lib/types';
import { SEARCH_DEBOUNCE_MS, SEARCH_MIN_LENGTH } from '$lib/config'; import { SEARCH_DEBOUNCE_MS, SEARCH_MIN_LENGTH } from '$lib/config';
import { searchHref } from '$lib/utils/hrefs'; import { searchHref } from '$lib/state/navigation.svelte';
import Search from '@lucide/svelte/icons/search'; import Search from '@lucide/svelte/icons/search';
import X from '@lucide/svelte/icons/x'; import X from '@lucide/svelte/icons/x';
import ChevronLeft from '@lucide/svelte/icons/chevron-left'; import ChevronLeft from '@lucide/svelte/icons/chevron-left';
import AccountMenu from '$lib/components/ui/overlay/AccountMenu.svelte'; import AccountMenu from '../ui/overlay/AccountMenu.svelte';
import IconButton from '$lib/components/ui/primitives/IconButton.svelte'; import Avatar from '../ui/media/Avatar.svelte';
import Button from '$lib/components/ui/primitives/Button.svelte'; import IconButton from '../ui/primitives/IconButton.svelte';
import Button from '../ui/primitives/Button.svelte';
interface Props { interface Props {
user: SessionUser | null; user: SessionUser | null;
@@ -53,7 +54,14 @@
</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 label="Atrás" shape="round" surface size="xs" onclick={() => history.back()}> <IconButton
label="Atrás"
shape="round"
tone="subtle"
surface
size="xs"
onclick={() => history.back()}
>
<ChevronLeft class="size-icon-sm" /> <ChevronLeft class="size-icon-sm" />
</IconButton> </IconButton>
@@ -61,7 +69,7 @@
<label <label
class="flex h-9.5 w-full items-center gap-2.5 rounded-control bg-surface-2 px-3.5 transition focus-within:bg-surface-hover" class="flex h-9.5 w-full items-center gap-2.5 rounded-control bg-surface-2 px-3.5 transition focus-within:bg-surface-hover"
> >
<Search class="size-icon-sm shrink-0 text-fg-2" /> <Search class="size-4 shrink-0 text-fg-2" />
<input <input
type="text" type="text"
bind:this={searchInput} bind:this={searchInput}
@@ -77,15 +85,34 @@
aria-label="Borrar búsqueda" aria-label="Borrar búsqueda"
class="grid size-5 shrink-0 place-items-center rounded-full text-fg-2 transition hover:text-fg" class="grid size-5 shrink-0 place-items-center rounded-full text-fg-2 transition hover:text-fg"
> >
<X class="size-icon-xs" /> <X class="size-3.5" />
</button> </button>
{/if} {/if}
</label> </label>
</form> </form>
<div class="ml-auto shrink-0"> <div class="flex-1"></div>
<div class="shrink-0">
{#if user} {#if user}
<AccountMenu {user} {accountUrl} /> <AccountMenu {user} {accountUrl}>
{#snippet trigger({ toggle, open })}
<button
type="button"
onclick={toggle}
aria-label="Tu cuenta"
aria-expanded={open}
class="block shrink-0 rounded-full transition active:scale-95"
>
<Avatar
name={user.name}
src={user.picture}
size="sm"
class="border border-line-strong opacity-90"
/>
</button>
{/snippet}
</AccountMenu>
{:else} {:else}
<Button href="/auth/login" reload variant="accent" size="sm" class="shrink-0" <Button href="/auth/login" reload variant="accent" size="sm" class="shrink-0"
>Iniciar sesión</Button >Iniciar sesión</Button
@@ -1,48 +0,0 @@
import { page } from '$app/state';
import type { LucideIcon } from '@lucide/svelte';
import Home from '@lucide/svelte/icons/house';
import Compass from '@lucide/svelte/icons/compass';
import ListMusic from '@lucide/svelte/icons/list-music';
import Heart from '@lucide/svelte/icons/heart';
import Disc from '@lucide/svelte/icons/disc-2';
import Folder from '@lucide/svelte/icons/folder';
import Upload from '@lucide/svelte/icons/upload';
import type { SessionUser } from '$lib/types';
export interface AppNavLink {
href: string;
label: string;
icon: LucideIcon;
count?: number;
primary: boolean;
}
export interface AppNavCounts {
playlists?: number;
liked?: number;
}
export function isNavActive(href: string): boolean {
const section: string | null | undefined = page.data.section;
if (section !== undefined) return section === href;
return href === '/' ? page.url.pathname === '/' : page.url.pathname.startsWith(href);
}
export function appNavLinks(user: SessionUser | null, counts: AppNavCounts = {}): AppNavLink[] {
if (!user) return [{ href: '/explore', label: 'Descubrir', icon: Compass, primary: true }];
return [
{ href: '/', label: 'Inicio', icon: Home, primary: true },
{ href: '/explore', label: 'Descubrir', icon: Compass, primary: true },
{
href: '/playlists',
label: 'Playlists',
icon: ListMusic,
count: counts.playlists,
primary: true
},
{ href: '/liked', label: 'Me gusta', icon: Heart, count: counts.liked, primary: true },
{ href: '/albums', label: 'Mis álbumes', icon: Disc, primary: false },
{ href: '/library', label: 'Canciones subidas', icon: Folder, primary: false },
{ href: '/upload', label: 'Subir música', icon: Upload, primary: false }
];
}
@@ -4,7 +4,7 @@
import Slider from '$lib/components/ui/primitives/Slider.svelte'; import Slider from '$lib/components/ui/primitives/Slider.svelte';
import IconButton from '$lib/components/ui/primitives/IconButton.svelte'; import IconButton from '$lib/components/ui/primitives/IconButton.svelte';
import { player } from '$lib/player/player.svelte'; import { player } from '$lib/player/player.svelte';
import { queuePanel } from '$lib/state/panels.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 Volume1 from '@lucide/svelte/icons/volume-1';
import Volume2 from '@lucide/svelte/icons/volume-2'; import Volume2 from '@lucide/svelte/icons/volume-2';
@@ -12,12 +12,12 @@
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.duration > 0) player.seekFraction(value / player.duration); if (player.current.duration > 0) player.seekFraction(value / player.current.duration);
} }
</script> </script>
<div <div
class="animate-enter pointer-events-auto flex items-center gap-4.5 px-4 py-3 glass-bar 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 />
@@ -39,20 +39,20 @@
</span> </span>
<Slider <Slider
value={player.progress} value={player.progress}
max={player.duration} max={player.current.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.duration)} {fmtTime(player.current.duration)}
</span> </span>
</div> </div>
</div> </div>
<div class="flex w-(--mf-player-side-w) shrink-0 items-center justify-end gap-3.5"> <div class="hidden w-(--mf-player-side-w) shrink-0 items-center justify-end gap-3.5 sm:flex">
<IconButton <IconButton
label={player.muted ? 'Activar sonido' : 'Silenciar'} label={player.muted ? 'Activar sonido' : 'Silenciar'}
plain tone="plain"
size="xs" size="xs"
onclick={() => player.toggleMute()} onclick={() => player.toggleMute()}
> >
@@ -73,7 +73,7 @@
/> />
<IconButton <IconButton
label="Cola de reproducción" label="Cola de reproducción"
plain tone="plain"
size="xs" size="xs"
pressed={queuePanel.open} pressed={queuePanel.open}
onclick={() => queuePanel.toggle()} onclick={() => queuePanel.toggle()}
@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import PlayerBar from './PlayerBar.svelte'; import PlayerBar from './PlayerBar.svelte';
import TabsBar from '$lib/components/layout/TabsBar.svelte'; import TabsBar from '../layout/TabsBar.svelte';
import { player } from '$lib/player/player.svelte'; import { player } from '$lib/player/player.svelte';
import type { SessionUser } from '$lib/types'; import type { SessionUser } from '$lib/types';
@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { player } from '$lib/player/player.svelte'; import { player } from '$lib/player/player.svelte';
import { queuePanel } from '$lib/state/panels.svelte'; import { queuePanel } from '$lib/player/queuePanel.svelte';
import MediaIdentity from '$lib/components/ui/media/MediaIdentity.svelte'; import MediaIdentity from '$lib/components/ui/media/MediaIdentity.svelte';
import ListRow from '$lib/components/ui/media/ListRow.svelte'; import ListRow from '$lib/components/ui/media/ListRow.svelte';
import IconButton from '$lib/components/ui/primitives/IconButton.svelte'; import IconButton from '$lib/components/ui/primitives/IconButton.svelte';
@@ -43,16 +43,17 @@
inert={!queuePanel.open} inert={!queuePanel.open}
> >
<aside <aside
class="flex min-h-0 w-(--mf-queue-w) shrink-0 flex-col border-l border-hairline px-4 pt-5 pb-5" class="flex min-h-0 shrink-0 flex-col border-l border-hairline px-4 pt-5 pb-5"
style="width:var(--mf-queue-w)"
> >
<div class="mb-5 flex items-center justify-between"> <div class="mb-5 flex items-center justify-between">
<h2 class="font-display text-base font-medium tracking-tight text-fg">En cola</h2> <h2 class="font-display text-base font-medium tracking-tight text-fg">En cola</h2>
<IconButton label="Cerrar cola" plain size="xs" onclick={() => queuePanel.close()}> <IconButton label="Cerrar cola" tone="plain" size="xs" onclick={() => queuePanel.close()}>
<X class="size-icon-md" /> <X class="size-icon-md" />
</IconButton> </IconButton>
</div> </div>
{#if player.current} {#if player.current.id}
<p class="mb-3 text-eyebrow text-fg-2">Reproduciendo</p> <p class="mb-3 text-eyebrow text-fg-2">Reproduciendo</p>
<div class="mb-6 surface-active rounded-control p-2.5"> <div class="mb-6 surface-active rounded-control p-2.5">
<MediaIdentity <MediaIdentity
@@ -13,20 +13,27 @@
let { compact = false }: Props = $props(); let { compact = false }: Props = $props();
const current = $derived(player.current); const isLiked = $derived(player.current.id !== '' && liked.isLiked(player.current.id));
const isLiked = $derived(current !== null && liked.isLiked(current.id));
function toggleLike() { function toggleLike() {
if (current) liked.toggle(current); if (!player.current.id) return;
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 current} {#if player.current.id}
<Artwork <Artwork
trackIds={[current.id]} trackIds={[player.current.id]}
size="sm" size="sm"
alt={current.title} alt={player.current.title}
class="shrink-0 md:size-cover-md" class="shrink-0 md:size-cover-md"
/> />
{:else} {:else}
@@ -34,21 +41,21 @@
{/if} {/if}
<div class="min-w-0"> <div class="min-w-0">
<div class="flex min-w-0 items-center gap-1.5"> <div class="flex min-w-0 items-center gap-1.5">
{#if current?.explicit} {#if player.current.explicit}
<ExplicitBadge /> <ExplicitBadge />
{/if} {/if}
<span class="truncate text-sm text-fg">{current?.title ?? ''}</span> <span class="truncate text-sm text-fg">{player.current.title}</span>
</div> </div>
<ArtistLink <ArtistLink
name={current?.artist || '—'} name={player.current.artist || '—'}
ownerUserId={current?.ownerUserId} ownerUserId={player.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'}
plain tone="plain"
size="md" size="md"
pressed={isLiked} pressed={isLiked}
onclick={toggleLike} onclick={toggleLike}
@@ -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-icon-sm" /> <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-icon-sm" /> <Repeat class="size-4" />
</button> </button>
{/if} {/if}
</div> </div>
@@ -2,10 +2,10 @@
import { untrack } from 'svelte'; import { untrack } from 'svelte';
import Disc3 from '@lucide/svelte/icons/disc-3'; import Disc3 from '@lucide/svelte/icons/disc-3';
import CoverForm from './CoverForm.svelte'; import CoverForm from './CoverForm.svelte';
import Field from '$lib/components/ui/primitives/Field.svelte'; import Field from '../primitives/Field.svelte';
import Input from '$lib/components/ui/primitives/Input.svelte'; import Input from '../primitives/Input.svelte';
import Textarea from '$lib/components/ui/primitives/Textarea.svelte'; import Textarea from '../primitives/Textarea.svelte';
import { ALBUM_EARLIEST_YEAR, LIMITS } from '$lib/validation'; import { ALBUM_EARLIEST_YEAR } from '$lib/config';
interface Props { interface Props {
action: string; action: string;
@@ -58,13 +58,10 @@
> >
{#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={LIMITS.albumTitle} maxlength={200}
required required
bind:value={title} bind:value={title}
placeholder={titlePlaceholder} placeholder={titlePlaceholder}
@@ -88,7 +85,7 @@
<Textarea <Textarea
id="album-description" id="album-description"
name="description" name="description"
maxlength={LIMITS.albumDescription} maxlength={256}
bind:value={description} bind:value={description}
placeholder="¿De qué va este álbum?" placeholder="¿De qué va este álbum?"
/> />
@@ -3,9 +3,9 @@
import { enhance } from '$app/forms'; import { enhance } from '$app/forms';
import type { LucideIcon } from '@lucide/svelte'; import type { LucideIcon } from '@lucide/svelte';
import ImageDropzone from './ImageDropzone.svelte'; import ImageDropzone from './ImageDropzone.svelte';
import Button from '$lib/components/ui/primitives/Button.svelte'; import Button from '../primitives/Button.svelte';
import Field from '$lib/components/ui/primitives/Field.svelte'; import Field from '../primitives/Field.svelte';
import Alert from '$lib/components/ui/primitives/Alert.svelte'; import Alert from '../primitives/Alert.svelte';
interface Props { interface Props {
action: string; action: string;
@@ -81,8 +81,10 @@
<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="ml-auto flex shrink-0 items-center gap-3"> <div class="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,16 +37,9 @@
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
@@ -2,12 +2,13 @@
import { untrack } from 'svelte'; import { untrack } from 'svelte';
import Music from '@lucide/svelte/icons/music'; import Music from '@lucide/svelte/icons/music';
import CoverForm from './CoverForm.svelte'; import CoverForm from './CoverForm.svelte';
import Field from '$lib/components/ui/primitives/Field.svelte'; import Field from '../primitives/Field.svelte';
import Input from '$lib/components/ui/primitives/Input.svelte'; import Input from '../primitives/Input.svelte';
import Textarea from '$lib/components/ui/primitives/Textarea.svelte'; import Textarea from '../primitives/Textarea.svelte';
import SegmentedControl from '$lib/components/ui/primitives/SegmentedControl.svelte'; import SegmentedControl from '../primitives/SegmentedControl.svelte';
import { LIMITS } from '$lib/validation'; const NAME_MAX_LENGTH = 60;
const DESCRIPTION_MAX_LENGTH = 300;
interface Props { interface Props {
action: string; action: string;
@@ -60,12 +61,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}/{LIMITS.playlistName}</span> <span class="ml-auto tabular-nums">{name.length}/{NAME_MAX_LENGTH}</span>
{/snippet} {/snippet}
<Input <Input
id="playlist-name" id="playlist-name"
name="name" name="name"
maxlength={LIMITS.playlistName} maxlength={NAME_MAX_LENGTH}
required required
bind:value={name} bind:value={name}
placeholder={namePlaceholder} placeholder={namePlaceholder}
@@ -76,7 +77,7 @@
<Textarea <Textarea
id="playlist-description" id="playlist-description"
name="description" name="description"
maxlength={LIMITS.playlistDescription} maxlength={DESCRIPTION_MAX_LENGTH}
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"
@@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
interface Props { interface Props {
name?: string | null; name?: string | null;
ownerUserId?: string | null; ownerUserId?: string | number | null;
class?: string; class?: string;
} }
@@ -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:underline {klass}" class="block max-w-full truncate hover:text-fg-2 hover:underline {klass}"
> >
{name} {name}
</a> </a>
@@ -1,15 +1,15 @@
<script lang="ts"> <script lang="ts">
import type { Snippet } from 'svelte'; import type { Snippet } from 'svelte';
import { trackCover } from '$lib/utils/hrefs';
type Size = 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl' | 'hero' | 'fill'; type Size = 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl' | 'hero' | 'fill';
interface Props { interface Props {
src?: string | null; src?: string | null;
trackIds?: string[]; trackIds?: (string | number)[];
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,6 +21,7 @@
size, size,
shape = 'auto', shape = 'auto',
imageSize, imageSize,
fallback = 'music',
alt = '', alt = '',
class: klass = '', class: klass = '',
children children
@@ -62,12 +63,22 @@
const resolvedImageSize = $derived(imageSize ?? AUTO_IMAGE_SIZE[size]); const resolvedImageSize = $derived(imageSize ?? AUTO_IMAGE_SIZE[size]);
const radius = $derived(shape === 'round' ? 'rounded-full' : RADIUS[size]); const radius = $derived(shape === 'round' ? 'rounded-full' : RADIUS[size]);
let failedSrc = $state<string | null>(null); let srcFailed = $state(false);
let failedSingle = $state<string | null>(null); $effect(() => {
src;
srcFailed = false;
});
const showSrc = $derived(!!src && failedSrc !== src); let singleFailed = $state(false);
$effect(() => {
trackIds[0];
singleFailed = false;
});
const showSrc = $derived(!!src && !srcFailed);
const showMosaic = $derived(!showSrc && trackIds.length >= 4); const showMosaic = $derived(!showSrc && trackIds.length >= 4);
const singleId = $derived(!showSrc && !showMosaic && trackIds.length > 0 ? trackIds[0] : null); const singleId = $derived(!showSrc && !showMosaic && trackIds.length > 0 ? trackIds[0] : null);
const showSingle = $derived(singleId !== null && !singleFailed);
</script> </script>
<div class="relative overflow-hidden {DIMENSION[size]} {radius} {klass}"> <div class="relative overflow-hidden {DIMENSION[size]} {radius} {klass}">
@@ -78,14 +89,14 @@
loading="lazy" loading="lazy"
decoding="async" decoding="async"
fetchpriority="low" fetchpriority="low"
onerror={() => (failedSrc = src ?? null)} onerror={() => (srcFailed = true)}
class="h-full w-full object-cover" class="h-full w-full object-cover"
/> />
{:else if showMosaic} {:else if showMosaic}
<div class="grid h-full w-full grid-cols-2 grid-rows-2"> <div class="grid h-full w-full grid-cols-2 grid-rows-2">
{#each trackIds.slice(0, 4) as id (id)} {#each trackIds.slice(0, 4) as id (id)}
<img <img
src={trackCover(id, 'small')} src="/api/tracks/{id}/cover?size=small"
alt="" alt=""
loading="lazy" loading="lazy"
decoding="async" decoding="async"
@@ -95,17 +106,17 @@
/> />
{/each} {/each}
</div> </div>
{:else if singleId !== null && failedSingle !== singleId} {:else if showSingle}
<img <img
src={trackCover(singleId, resolvedImageSize)} src="/api/tracks/{singleId}/cover?size={resolvedImageSize}"
{alt} {alt}
loading="lazy" loading="lazy"
decoding="async" decoding="async"
fetchpriority="low" fetchpriority="low"
onerror={() => (failedSingle = singleId)} onerror={() => (singleFailed = true)}
class="h-full w-full object-cover" class="h-full w-full object-cover"
/> />
{:else} {:else if fallback === 'music'}
<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
@@ -113,6 +124,10 @@
/> />
</svg> </svg>
</div> </div>
{:else if fallback === 'gradient'}
<div class="h-full w-full bg-(image:--mf-cover-grad)"></div>
{:else}
<div class="h-full w-full bg-surface"></div>
{/if} {/if}
{@render children?.()} {@render children?.()}
</div> </div>
@@ -33,7 +33,11 @@
.join('') .join('')
); );
let failedSrc = $state<string | null>(null); let failed = $state(false);
$effect(() => {
src;
failed = false;
});
</script> </script>
<div <div
@@ -41,13 +45,13 @@
size size
]} {klass}" ]} {klass}"
> >
{#if src && failedSrc !== src} {#if src && !failed}
<img <img
{src} {src}
alt="" alt=""
loading="lazy" loading="lazy"
decoding="async" decoding="async"
onerror={() => (failedSrc = src ?? null)} onerror={() => (failed = true)}
class="absolute inset-0 h-full w-full object-cover" class="absolute inset-0 h-full w-full object-cover"
/> />
{:else} {:else}
@@ -1,11 +1,19 @@
<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 { paused = false, overlay = false, class: klass = '' }: Props = $props(); let {
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>
@@ -22,11 +30,11 @@
{/each} {/each}
</div> </div>
{:else} {:else}
<div class="flex h-3.5 items-end justify-center gap-0.5 {klass}"> <div class="flex items-end justify-center gap-0.5 {klass}" style="height:{size}px">
{#each delays as delay (delay)} {#each delays as delay (delay)}
<span <span
class="h-full w-0.5 rounded-full bg-accent" class="rounded-full bg-accent"
style="animation:equalize .9s ease-in-out {delay}s infinite; animation-play-state:{paused style="width:{barWidth}px; height:100%; animation:equalize .9s ease-in-out {delay}s infinite; animation-play-state:{paused
? 'paused' ? 'paused'
: 'running'}" : 'running'}"
></span> ></span>
@@ -1,15 +1,16 @@
<script lang="ts"> <script lang="ts">
import { untrack } from 'svelte'; import { untrack } from 'svelte';
import { enhance } from '$app/forms'; import { enhance } from '$app/forms';
import Button from '$lib/components/ui/primitives/Button.svelte'; import Button from '../primitives/Button.svelte';
interface Props { interface Props {
following: boolean; following: boolean;
userId?: string;
invalidateAll?: boolean; invalidateAll?: boolean;
class?: string; class?: string;
} }
let { following: initial, invalidateAll = false, class: klass = '' }: Props = $props(); let { following: initial, userId, invalidateAll = false, class: klass = '' }: Props = $props();
let following = $state(untrack(() => initial)); let following = $state(untrack(() => initial));
let submitting = $state(false); let submitting = $state(false);
@@ -29,6 +30,9 @@
}; };
}} }}
> >
{#if userId}
<input type="hidden" name="userId" value={userId} />
{/if}
<Button <Button
type="submit" type="submit"
size="sm" size="sm"
@@ -12,10 +12,10 @@
variant?: 'row' | 'card'; variant?: 'row' | 'card';
title: string; title: string;
subtitle?: string | null; subtitle?: string | null;
subtitleHref?: string | null; subtitleHref?: string | number | null;
explicit?: boolean; explicit?: boolean;
trackId?: string; trackId?: string | number;
trackIds?: string[]; trackIds?: (string | number)[];
coverSrc?: string | null; coverSrc?: string | null;
art?: Snippet; art?: Snippet;
overlay?: Snippet; overlay?: Snippet;
@@ -8,7 +8,7 @@
title: string; title: string;
subtitle?: string | null; subtitle?: string | null;
src?: string | null; src?: string | null;
trackIds?: string[]; trackIds?: (string | number)[];
onPlay?: (event: MouseEvent) => void; onPlay?: (event: MouseEvent) => void;
art?: Snippet; art?: Snippet;
oncontextmenu?: (event: MouseEvent) => void; oncontextmenu?: (event: MouseEvent) => void;
@@ -5,11 +5,11 @@
import ArtistLink from './ArtistLink.svelte'; import ArtistLink from './ArtistLink.svelte';
interface Props { interface Props {
trackId?: string; trackId?: string | number;
trackIds?: string[]; trackIds?: (string | number)[];
title: string; title: string;
artist?: string | null; artist?: string | null;
ownerUserId?: string | null; ownerUserId?: string | number | null;
coverSrc?: string | null; coverSrc?: string | null;
size?: 'xs' | 'sm' | 'lg'; size?: 'xs' | 'sm' | 'lg';
explicit?: boolean; explicit?: boolean;
@@ -18,6 +18,7 @@
class?: string; class?: string;
art?: Snippet; art?: Snippet;
overlay?: Snippet; overlay?: Snippet;
badge?: Snippet;
} }
let { let {
@@ -33,7 +34,8 @@
titleClass = '', titleClass = '',
class: klass = '', class: klass = '',
art, art,
overlay overlay,
badge
}: Props = $props(); }: Props = $props();
</script> </script>
@@ -59,6 +61,7 @@
<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,35 +0,0 @@
<script lang="ts">
import { player } from '$lib/player/player.svelte';
import { isQueueCurrent, playAllOrToggle } from '$lib/player/actions';
import type { Track } from '$lib/types';
import Button from '$lib/components/ui/primitives/Button.svelte';
interface Props {
items: Track[];
load?: () => Promise<Track[]>;
variant?: 'primary' | 'secondary' | 'accent';
size?: 'sm' | 'md' | 'lg';
idleLabel?: string;
activeLabel?: string;
}
let {
items,
load,
variant = 'primary',
size = 'sm',
idleLabel = 'Reproducir',
activeLabel = 'Pausar'
}: Props = $props();
const active = $derived(isQueueCurrent(items) && player.playing);
async function play() {
if (load && !isQueueCurrent(items)) playAllOrToggle(await load());
else playAllOrToggle(items);
}
</script>
<Button {variant} {size} onclick={play} disabled={items.length === 0 && !load}>
{active ? activeLabel : idleLabel}
</Button>
@@ -1,9 +1,18 @@
<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 type TrackListTrack = Track & { date?: string | number }; export interface TrackListTrack {
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' },
@@ -27,7 +36,7 @@
import { pressable } from '$lib/actions/pressable'; import { pressable } from '$lib/actions/pressable';
import MediaIdentity from './MediaIdentity.svelte'; import MediaIdentity from './MediaIdentity.svelte';
import EqBars from './EqBars.svelte'; import EqBars from './EqBars.svelte';
import IconButton from '$lib/components/ui/primitives/IconButton.svelte'; import IconButton from '../primitives/IconButton.svelte';
interface RowAction { interface RowAction {
action?: string; action?: string;
@@ -86,7 +95,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 class="size-icon-sm" /> <Hash size={16} />
{/if} {/if}
<span>Título</span> <span>Título</span>
{#each columns as column (column)} {#each columns as column (column)}
@@ -99,7 +108,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.currentId === track.id} {@const active = player.current.id === track.id}
<div <div
role="listitem" role="listitem"
@@ -129,6 +138,8 @@
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
@@ -136,7 +147,7 @@
title={track.title} title={track.title}
artist={track.artist} artist={track.artist}
ownerUserId={track.ownerUserId} ownerUserId={track.ownerUserId}
explicit={track.explicit} explicit={track.isExplicit ?? track.explicit ?? false}
{active} {active}
> >
{#snippet overlay()} {#snippet overlay()}
@@ -149,18 +160,26 @@
{#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'}
{track.listensCount} {Number(track.listensCount ?? 0)}
{:else if track.date} {:else if column === 'added' && track.addedAt}
{fmtDate(track.date)} {fmtDate(track.addedAt)}
{: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(track.duration)} {fmtTime(Number(track.duration))}
</span> </span>
{#if rowAction} {#if rowAction}
{#if rowAction.action} {#if rowAction.action}
<form method="POST" action={rowAction.action} use:enhance> <form
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" />
@@ -10,17 +10,16 @@
import GlassMenu from './GlassMenu.svelte'; import GlassMenu from './GlassMenu.svelte';
import { themeMode } from '$lib/theme/mode.svelte'; import { themeMode } from '$lib/theme/mode.svelte';
import { clickOutside } from '$lib/actions/clickOutside'; import { clickOutside } from '$lib/actions/clickOutside';
import { onEscape } from '$lib/actions/onEscape';
import Avatar from '$lib/components/ui/media/Avatar.svelte';
interface Props { interface Props {
user: SessionUser; user: SessionUser;
accountUrl?: string | null; accountUrl?: string | null;
width?: 'sm' | 'md'; width?: 'sm' | 'md';
extraItems?: Snippet<[{ close: () => void }]>; extraItems?: Snippet<[{ close: () => void }]>;
trigger: Snippet<[{ toggle: () => void; open: boolean }]>;
} }
let { user, accountUrl, width = 'sm', extraItems }: Props = $props(); let { user, accountUrl, width = 'sm', extraItems, trigger }: Props = $props();
const WIDTH: Record<'sm' | 'md', string> = { sm: 'w-52', md: 'w-56' }; const WIDTH: Record<'sm' | 'md', string> = { sm: 'w-52', md: 'w-56' };
@@ -33,23 +32,16 @@
function close() { function close() {
open = false; open = false;
} }
function onWindowKeydown(event: KeyboardEvent) {
if (event.key === 'Escape') close();
}
</script> </script>
<div class="relative" use:clickOutside={close} use:onEscape={open ? close : undefined}> <svelte:window onkeydown={onWindowKeydown} />
<button
type="button" <div class="relative" use:clickOutside={close}>
onclick={toggle} {@render trigger({ toggle, open })}
aria-label="Tu cuenta"
aria-expanded={open}
class="block shrink-0 rounded-full transition active:scale-95"
>
<Avatar
name={user.name}
src={user.picture}
size="sm"
class="border border-line-strong opacity-90"
/>
</button>
{#if open} {#if open}
<GlassMenu <GlassMenu
@@ -69,7 +61,7 @@
{/if} {/if}
<MenuItem <MenuItem
icon={themeMode.current === 'dark' ? Sun : Moon} icon={themeMode.current === 'dark' ? Sun : Moon}
label={themeMode.current === 'dark' ? 'Modo claro' : 'Modo oscuro'} label={themeMode.current === 'dark' ? 'Modo blanco' : '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>
@@ -1,46 +0,0 @@
<script lang="ts">
import ListEnd from '@lucide/svelte/icons/list-end';
import ListPlus from '@lucide/svelte/icons/list-plus';
import { player } from '$lib/player/player.svelte';
import { fetchAlbumTracks } from '$lib/data/albums';
import type { Menu } from '$lib/state/menu.svelte';
import type { Playlist } from '$lib/types';
import ContextMenu from './ContextMenu.svelte';
interface Props {
menu: Menu<string>;
playlists: Playlist[];
}
let { menu, playlists }: Props = $props();
async function playNext(albumId: string) {
menu.close();
player.playNext(await fetchAlbumTracks(albumId));
}
async function addToQueue(albumId: string) {
menu.close();
player.appendToQueue(await fetchAlbumTracks(albumId));
}
</script>
{#if menu.state}
{@const { target: albumId, x, y, openLeft } = menu.state}
<ContextMenu
{x}
{y}
{openLeft}
onClose={() => menu.close()}
items={[
{ icon: ListPlus, label: 'Reproducir a continuación', onclick: () => playNext(albumId) },
{ icon: ListEnd, label: 'Añadir a la cola', onclick: () => addToQueue(albumId) }
]}
playlistAction={{
action: '?/addAlbumToPlaylist',
fields: { albumId },
label: 'Añadir álbum a una playlist'
}}
{playlists}
/>
{/if}
@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import Dialog from './Dialog.svelte'; import Dialog from './Dialog.svelte';
import Button from '$lib/components/ui/primitives/Button.svelte'; import Button from '../primitives/Button.svelte';
interface Props { interface Props {
open: boolean; open: boolean;
@@ -9,19 +9,15 @@
description: string; description: string;
confirmLabel: string; confirmLabel: string;
action: string; action: string;
fields?: Record<string, string>;
} }
let { open, onClose, title, description, confirmLabel, action, fields = {} }: Props = $props(); let { open, onClose, title, description, confirmLabel, action }: 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}
@@ -1,10 +1,27 @@
<script lang="ts" module>
import { MENU_WIDTH, SUBMENU_WIDTH } from '$lib/config';
export interface MenuPosition {
x: number;
y: number;
openLeft: boolean;
}
export function contextMenuPosition(event: MouseEvent): MenuPosition {
event.preventDefault();
return {
x: Math.max(8, Math.min(event.clientX, window.innerWidth - MENU_WIDTH - 8)),
y: Math.max(8, Math.min(event.clientY, window.innerHeight - 60)),
openLeft: event.clientX + MENU_WIDTH + SUBMENU_WIDTH + 16 > window.innerWidth
};
}
</script>
<script lang="ts"> <script lang="ts">
import { enhance } from '$app/forms'; import { enhance } from '$app/forms';
import { onEscape } from '$lib/actions/onEscape';
import { playlistCover } from '$lib/utils/hrefs';
import type { LucideIcon } from '@lucide/svelte'; import type { LucideIcon } from '@lucide/svelte';
import ChevronRight from '@lucide/svelte/icons/chevron-right'; import ChevronRight from '@lucide/svelte/icons/chevron-right';
import MediaIdentity from '$lib/components/ui/media/MediaIdentity.svelte'; import MediaIdentity from '../media/MediaIdentity.svelte';
import GlassMenu from './GlassMenu.svelte'; import GlassMenu from './GlassMenu.svelte';
import MenuItem from './MenuItem.svelte'; import MenuItem from './MenuItem.svelte';
@@ -15,16 +32,21 @@
onClose: () => void; onClose: () => void;
items: { icon: LucideIcon; label: string; onclick: () => void }[]; items: { icon: LucideIcon; label: string; onclick: () => void }[];
playlistAction?: { action: string; fields: Record<string, string>; label: string }; playlistAction?: { action: string; fields: Record<string, string>; label: string };
playlists?: { id: string; name: string; updatedAt: string }[]; playlists?: { id: string; name: string }[];
} }
let { x, y, openLeft, onClose, items, playlistAction, playlists = [] }: Props = $props(); let { x, y, openLeft, onClose, items, playlistAction, playlists = [] }: Props = $props();
function onWindowKeydown(event: KeyboardEvent) {
if (event.key === 'Escape') onClose();
}
</script> </script>
<svelte:window onkeydown={onWindowKeydown} />
<div <div
class="fixed inset-0 z-(--z-backdrop)" class="fixed inset-0 z-(--z-backdrop)"
role="presentation" role="presentation"
use:onEscape={onClose}
onclick={onClose} onclick={onClose}
oncontextmenu={(e) => { oncontextmenu={(e) => {
e.preventDefault(); e.preventDefault();
@@ -75,7 +97,7 @@
> >
<MediaIdentity <MediaIdentity
title={playlist.name} title={playlist.name}
coverSrc={playlistCover(playlist, 'small')} coverSrc="/api/playlists/{playlist.id}/cover?size=small"
size="xs" size="xs"
titleClass="text-fg-2" titleClass="text-fg-2"
/> />
@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import Dialog from './Dialog.svelte'; import Dialog from './Dialog.svelte';
import Button from '$lib/components/ui/primitives/Button.svelte'; import Button from '../primitives/Button.svelte';
import { dialog, type DialogAction, type DialogOptions } from '$lib/state/dialog.svelte'; import { dialog, type DialogAction, type DialogOptions } from '$lib/state/dialog.svelte';
// Keep the last dialog's content so the exit transition doesn't render an empty panel. // Keep the last dialog's content so the exit transition doesn't render an empty panel.
@@ -28,17 +28,21 @@
); );
</script> </script>
<!-- svelte-ignore a11y_no_static_element_interactions --> {#if href}
<svelte:element <a
this={href ? 'a' : 'button'}
{href} {href}
{target} {target}
rel={href && target === '_blank' ? 'noopener noreferrer' : undefined} rel={target === '_blank' ? 'noopener noreferrer' : undefined}
type={href ? undefined : type} data-sveltekit-reload={reload ? '' : undefined}
data-sveltekit-reload={href && reload ? '' : undefined}
{onclick} {onclick}
class={classes} class={classes}
> >
<Icon class="size-icon-md shrink-0" /> <Icon class="size-icon-md shrink-0" />
{label} {label}
</svelte:element> </a>
{:else}
<button {type} {onclick} class={classes}>
<Icon class="size-icon-md shrink-0" />
{label}
</button>
{/if}
@@ -1,10 +1,9 @@
<script lang="ts"> <script lang="ts">
import type { Snippet } from 'svelte'; import type { Snippet } from 'svelte';
import X from '@lucide/svelte/icons/x'; import X from '@lucide/svelte/icons/x';
import IconButton from '$lib/components/ui/primitives/IconButton.svelte'; import IconButton from '../primitives/IconButton.svelte';
import { fade } from 'svelte/transition'; import { fade } from 'svelte/transition';
import { pop } from '$lib/utils/transitions'; import { pop } from '$lib/utils/transitions';
import { onEscape } from '$lib/actions/onEscape';
interface Props { interface Props {
open: boolean; open: boolean;
@@ -49,7 +48,12 @@
} }
function onWindowKeydown(event: KeyboardEvent) { function onWindowKeydown(event: KeyboardEvent) {
if (!open || event.key !== 'Tab') return; if (!open) return;
if (event.key === 'Escape') {
onClose();
return;
}
if (event.key !== 'Tab') return;
const elements = focusableElements(); const elements = focusableElements();
if (elements.length === 0) return; if (elements.length === 0) return;
const first = elements[0]; const first = elements[0];
@@ -75,7 +79,6 @@
> >
<div <div
bind:this={panel} bind:this={panel}
use:onEscape={onClose}
transition:pop={{ duration: 200 }} transition:pop={{ duration: 200 }}
class="glass-panel max-h-[85dvh] w-full glow-drift overflow-y-auto {maxWidth} rounded-panel p-5 outline-none sm:p-6 {panelClass}" class="glass-panel max-h-[85dvh] w-full glow-drift overflow-y-auto {maxWidth} rounded-panel p-5 outline-none sm:p-6 {panelClass}"
role="dialog" role="dialog"
@@ -96,7 +99,7 @@
</h2> </h2>
</div> </div>
<IconButton label="Cerrar" onclick={onClose} class="shrink-0"> <IconButton label="Cerrar" onclick={onClose} class="shrink-0">
<X class="size-icon-sm" /> <X class="size-4" />
</IconButton> </IconButton>
</div> </div>
{/if} {/if}
@@ -1,36 +0,0 @@
<script lang="ts">
import ListPlus from '@lucide/svelte/icons/list-plus';
import { player } from '$lib/player/player.svelte';
import type { Menu } from '$lib/state/menu.svelte';
import type { Playlist, Track } from '$lib/types';
import ContextMenu from './ContextMenu.svelte';
interface Props {
menu: Menu<Track>;
playlists: Playlist[];
}
let { menu, playlists }: Props = $props();
function playNext(track: Track) {
player.playNext([track]);
menu.close();
}
</script>
{#if menu.state}
{@const { target: track, x, y, openLeft } = menu.state}
<ContextMenu
{x}
{y}
{openLeft}
onClose={() => menu.close()}
items={[{ icon: ListPlus, label: 'Reproducir a continuación', onclick: () => playNext(track) }]}
playlistAction={{
action: '?/addTrack',
fields: { trackId: track.id },
label: 'Añadir a una playlist'
}}
{playlists}
/>
{/if}
@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import ArrowLeft from '@lucide/svelte/icons/arrow-left'; import ArrowLeft from '@lucide/svelte/icons/arrow-left';
import { previousPage } from '$lib/state/history.svelte'; import { previousPage } from '$lib/state/navigation.svelte';
interface Props { interface Props {
href: string; href: string;
@@ -28,13 +28,13 @@
children children
}: Props = $props(); }: Props = $props();
const SIZE = { const sizes = {
sm: 'rounded-control px-4 py-2 text-xs', sm: 'rounded-control px-4 py-2 text-xs',
md: 'rounded-btn-sm px-4.5 py-2.5 text-control-sm', md: 'rounded-btn-sm px-4.5 py-2.5 text-control-sm',
lg: 'rounded-btn px-6 py-3.5 text-sm' lg: 'rounded-btn px-6 py-3.5 text-sm'
}; };
const VARIANT = { const variants = {
primary: 'bg-cta-strong text-ink hover:bg-cta-strong-hover', primary: 'bg-cta-strong text-ink hover:bg-cta-strong-hover',
secondary: 'bg-btn text-fg hover:bg-btn-hover', secondary: 'bg-btn text-fg hover:bg-btn-hover',
accent: 'surface-active text-accent-soft hover:surface-active-hover', accent: 'surface-active text-accent-soft hover:surface-active-hover',
@@ -42,22 +42,19 @@
}; };
const classes = $derived( const classes = $derived(
`inline-flex items-center justify-center gap-2 font-medium btn-transition focus-ring focus-visible:ring-offset-2 focus-visible:ring-offset-bg disabled:opacity-40 ${SIZE[size]} ${VARIANT[variant]} ${klass}` `inline-flex items-center justify-center gap-2 font-medium btn-transition focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-bg focus-visible:outline-none disabled:opacity-40 ${sizes[size]} ${variants[variant]} ${klass}`
); );
</script> </script>
<!-- svelte-ignore a11y_no_static_element_interactions --> {#if href}
<svelte:element <a {href} data-sveltekit-reload={reload ? '' : undefined} class={classes}>
this={href ? 'a' : 'button'} {@render children()}
{href} </a>
type={href ? undefined : type} {:else}
disabled={href ? undefined : disabled || loading} <button {type} disabled={disabled || loading} {onclick} class={classes}>
data-sveltekit-reload={href && reload ? '' : undefined} {#if loading}
{onclick} <LoaderCircle class="size-4 animate-spin" />
class={classes}
>
{#if loading && !href}
<LoaderCircle class="size-icon-sm animate-spin" />
{/if} {/if}
{@render children()} {@render children()}
</svelte:element> </button>
{/if}
@@ -21,7 +21,7 @@
class="peer size-5 cursor-pointer appearance-none rounded-tag border border-line bg-surface transition checked:border-accent-soft checked:bg-accent-soft focus-visible:border-accent/60 focus-visible:outline-none" class="peer size-5 cursor-pointer appearance-none rounded-tag border border-line bg-surface transition checked:border-accent-soft checked:bg-accent-soft focus-visible:border-accent/60 focus-visible:outline-none"
/> />
<Check <Check
class="pointer-events-none absolute size-icon-xs 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"
/> />
</span> </span>
<span class="cursor-pointer">{@render children()}</span> <span class="cursor-pointer">{@render children()}</span>
@@ -14,24 +14,22 @@
const classes = $derived( const classes = $derived(
href href
? 'focus-ring rounded-full border border-line-strong px-3.5 py-2 text-xs text-fg-2 transition-colors hover:bg-hover hover:text-fg' ? 'rounded-full border border-line-strong px-3.5 py-2 text-xs text-fg-2 transition-colors hover:bg-hover hover:text-fg'
: `focus-ring flex items-center gap-2 rounded-full px-3.5 py-2 text-xs font-medium transition ${ : `flex items-center gap-2 rounded-full px-3.5 py-2 text-xs font-medium transition ${
selected ? 'bg-cta-strong text-ink' : 'bg-btn text-fg-2 hover:bg-btn-hover hover:text-fg' selected ? 'bg-cta-strong text-ink' : 'bg-btn text-fg-2 hover:bg-btn-hover hover:text-fg'
} ${disabled ? 'cursor-not-allowed opacity-40' : ''}` } ${disabled ? 'cursor-not-allowed opacity-40' : ''}`
); );
</script> </script>
<!-- svelte-ignore a11y_no_static_element_interactions --> {#snippet content()}
<svelte:element
this={href ? 'a' : 'button'}
{href}
type={href ? undefined : 'button'}
disabled={href ? undefined : disabled}
onclick={href ? undefined : onclick}
class={classes}
>
{@render children()} {@render children()}
{#if count !== undefined} {#if count !== undefined}
<span class="tabular-nums {selected ? 'opacity-55' : 'text-fg-2'}">{count}</span> <span class="tabular-nums {selected ? 'opacity-55' : 'text-fg-2'}">{count}</span>
{/if} {/if}
</svelte:element> {/snippet}
{#if href}
<a {href} class={classes}>{@render content()}</a>
{:else}
<button type="button" {onclick} {disabled} class={classes}>{@render content()}</button>
{/if}
@@ -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';
plain?: boolean; tone?: 'muted' | 'subtle' | 'plain';
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',
plain = false, tone = 'muted',
pressed, pressed,
surface = false, surface = false,
revealOnHover = false, revealOnHover = false,
@@ -40,28 +40,35 @@
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' : plain ? '' : 'hover:bg-hover' surface ? 'bg-surface-2 hover:bg-surface-hover' : tone === 'plain' ? '' : 'hover:bg-hover'
); );
const classes = $derived( const classes = $derived(
`grid place-items-center transition focus-ring ${SIZE[size]} ${shape === 'round' ? 'rounded-full' : 'rounded-control'} ${pressed ? 'text-accent' : 'text-fg-2 hover:text-fg'} ${background} ${ `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} ${
revealOnHover ? 'sm:opacity-0 sm:group-hover:opacity-100 sm:focus-visible:opacity-100' : '' revealOnHover ? 'sm:opacity-0 sm:group-hover:opacity-100 sm:focus-visible:opacity-100' : ''
} ${klass}` } ${klass}`
); );
</script> </script>
<!-- svelte-ignore a11y_no_static_element_interactions --> {#if href}
<svelte:element <a
this={href ? 'a' : 'button'}
{href} {href}
type={href ? undefined : type} data-sveltekit-reload={reload ? '' : undefined}
data-sveltekit-reload={href && reload ? '' : undefined}
aria-label={label} aria-label={label}
aria-pressed={href ? undefined : pressed}
title={label} title={label}
{onclick}
class={classes} class={classes}
> >
{@render children()} {@render children()}
</svelte:element> </a>
{:else}
<button {type} {onclick} aria-label={label} title={label} aria-pressed={pressed} class={classes}>
{@render children()}
</button>
{/if}
@@ -1,23 +1,15 @@
<script lang="ts"> <script lang="ts">
import LoaderCircle from '@lucide/svelte/icons/loader-circle'; import LoaderCircle from '@lucide/svelte/icons/loader-circle';
import Button from './Button.svelte';
import { INFINITE_SCROLL_ROOT_MARGIN } from '$lib/config'; import { INFINITE_SCROLL_ROOT_MARGIN } from '$lib/config';
interface Props { interface Props {
onLoadMore: () => void; onLoadMore: () => void;
hasMore: boolean; hasMore: boolean;
loading: boolean; loading: boolean;
error?: boolean;
rootMargin?: string; rootMargin?: string;
} }
let { let { onLoadMore, hasMore, loading, rootMargin = INFINITE_SCROLL_ROOT_MARGIN }: Props = $props();
onLoadMore,
hasMore,
loading,
error = false,
rootMargin = INFINITE_SCROLL_ROOT_MARGIN
}: Props = $props();
let sentinel = $state<HTMLDivElement>(); let sentinel = $state<HTMLDivElement>();
let visible = $state(false); let visible = $state(false);
@@ -36,17 +28,12 @@
}); });
$effect(() => { $effect(() => {
if (visible && hasMore && !loading && !error) onLoadMore(); if (visible && hasMore && !loading) onLoadMore();
}); });
</script> </script>
<div bind:this={sentinel} class="flex min-h-12 items-center justify-center py-8"> <div bind:this={sentinel} class="flex min-h-12 items-center justify-center py-8">
{#if loading} {#if loading}
<LoaderCircle class="size-icon-lg animate-spin text-fg-2" /> <LoaderCircle class="size-5 animate-spin text-fg-2" />
{:else if error}
<div class="flex items-center gap-3 text-sm text-fg-2">
No se pudo cargar más.
<Button variant="secondary" size="sm" onclick={onLoadMore}>Reintentar</Button>
</div>
{/if} {/if}
</div> </div>
@@ -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'}" : 'text-fg-2 hover:text-fg-2'}"
> >
{option.label} {option.label}
</button> </button>
+3 -5
View File
@@ -1,21 +1,19 @@
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 PLAYLIST_TRACKS_PAGE_SIZE = 200; export const ALBUM_EARLIEST_YEAR = 1877;
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 PROFILE_PLAYLISTS_PAGE_SIZE = 50;
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;
+5 -9
View File
@@ -1,12 +1,8 @@
import type { Paged, Track } from '$lib/types'; import { toQueueItems, type ApiTrackLike, type QueueItem } from '$lib/player/player.svelte';
async function fetchTracks(url: string): Promise<Track[]> { export async function fetchAlbumQueueItems(albumId: string): Promise<QueueItem[]> {
const res = await fetch(url); const res = await fetch(`/api/albums/${albumId}/tracks`);
if (!res.ok) return []; if (!res.ok) return [];
return ((await res.json()) as Paged<Track>).items; const data = (await res.json()) as { items: ApiTrackLike[] };
return toQueueItems(data.items ?? []);
} }
export const fetchAlbumTracks = (albumId: string) => fetchTracks(`/api/albums/${albumId}/tracks`);
export const fetchPlaylistTracks = (playlistId: string) =>
fetchTracks(`/api/playlists/${playlistId}/tracks`);
-28
View File
@@ -1,28 +0,0 @@
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
@@ -0,0 +1,19 @@
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
};
}
+15 -8
View File
@@ -1,14 +1,21 @@
import { player } from '$lib/player/player.svelte'; import { player, toQueueItems, type ApiTrackLike, type QueueItem } from '$lib/player/player.svelte';
import type { Track } from '$lib/types';
export function mergeRecentlyPlayed(history: Track[], limit: number): Track[] { export function mergeRecentlyPlayed(history: ApiTrackLike[], limit: number): QueueItem[] {
const seen = new Set<string>(); const seen = new Set<string>();
const merged: Track[] = []; const merged: QueueItem[] = [];
for (const track of [...player.recentlyPlayed, ...history]) { for (const t of player.recentlyPlayed) {
if (seen.has(track.id)) continue; const id = String(t.id);
seen.add(track.id); if (seen.has(id)) continue;
merged.push(track); seen.add(id);
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);
-42
View File
@@ -1,42 +0,0 @@
import { describe, expect, it } from 'vitest';
import { findTopResult, searchChips } 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();
});
});
describe('searchChips', () => {
it('adds an "all" chip with the total', () => {
const chips = searchChips({ tracks: 3, albums: 2, playlists: 0, users: 1 });
expect(chips.map((chip) => [chip.filter, chip.count])).toEqual([
['all', 6],
['tracks', 3],
['albums', 2],
['playlists', 0],
['users', 1]
]);
expect(chips[0].label).toBe('Todo');
});
});
+22 -24
View File
@@ -1,40 +1,38 @@
export const SEARCH_KINDS = ['tracks', 'albums', 'playlists', 'users'] as const; export const SEARCH_FILTERS = ['Todo', 'Canciones', 'Álbumes', 'Playlists', 'Usuarios'] as const;
export type SearchKind = (typeof SEARCH_KINDS)[number]; export type SearchFilter = (typeof SEARCH_FILTERS)[number];
export type SearchFilter = 'all' | SearchKind;
export const SEARCH_GROUP_PREVIEW = 4; export const SEARCH_GROUP_PREVIEW = 4;
export const SEARCH_LABELS: Record<SearchFilter, string> = { export function showGroup(filter: SearchFilter, label: Exclude<SearchFilter, 'Todo'>) {
all: 'Todo', return filter === 'Todo' || filter === label;
tracks: 'Canciones',
albums: 'Álbumes',
playlists: 'Playlists',
users: 'Usuarios'
};
export function showGroup(filter: SearchFilter, kind: SearchKind) {
return filter === 'all' || filter === kind;
} }
export function capped<T>(filter: SearchFilter, list: T[]) { export function capped<T>(filter: SearchFilter, list: T[]) {
return filter === 'all' ? list.slice(0, SEARCH_GROUP_PREVIEW) : list; return filter === 'Todo' ? list.slice(0, SEARCH_GROUP_PREVIEW) : list;
} }
export function matchPlaylists<T extends { name: string }>(playlists: T[], query: string): T[] { export function matchPlaylists<T extends { name: string }>(playlists: T[], query: string): T[] {
return query ? playlists.filter((p) => p.name.toLowerCase().includes(query.toLowerCase())) : []; return query ? playlists.filter((p) => p.name.toLowerCase().includes(query.toLowerCase())) : [];
} }
export type SearchCounts = Record<SearchKind, number>; export function searchCounts(counts: {
tracks: number;
export function searchTotal(counts: SearchCounts): number { albums: number;
return SEARCH_KINDS.reduce((sum, kind) => sum + counts[kind], 0); playlists: number;
users: number;
}) {
return {
Canciones: counts.tracks,
Álbumes: counts.albums,
Playlists: counts.playlists,
Usuarios: counts.users
};
} }
export function searchChips(counts: SearchCounts) { export function searchChips(counts: ReturnType<typeof searchCounts>) {
const filters: SearchFilter[] = ['all', ...SEARCH_KINDS]; const total = counts.Canciones + counts.Álbumes + counts.Playlists + counts.Usuarios;
return filters.map((filter) => ({ return SEARCH_FILTERS.map((label) => ({
filter, label,
label: SEARCH_LABELS[filter], count: label === 'Todo' ? total : counts[label]
count: filter === 'all' ? searchTotal(counts) : counts[filter]
})); }));
} }
-18
View File
@@ -1,18 +0,0 @@
import { shuffle } from '$lib/utils/collections';
import type { Track } from '$lib/types';
import { player } from './player.svelte';
export function isQueueCurrent(items: { id: string }[]): boolean {
return items.some((item) => item.id === player.currentId);
}
export function playAllOrToggle(items: Track[]) {
if (items.length === 0) return;
if (isQueueCurrent(items)) player.toggle();
else player.playQueue(items, 0);
}
export function playShuffled(items: Track[]) {
if (items.length === 0) return;
player.playQueue(shuffle(items), 0);
}
+41 -12
View File
@@ -1,4 +1,24 @@
import type { LikedTrack, Track } from '$lib/types'; import { toQueueItems, type ApiTrackLike } from './player.svelte';
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>>({});
@@ -7,23 +27,35 @@ 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: LikedTrack[]) { hydrate(tracks: (ApiTrackLike & { createdAt: string; duration: number | string })[]) {
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): boolean { isLiked(id: string | number): boolean {
return id in this.entries; return String(id) in this.entries;
} }
async toggle(track: Track) { async toggle(track: LikeToggleInput) {
const id = track.id; const id = String(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, likedAt: Date.now() }; else next[id] = { ...track, id, likedAt: Date.now() };
this.entries = next; this.entries = next;
try { try {
@@ -34,10 +66,7 @@ class LikedStore {
}); });
if (!res.ok) throw new Error('Failed to toggle like'); if (!res.ok) throw new Error('Failed to toggle like');
} catch { } catch {
const reverted = { ...this.entries }; this.entries = before;
if (wasLiked) reverted[id] = before[id];
else delete reverted[id];
this.entries = reverted;
} }
} }
} }
-36
View File
@@ -1,36 +0,0 @@
import { browser } from '$app/environment';
import type { Track } from '$lib/types';
import { trackCover } from '$lib/utils/hrefs';
interface MediaSessionHandlers {
play: () => void;
pause: () => void;
previous: () => void;
next: () => void;
}
const supported = () => browser && 'mediaSession' in navigator;
export const mediaSession = {
setup(handlers: MediaSessionHandlers) {
if (!supported()) return;
navigator.mediaSession.setActionHandler('play', handlers.play);
navigator.mediaSession.setActionHandler('pause', handlers.pause);
navigator.mediaSession.setActionHandler('previoustrack', handlers.previous);
navigator.mediaSession.setActionHandler('nexttrack', handlers.next);
},
setPlaying(playing: boolean) {
if (!supported()) return;
navigator.mediaSession.playbackState = playing ? 'playing' : 'paused';
},
setTrack(track: Track) {
if (!supported()) return;
navigator.mediaSession.metadata = new MediaMetadata({
title: track.title,
artist: track.artist ?? '',
artwork: [{ src: trackCover(track.id, 'small'), sizes: '256x256' }]
});
}
};
+310 -93
View File
@@ -1,56 +1,149 @@
import { browser } from '$app/environment'; import { browser } from '$app/environment';
import { accent } from '$lib/theme/accent.svelte'; import { DEFAULT_ACCENT } from '$lib/theme/color';
import { extractAccent, type Accent } from '$lib/theme/palette';
import { shuffle } from '$lib/data/collections';
import { dialog } from '$lib/state/dialog.svelte'; import { dialog } from '$lib/state/dialog.svelte';
import type { Track } from '$lib/types';
import { readStorage, writeStorage } from '$lib/utils/storage';
import { trackCover } from '$lib/utils/hrefs';
import { ListenTracker } from './listenTracker'; import { ListenTracker } from './listenTracker';
import { mediaSession } from './mediaSession';
import { ProgressClock } from './progressClock';
import { append, insertNext, isLastIndex, move, nextIndex, previousIndex } from './queue';
import { resolveStream } from './stream';
const RECENT_LIMIT = 15; export interface PlayerTrack {
const DEFAULT_VOLUME = 100; id: string | number;
const VOLUME_KEY = 'musify.player.volume'; title: string;
const MUTED_KEY = 'musify.player.muted'; artist: string;
duration: number;
function clampVolume(value: number): number { explicit?: boolean;
return Math.min(100, Math.max(0, Math.round(value))); ownerUserId?: string | number | null;
listensCount?: number | string;
} }
function loadVolume(): number { export interface Playlist {
const stored = readStorage(VOLUME_KEY); id: string | number;
if (stored === null || stored.trim() === '') return DEFAULT_VOLUME; name: string;
const parsed = Number(stored); trackIds: (string | number)[];
return Number.isFinite(parsed) ? clampVolume(parsed) : DEFAULT_VOLUME; }
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 (isQueueCurrent(items)) player.toggle();
else player.playQueue(items, 0);
}
export function playShuffled(items: QueueItem[]) {
if (items.length === 0) return;
player.playQueue(shuffle(items), 0);
}
async function readErrorMessage(res: Response): Promise<string> {
try {
const body = (await res.json()) as { message?: string };
return body?.message || `Error ${res.status}`;
} catch {
return `Error ${res.status}`;
}
}
const RECENT_LIMIT = 15;
const EMPTY: PlayerTrack = {
id: '',
title: '',
artist: '',
duration: 0
};
function toTrack(item: QueueItem): PlayerTrack {
return {
id: item.id,
title: item.title,
artist: item.artist ?? '',
duration: Number(item.duration) || 0,
explicit: item.explicit,
ownerUserId: item.ownerUserId,
listensCount: item.listensCount
};
} }
class PlayerState { class PlayerState {
tracks = $state<Track[]>([]); tracks = $state<PlayerTrack[]>([]);
currentId = $state<string | null>(null); currentId = $state<string | number | null>(null);
playing = $state(false); playing = $state(false);
progress = $state(0); progress = $state(0);
volume = $state(browser ? loadVolume() : DEFAULT_VOLUME); volume = $state(browser ? Number(localStorage.getItem('player.volume') ?? 100) : 100);
muted = $state(browser ? readStorage(MUTED_KEY) === 'true' : false); muted = $state(browser ? localStorage.getItem('player.muted') === 'true' : false);
loading = $state(false); loading = $state(false);
recentlyPlayed = $state<Track[]>([]); recentlyPlayed = $state<PlayerTrack[]>([]);
playlists = $state<Playlist[]>([]);
shuffle = $state(false); shuffle = $state(false);
repeat = $state(false); repeat = $state(false);
accentColor = $state<string | null>(null);
#audio: HTMLAudioElement | null = null; #audio: HTMLAudioElement | null = null;
#loadToken = 0; #loadToken = 0;
#accentCache = new Map<string, Accent>();
#rafId: number | null = null;
#listen = new ListenTracker(); #listen = new ListenTracker();
#clock = new ProgressClock(
() => this.#audio, current = $derived(this.tracks.find((t) => t.id === this.currentId) ?? EMPTY);
(value) => (this.progress = value) accent = $derived(this.accentColor ?? DEFAULT_ACCENT);
progressPercent = $derived(
this.current.duration > 0 ? Math.min(100, (this.progress / this.current.duration) * 100) : 0
); );
current = $derived(this.tracks.find((t) => t.id === this.currentId) ?? null); get isPlaying() {
duration = $derived(this.current?.duration ?? 0); return this.playing;
progressPercent = $derived( }
this.duration > 0 ? Math.min(100, (this.progress / this.duration) * 100) : 0
);
#audioEl(): HTMLAudioElement | null { #audioEl(): HTMLAudioElement | null {
if (!browser) return null; if (!browser) return null;
@@ -69,18 +162,18 @@ class PlayerState {
audio.addEventListener('play', () => { audio.addEventListener('play', () => {
this.#listen.resync(audio.currentTime); this.#listen.resync(audio.currentTime);
this.playing = true; this.playing = true;
this.#clock.start(); this.#startProgressLoop();
mediaSession.setPlaying(true); this.#syncMediaSessionPlaybackState();
}); });
audio.addEventListener('pause', () => { audio.addEventListener('pause', () => {
this.#listen.interrupt(); this.#listen.interrupt();
this.#listen.flush(); this.#listen.flush();
this.playing = false; this.playing = false;
this.#clock.stop(); this.#stopProgressLoop();
mediaSession.setPlaying(false); this.#syncMediaSessionPlaybackState();
}); });
audio.addEventListener('ended', () => { audio.addEventListener('ended', () => {
this.#clock.stop(); this.#stopProgressLoop();
this.#listen.tick(audio.currentTime); this.#listen.tick(audio.currentTime);
this.#listen.begin(null); this.#listen.begin(null);
if (this.repeat) { if (this.repeat) {
@@ -89,14 +182,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', () => {
this.#clock.stop(); this.#stopProgressLoop();
this.loading = false; this.loading = false;
this.playing = false; this.playing = false;
this.#fail(); this.#fail();
@@ -106,20 +195,61 @@ class PlayerState {
if (document.visibilityState === 'hidden') this.#listen.flush(); if (document.visibilityState === 'hidden') this.#listen.flush();
}); });
window.addEventListener('pagehide', () => this.#listen.flush()); window.addEventListener('pagehide', () => this.#listen.flush());
mediaSession.setup({ this.#setupMediaSession(audio);
play: () => audio.play().catch(() => {}),
pause: () => audio.pause(),
previous: () => this.previous(),
next: () => this.next()
});
return audio; return audio;
} }
#stopAtEnd(audio: HTMLAudioElement) { #setupMediaSession(audio: HTMLAudioElement) {
audio.currentTime = 0; if (!('mediaSession' in navigator)) return;
this.progress = 0; navigator.mediaSession.setActionHandler('play', () => audio.play().catch(() => {}));
this.playing = false; navigator.mediaSession.setActionHandler('pause', () => audio.pause());
mediaSession.setPlaying(false); navigator.mediaSession.setActionHandler('previoustrack', () => this.previous());
navigator.mediaSession.setActionHandler('nexttrack', () => this.next());
}
#syncMediaSessionPlaybackState() {
if (!browser || !('mediaSession' in navigator)) return;
navigator.mediaSession.playbackState = this.playing ? 'playing' : 'paused';
}
#syncMediaSessionMetadata(track: PlayerTrack) {
if (!browser || !('mediaSession' in navigator)) return;
if (!track.id) {
navigator.mediaSession.metadata = null;
return;
}
navigator.mediaSession.metadata = new MediaMetadata({
title: track.title,
artist: track.artist,
artwork: [{ src: `/api/tracks/${track.id}/cover?size=small`, sizes: '256x256' }]
});
}
#clockTime = 0;
#clockStamp = 0;
#tickProgress = (now: number) => {
const audio = this.#audio;
if (!audio) return;
if (audio.currentTime !== this.#clockTime) {
this.#clockTime = audio.currentTime;
this.#clockStamp = now;
}
const elapsed = Math.min(0.25, (now - this.#clockStamp) / 1000);
const smooth = this.#clockTime + (audio.paused ? 0 : elapsed * audio.playbackRate);
this.progress = Math.min(smooth, Number.isFinite(audio.duration) ? audio.duration : smooth);
this.#rafId = requestAnimationFrame(this.#tickProgress);
};
#startProgressLoop() {
if (this.#rafId !== null) return;
this.#rafId = requestAnimationFrame(this.#tickProgress);
}
#stopProgressLoop() {
if (this.#rafId === null) return;
cancelAnimationFrame(this.#rafId);
this.#rafId = null;
} }
#syncDuration(value: number) { #syncDuration(value: number) {
@@ -132,16 +262,29 @@ class PlayerState {
return this.tracks.findIndex((t) => t.id === this.currentId); return this.tracks.findIndex((t) => t.id === this.currentId);
} }
#pushRecent(track: Track) { #pushRecent(track: PlayerTrack) {
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
); );
} }
#announce(track: Track) { async #applyAccent(id: string | number) {
accent.follow(trackCover(track.id, 'small'), () => this.currentId === track.id); const key = String(id);
mediaSession.setTrack(track); const cached = this.#accentCache.get(key);
if (cached) {
this.accentColor = cached.accent;
return;
}
const result = await extractAccent(`/api/tracks/${id}/cover?size=small`);
if (this.currentId !== id) return;
if (result) {
this.#accentCache.set(key, result);
this.accentColor = result.accent;
} else {
this.accentColor = null;
}
} }
async #loadCurrent() { async #loadCurrent() {
@@ -149,27 +292,32 @@ class PlayerState {
if (!audio || this.currentId === null) return; if (!audio || this.currentId === null) return;
const id = this.currentId; const id = this.currentId;
const track = this.tracks.find((t) => t.id === id); const track = this.tracks.find((t) => t.id === id);
if (track) this.#announce(track); this.#applyAccent(id);
if (track) this.#syncMediaSessionMetadata(track);
this.#listen.begin(null); this.#listen.begin(null);
const token = ++this.#loadToken; const token = ++this.#loadToken;
this.loading = true; this.loading = true;
this.progress = 0; this.progress = 0;
try { try {
const { src, listenId } = await resolveStream(id); const { src, listenId } = await this.#resolveLocalSrc(String(id));
if (token !== this.#loadToken) return; if (token !== this.#loadToken) return;
audio.src = src; audio.src = src;
audio.volume = this.volume / 100; audio.volume = this.volume / 100;
this.#listen.begin(listenId); this.#listen.begin(listenId);
await audio.play(); await audio.play();
this.loading = false; this.loading = false;
if (track) this.#pushRecent(track); this.#pushRecent(this.current);
} catch (err) { } catch (exception) {
console.error('playback failed', err); console.error('playback failed', exception);
if (token !== this.#loadToken) return; if (token !== this.#loadToken) return;
this.loading = false; this.loading = false;
this.playing = false; this.playing = false;
if (err instanceof DOMException && err.name === 'AbortError') return; if (exception instanceof DOMException && exception.name === 'AbortError') return;
this.#fail(err instanceof Error && !(err instanceof DOMException) ? err.message : undefined); this.#fail(
exception instanceof Error && !(exception instanceof DOMException)
? exception.message
: undefined
);
} }
} }
@@ -181,54 +329,109 @@ class PlayerState {
); );
} }
async #resolveLocalSrc(id: string): Promise<{ src: string; listenId: string | null }> {
const res = await fetch(`/api/tracks/${id}/stream`);
if (!res.ok) throw new Error(await readErrorMessage(res));
const { manifestUrl, ticket, listenId } = (await res.json()) as {
manifestUrl: string;
ticket: string;
listenId?: string | null;
};
return { src: this.#withTicket(manifestUrl, ticket), listenId: listenId ?? null };
}
async #beginRepeatListen() { async #beginRepeatListen() {
const id = this.currentId; const id = this.currentId;
if (id === null) return; if (id === null) return;
try { try {
const { listenId } = await resolveStream(id); const res = await fetch(`/api/tracks/${id}/stream`);
if (this.currentId === id) this.#listen.begin(listenId); if (!res.ok) return;
const { listenId } = (await res.json()) as { listenId?: string | null };
if (this.currentId === id) this.#listen.begin(listenId ?? null);
} catch { } catch {
return; return;
} }
} }
hydrate(track: Track) { #withTicket(url: string, ticket: string): string {
if (this.currentId !== null || this.tracks.length > 0) return; if (!ticket) return url;
this.tracks = [track]; const sep = url.includes('?') ? '&' : '?';
this.currentId = track.id; return `${url}${sep}t=${encodeURIComponent(ticket)}`;
this.#announce(track);
} }
playQueue(list: Track[], startIndex = 0) { hydrate(item: ApiTrackLike) {
if (this.currentId !== null || this.tracks.length > 0) return;
const track = toTrack(toQueueItem(item));
this.tracks = [track];
this.currentId = track.id;
this.#applyAccent(track.id);
this.#syncMediaSessionMetadata(track);
}
playlistTracks(ids: (string | number)[]): PlayerTrack[] {
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]; this.tracks = list.map(toTrack);
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: Track[], index: number) { playOrToggle(list: QueueItem[], 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);
} }
playNext(items: Track[]) { playNextItem(item: QueueItem) {
if (items.length === 0) return; this.playNext([item]);
if (this.currentId === null) {
this.playQueue(items, 0);
return;
}
this.tracks = insertNext(this.tracks, this.#index(), items);
} }
appendToQueue(items: Track[]) { 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;
} }
this.tracks = append(this.tracks, items); const tracks = [...this.tracks];
tracks.splice(this.#index() + 1, 0, ...items.map(toTrack));
this.tracks = tracks;
}
appendToQueue(items: QueueItem[]) {
if (items.length === 0) return;
if (this.currentId === null) {
this.playQueue(items, 0);
return;
}
this.tracks = [...this.tracks, ...items.map(toTrack)];
}
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() {
@@ -251,13 +454,19 @@ class PlayerState {
this.muted = value; this.muted = value;
const audio = this.#audioEl(); const audio = this.#audioEl();
if (audio) audio.muted = value; if (audio) audio.muted = value;
if (browser) writeStorage(MUTED_KEY, String(value)); if (browser) localStorage.setItem('player.muted', String(value));
} }
next() { next() {
if (this.tracks.length === 0) return; if (this.tracks.length === 0) return;
const idx = this.#index(); const idx = this.#index();
this.currentId = this.tracks[nextIndex(this.tracks.length, idx, this.shuffle)].id; let nextIdx = (idx + 1) % this.tracks.length;
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();
} }
@@ -281,11 +490,17 @@ class PlayerState {
} }
moveQueueItem(from: number, insertAt: number) { moveQueueItem(from: number, insertAt: number) {
this.tracks = move(this.tracks, from, insertAt); if (from < 0 || from >= this.tracks.length) return;
const target = insertAt > from ? insertAt - 1 : insertAt;
if (target === from) return;
const tracks = [...this.tracks];
const [item] = tracks.splice(from, 1);
tracks.splice(target, 0, item);
this.tracks = tracks;
} }
clearUpcoming() { clearUpcoming() {
this.tracks = this.current ? [this.current] : []; this.tracks = this.currentId === null ? [] : [this.current];
} }
previous() { previous() {
@@ -296,24 +511,26 @@ class PlayerState {
return; return;
} }
const idx = this.#index(); const idx = this.#index();
this.currentId = this.tracks[previousIndex(this.tracks.length, idx)].id; this.currentId = this.tracks[(idx - 1 + this.tracks.length) % this.tracks.length].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.duration; const target = clamped * this.current.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 = Number.isFinite(value) ? clampVolume(value) : this.volume; this.volume = Math.min(100, Math.max(0, Math.round(value)));
if (this.muted && this.volume > 0) this.#applyMuted(false); if (this.muted && this.volume > 0) this.#applyMuted(false);
const audio = this.#audioEl(); const audio = this.#audioEl();
if (audio) audio.volume = this.volume / 100; if (audio) {
if (browser) writeStorage(VOLUME_KEY, String(this.volume)); audio.volume = this.volume / 100;
localStorage.setItem('player.volume', String(this.volume));
}
} }
} }
@@ -1,36 +0,0 @@
const MAX_INTERPOLATION_SECONDS = 0.25;
export class ProgressClock {
#rafId: number | null = null;
#time = 0;
#stamp = 0;
constructor(
private getAudio: () => HTMLAudioElement | null,
private onProgress: (seconds: number) => void
) {}
#tick = (now: number) => {
const audio = this.getAudio();
if (!audio) return;
if (audio.currentTime !== this.#time) {
this.#time = audio.currentTime;
this.#stamp = now;
}
const elapsed = Math.min(MAX_INTERPOLATION_SECONDS, (now - this.#stamp) / 1000);
const smooth = this.#time + (audio.paused ? 0 : elapsed * audio.playbackRate);
this.onProgress(Math.min(smooth, Number.isFinite(audio.duration) ? audio.duration : smooth));
this.#rafId = requestAnimationFrame(this.#tick);
};
start() {
if (this.#rafId !== null) return;
this.#rafId = requestAnimationFrame(this.#tick);
}
stop() {
if (this.#rafId === null) return;
cancelAnimationFrame(this.#rafId);
this.#rafId = null;
}
}
-85
View File
@@ -1,85 +0,0 @@
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 }[]) => 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
@@ -1,60 +0,0 @@
interface Identified {
id: string;
}
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;
}
@@ -0,0 +1,3 @@
import { createToggle } from '../state/toggle.svelte';
export const queuePanel = createToggle();
-25
View File
@@ -1,25 +0,0 @@
async function readErrorMessage(res: Response): Promise<string> {
try {
const body = (await res.json()) as { message?: string };
return body?.message || `Error ${res.status}`;
} catch {
return `Error ${res.status}`;
}
}
function withTicket(url: string, ticket: string): string {
if (!ticket) return url;
const separator = url.includes('?') ? '&' : '?';
return `${url}${separator}t=${encodeURIComponent(ticket)}`;
}
export async function resolveStream(id: string): Promise<{ src: string; listenId: string | null }> {
const res = await fetch(`/api/tracks/${id}/stream`);
if (!res.ok) throw new Error(await readErrorMessage(res));
const { manifestUrl, ticket, listenId } = (await res.json()) as {
manifestUrl: string;
ticket: string;
listenId?: string | null;
};
return { src: withTicket(manifestUrl, ticket), listenId: listenId ?? null };
}
@@ -1,5 +1,4 @@
import { formString } from '$lib/server/api'; import { ALBUM_EARLIEST_YEAR } from '$lib/config';
import { ALBUM_EARLIEST_YEAR, LIMITS } from '$lib/validation';
interface AlbumFormBody { interface AlbumFormBody {
title: string; title: string;
@@ -8,19 +7,17 @@ interface AlbumFormBody {
} }
export function parseAlbumForm(form: FormData): { body: AlbumFormBody } | { failMessage: string } { export function parseAlbumForm(form: FormData): { body: AlbumFormBody } | { failMessage: string } {
const title = formString(form, 'title').trim(); const title = String(form.get('title') ?? '').trim();
if (title === '' || title.length > LIMITS.albumTitle) { if (title === '' || title.length > 200) {
return { failMessage: `El título es obligatorio (máx. ${LIMITS.albumTitle} caracteres).` }; return { failMessage: 'El título es obligatorio (máx. 200 caracteres).' };
} }
const description = formString(form, 'description').trim(); const description = String(form.get('description') ?? '').trim();
if (description.length > LIMITS.albumDescription) { if (description.length > 256) {
return { return { failMessage: 'La descripción no puede pasar de 256 caracteres.' };
failMessage: `La descripción no puede pasar de ${LIMITS.albumDescription} caracteres.`
};
} }
const rawYear = formString(form, 'releaseYear').trim(); const rawYear = String(form.get('releaseYear') ?? '').trim();
const latestYear = new Date().getFullYear() + 1; const latestYear = new Date().getFullYear() + 1;
let releaseYear: number | null = null; let releaseYear: number | null = null;
+14 -58
View File
@@ -1,5 +1,5 @@
import createClient from 'openapi-fetch'; import createClient from 'openapi-fetch';
import { error, fail, redirect, type ActionFailure, type RequestEvent } from '@sveltejs/kit'; import { error, fail, redirect, type ActionFailure } 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,18 +17,9 @@ 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) loginRedirect(url); if (!locals.user)
redirect(302, `/auth?returnTo=${encodeURIComponent(url.pathname + url.search)}`);
return locals.user; return locals.user;
} }
@@ -38,42 +29,16 @@ 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) loginRedirect(url); if (!allowAnonymousListening)
redirect(302, `/auth?returnTo=${encodeURIComponent(url.pathname + url.search)}`);
return null; return null;
} }
export function formString(form: FormData, key: string): string { export function requireAccessTokenAction(
return String(form.get(key) ?? ''); locals: App.Locals,
} message = 'Inicia sesión.'
): string | ActionFailure<{ message: string }> {
export function formFile(form: FormData, key: string): File | null { return locals.accessToken ?? fail(401, { message });
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>(
@@ -105,18 +70,9 @@ export function apiErrorDetail(error: unknown): string | undefined {
return undefined; return undefined;
} }
type Failure = ActionFailure<{ message: string; detail?: string }>; export function unwrapOrFail(
result: { error?: unknown },
export function failOnError(result: { error?: unknown }, message: string): Failure | undefined { message: string
): 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
@@ -1,21 +0,0 @@
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('/');
});
});
+5 -12
View File
@@ -18,13 +18,6 @@ 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 () => {
@@ -36,9 +29,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((err) => { })().catch((exception) => {
configPromise = undefined; configPromise = undefined;
throw err; throw exception;
}); });
return configPromise; return configPromise;
} }
@@ -51,7 +44,7 @@ function sessionKey(): Promise<Uint8Array> {
return keyPromise; return keyPromise;
} }
function sessionCookieOptions(url: URL) { export function sessionCookieOptions(url: URL) {
return { return {
httpOnly: true, httpOnly: true,
sameSite: 'lax' as const, sameSite: 'lax' as const,
@@ -151,8 +144,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 (err) { } catch (exception) {
console.error('Token refresh failed', err); console.error('Token refresh failed', exception);
return null; return null;
} }
} }
+1 -8
View File
@@ -1,7 +1,6 @@
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 {
@@ -36,13 +35,7 @@ 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() {
const secret = required('SESSION_SECRET', env.SESSION_SECRET); return 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);
+12 -8
View File
@@ -1,11 +1,15 @@
import { fail } from '@sveltejs/kit'; import { fail, type RequestEvent } from '@sveltejs/kit';
import { authedAction } from '$lib/server/api'; import { createApiClient, requireAccessTokenAction } from '$lib/server/api';
const setFollowing = (follow: boolean) => async function setFollowing({ request, params, locals, fetch }: RequestEvent, follow: boolean) {
authedAction(async ({ api, params }) => { const accessToken = requireAccessTokenAction(locals);
const id = params.id ?? ''; if (typeof accessToken !== 'string') return accessToken;
const form = await request.formData();
const id = String(form.get('userId') ?? '') || String(params.id ?? '');
if (!id) return fail(400, { message: 'Falta el usuario.' }); if (!id) return fail(400, { message: 'Falta el usuario.' });
const api = createApiClient({ fetch, accessToken });
const options = { params: { path: { id } } }; const options = { params: { path: { id } } };
const { error } = follow const { error } = follow
? await api.POST('/users/{id}/follow', options) ? await api.POST('/users/{id}/follow', options)
@@ -18,7 +22,7 @@ const setFollowing = (follow: boolean) =>
} }
return { following: follow, userId: id }; return { following: follow, userId: id };
}); }
export const followUserAction = setFollowing(true); export const followUserAction = (event: RequestEvent) => setFollowing(event, true);
export const unfollowUserAction = setFollowing(false); export const unfollowUserAction = (event: RequestEvent) => setFollowing(event, false);
+3 -1
View File
@@ -1,6 +1,8 @@
import type { ApiClient } from '$lib/server/api'; import type { createApiClient } from '$lib/server/api';
import type { FollowList } from '$lib/types'; import type { FollowList } from '$lib/types';
type ApiClient = ReturnType<typeof createApiClient>;
export function fetchFollowList( export function fetchFollowList(
api: ApiClient, api: ApiClient,
list: FollowList, list: FollowList,
@@ -1,36 +0,0 @@
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,34 +0,0 @@
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');
});
});
@@ -1,42 +0,0 @@
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')
}
};
}
@@ -1,36 +0,0 @@
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');
});
});
@@ -1,30 +0,0 @@
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' } };
}

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