Design Prot Merge #1
@@ -36,7 +36,7 @@ namespace Musify.Application.Albums
|
|||||||
|
|
||||||
var albumIds = ranked.Select(entry => entry.AlbumId).ToList();
|
var albumIds = ranked.Select(entry => entry.AlbumId).ToList();
|
||||||
|
|
||||||
var albums = await database.UserAlbums
|
var albums = await database.Albums
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.Where(album => albumIds.Contains(album.Id))
|
.Where(album => albumIds.Contains(album.Id))
|
||||||
.Select(album => new
|
.Select(album => new
|
||||||
|
|||||||
@@ -14,7 +14,9 @@ namespace Musify.Application.Albums.Responses
|
|||||||
string? LargeImageKeyName,
|
string? LargeImageKeyName,
|
||||||
DateTime CreatedAt,
|
DateTime CreatedAt,
|
||||||
DateTime UpdatedAt,
|
DateTime UpdatedAt,
|
||||||
IReadOnlyList<Guid> CoverTrackIds)
|
IReadOnlyList<Guid> CoverTrackIds,
|
||||||
|
string? YouTubeAlbumId = null,
|
||||||
|
string? ThumbnailUrl = null)
|
||||||
{
|
{
|
||||||
public const int CoverTrackCount = 4;
|
public const int CoverTrackCount = 4;
|
||||||
|
|
||||||
@@ -37,5 +39,28 @@ namespace Musify.Application.Albums.Responses
|
|||||||
album.UpdatedAt,
|
album.UpdatedAt,
|
||||||
coverTrackIds ?? []);
|
coverTrackIds ?? []);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static AlbumApplicationResponse FromEntity(
|
||||||
|
Album album,
|
||||||
|
int trackCount,
|
||||||
|
IReadOnlyList<Guid>? coverTrackIds = null)
|
||||||
|
{
|
||||||
|
var externalAlbum = album as ExternalAlbum;
|
||||||
|
return new AlbumApplicationResponse(
|
||||||
|
album.Id,
|
||||||
|
album.Title,
|
||||||
|
album.Description,
|
||||||
|
album.ReleaseYear,
|
||||||
|
album is UserAlbum userAlbum ? userAlbum.OwnerUserId : 0,
|
||||||
|
trackCount,
|
||||||
|
album.Pictures?.SmallName,
|
||||||
|
album.Pictures?.MediumName,
|
||||||
|
album.Pictures?.LargeName,
|
||||||
|
album.CreatedAt,
|
||||||
|
album.UpdatedAt,
|
||||||
|
coverTrackIds ?? [],
|
||||||
|
externalAlbum?.ExternalId,
|
||||||
|
externalAlbum?.ThumbnailUrl);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,10 @@ namespace Musify.Application.Configuration
|
|||||||
|
|
||||||
[Required]
|
[Required]
|
||||||
public PlayListPicturesSizes PicturesSizes { get; set; } = new PlayListPicturesSizes();
|
public PlayListPicturesSizes PicturesSizes { get; set; } = new PlayListPicturesSizes();
|
||||||
|
|
||||||
|
public bool SeedPresetPictures { get; set; } = true;
|
||||||
|
|
||||||
|
public bool OverwritePresetPictures { get; set; } = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
public class PlayListRoutes
|
public class PlayListRoutes
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ namespace Musify.Application.Contracts
|
|||||||
|
|
||||||
DbSet<UserAlbum> UserAlbums { get; }
|
DbSet<UserAlbum> UserAlbums { get; }
|
||||||
|
|
||||||
|
DbSet<ExternalAlbum> ExternalAlbums { get; }
|
||||||
|
|
||||||
DbSet<AlbumHasTrack> AlbumHasTracks { get; }
|
DbSet<AlbumHasTrack> AlbumHasTracks { get; }
|
||||||
|
|
||||||
DbSet<Track> Tracks { get; }
|
DbSet<Track> Tracks { get; }
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
using ErrorOr;
|
using ErrorOr;
|
||||||
|
using Mediator;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Musify.Application.Shared;
|
using Musify.Application.Contracts;
|
||||||
using Musify.Application.PlayLists.Responses;
|
using Musify.Application.PlayLists.Responses;
|
||||||
|
using Musify.Application.Shared;
|
||||||
|
using Musify.Domain.ValueObjects;
|
||||||
using X.PagedList;
|
using X.PagedList;
|
||||||
using X.PagedList.EF;
|
using X.PagedList.EF;
|
||||||
using Mediator;
|
|
||||||
using Musify.Application.Contracts;
|
|
||||||
|
|
||||||
namespace Musify.Application.PlayLists
|
namespace Musify.Application.PlayLists
|
||||||
{
|
{
|
||||||
@@ -35,6 +36,7 @@ namespace Musify.Application.PlayLists
|
|||||||
|
|
||||||
var pagedEntities = await playListsQuery
|
var pagedEntities = await playListsQuery
|
||||||
.OrderBy(p => p.CreatedAt)
|
.OrderBy(p => p.CreatedAt)
|
||||||
|
.Where(p => p.LifeCycleStatus == LifeCycleStatus.Active)
|
||||||
.Select(p => new
|
.Select(p => new
|
||||||
{
|
{
|
||||||
PlayList = p,
|
PlayList = p,
|
||||||
@@ -43,7 +45,7 @@ namespace Musify.Application.PlayLists
|
|||||||
.Take(PlayListApplicationResponse.CoverTrackCount)
|
.Take(PlayListApplicationResponse.CoverTrackCount)
|
||||||
.Select(plt => plt.TrackId)
|
.Select(plt => plt.TrackId)
|
||||||
.ToList()
|
.ToList()
|
||||||
})
|
} )
|
||||||
.ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken);
|
.ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken);
|
||||||
|
|
||||||
var pagedPlayLists = new StaticPagedList<PlayListApplicationResponse>(
|
var pagedPlayLists = new StaticPagedList<PlayListApplicationResponse>(
|
||||||
|
|||||||
@@ -96,6 +96,108 @@ public sealed class YouTubeTrackProvisioner(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task MaterializeAlbumAsync(string albumExternalId, YouTubeAlbumDetail detail, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var validTracks = detail.Tracks
|
||||||
|
.Where(track => !string.IsNullOrEmpty(track.VideoId))
|
||||||
|
.ToList();
|
||||||
|
if (validTracks.Count == 0)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var album = await GetOrCreateExternalAlbumAsync(albumExternalId, detail.Album.Title, detail.Album.ThumbnailUrl, cancellationToken);
|
||||||
|
|
||||||
|
var videoIds = validTracks.Select(track => track.VideoId).ToList();
|
||||||
|
|
||||||
|
var tracksByVideoId = await database.ExternalTracks
|
||||||
|
.Where(track => track.Source == TrackSource.YouTube && videoIds.Contains(track.ExternalId))
|
||||||
|
.ToDictionaryAsync(track => track.ExternalId, cancellationToken);
|
||||||
|
|
||||||
|
var linkedTrackIds = (await database.AlbumHasTracks
|
||||||
|
.Where(albumTrack => albumTrack.AlbumId == album.Id)
|
||||||
|
.Select(albumTrack => albumTrack.TrackId)
|
||||||
|
.ToListAsync(cancellationToken))
|
||||||
|
.ToHashSet();
|
||||||
|
|
||||||
|
if (tracksByVideoId.Count == validTracks.Count && linkedTrackIds.Count >= validTracks.Count)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var artistRefs = detail.Album.Artists
|
||||||
|
.GroupBy(artistRef => artistRef.Id ?? artistRef.Name.ToUpperInvariant())
|
||||||
|
.Select(group => group.First())
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var albumArtists = new List<Artist>();
|
||||||
|
foreach (var artistRef in artistRefs)
|
||||||
|
albumArtists.Add(await GetOrCreateArtistAsync(artistRef, cancellationToken));
|
||||||
|
|
||||||
|
foreach (var albumTrack in validTracks)
|
||||||
|
{
|
||||||
|
if (!tracksByVideoId.TryGetValue(albumTrack.VideoId, out var track))
|
||||||
|
{
|
||||||
|
track = BuildStubTrack(albumTrack);
|
||||||
|
await database.ExternalTracks.AddAsync(track, cancellationToken);
|
||||||
|
|
||||||
|
for (var position = 0; position < albumArtists.Count; position++)
|
||||||
|
{
|
||||||
|
await database.TrackArtists.AddAsync(new TrackArtist
|
||||||
|
{
|
||||||
|
TrackId = track.Id,
|
||||||
|
ArtistId = albumArtists[position].Id,
|
||||||
|
Position = position
|
||||||
|
}, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
tracksByVideoId[albumTrack.VideoId] = track;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (linkedTrackIds.Contains(track.Id))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
await database.AlbumHasTracks.AddAsync(new AlbumHasTrack
|
||||||
|
{
|
||||||
|
AlbumId = album.Id,
|
||||||
|
TrackId = track.Id,
|
||||||
|
TrackNumber = albumTrack.TrackNumber
|
||||||
|
}, cancellationToken);
|
||||||
|
linkedTrackIds.Add(track.Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await database.SaveChangesAsync(cancellationToken);
|
||||||
|
logger.LogInformation("Materialized YouTube album {AlbumId} ({TrackCount} tracks)", albumExternalId, validTracks.Count);
|
||||||
|
}
|
||||||
|
catch (DbUpdateException exception)
|
||||||
|
{
|
||||||
|
logger.LogWarning(exception, "Race while materializing YouTube album {AlbumId}", albumExternalId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private ExternalTrack BuildStubTrack(YouTubeAlbumTrack albumTrack)
|
||||||
|
{
|
||||||
|
var title = Truncate(albumTrack.Title, 200);
|
||||||
|
return new ExternalTrack
|
||||||
|
{
|
||||||
|
Title = title,
|
||||||
|
NormalizedTitle = title.ToUpperInvariant(),
|
||||||
|
Source = TrackSource.YouTube,
|
||||||
|
ExternalId = albumTrack.VideoId,
|
||||||
|
DurationSeconds = albumTrack.DurationSeconds,
|
||||||
|
Pictures = new TrackPictures
|
||||||
|
{
|
||||||
|
SmallName = trackConfiguration.Routes.PresetSmallPicture,
|
||||||
|
MediumName = trackConfiguration.Routes.PresetMediumPicture,
|
||||||
|
LargeName = trackConfiguration.Routes.PresetLargePicture,
|
||||||
|
ProcessingStatus = ProcessingStatus.Pending
|
||||||
|
},
|
||||||
|
Audio = new TrackAudio
|
||||||
|
{
|
||||||
|
DownloadRequested = false,
|
||||||
|
TranscodeStatus = ProcessingStatus.Pending
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
private async Task<Artist> GetOrCreateArtistAsync(YouTubeArtistRef reference, CancellationToken cancellationToken)
|
private async Task<Artist> GetOrCreateArtistAsync(YouTubeArtistRef reference, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var name = Truncate(reference.Name, 200);
|
var name = Truncate(reference.Name, 200);
|
||||||
@@ -127,6 +229,37 @@ public sealed class YouTubeTrackProvisioner(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task<ExternalAlbum> GetOrCreateExternalAlbumAsync(string externalId, string name, string? thumbnailUrl, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var existing = await database.ExternalAlbums
|
||||||
|
.SingleOrDefaultAsync(album => album.Source == TrackSource.YouTube && album.ExternalId == externalId, cancellationToken);
|
||||||
|
if (existing is not null)
|
||||||
|
return existing;
|
||||||
|
|
||||||
|
var title = Truncate(name, 200);
|
||||||
|
var album = new ExternalAlbum
|
||||||
|
{
|
||||||
|
Title = title,
|
||||||
|
NormalizedTitle = title.ToUpperInvariant(),
|
||||||
|
Source = TrackSource.YouTube,
|
||||||
|
ExternalId = externalId,
|
||||||
|
ThumbnailUrl = string.IsNullOrEmpty(thumbnailUrl) ? null : Truncate(thumbnailUrl, 512)
|
||||||
|
};
|
||||||
|
|
||||||
|
await database.ExternalAlbums.AddAsync(album, cancellationToken);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await database.SaveChangesAsync(cancellationToken);
|
||||||
|
return album;
|
||||||
|
}
|
||||||
|
catch (DbUpdateException)
|
||||||
|
{
|
||||||
|
database.ExternalAlbums.Remove(album);
|
||||||
|
return await database.ExternalAlbums.SingleAsync(a => a.Source == TrackSource.YouTube && a.ExternalId == externalId, cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static string Truncate(string value, int maxLength) =>
|
private static string Truncate(string value, int maxLength) =>
|
||||||
value.Length <= maxLength ? value : value[..maxLength];
|
value.Length <= maxLength ? value : value[..maxLength];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,18 @@
|
|||||||
using ErrorOr;
|
using ErrorOr;
|
||||||
using Mediator;
|
using Mediator;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
using Musify.Application.Contracts;
|
using Musify.Application.Contracts;
|
||||||
|
using Musify.Application.Services;
|
||||||
|
|
||||||
namespace Musify.Application.YouTube
|
namespace Musify.Application.YouTube
|
||||||
{
|
{
|
||||||
public record GetYouTubeAlbumQuery(string AlbumId)
|
public record GetYouTubeAlbumQuery(string AlbumId)
|
||||||
: IQuery<ErrorOr<YouTubeAlbumDetail>>;
|
: IQuery<ErrorOr<YouTubeAlbumDetail>>;
|
||||||
|
|
||||||
public class GetYouTubeAlbumQueryHandler(IYouTubeMusicService youTubeMusicService)
|
public class GetYouTubeAlbumQueryHandler(
|
||||||
|
IYouTubeMusicService youTubeMusicService,
|
||||||
|
YouTubeTrackProvisioner provisioner,
|
||||||
|
ILogger<GetYouTubeAlbumQueryHandler> logger)
|
||||||
: IQueryHandler<GetYouTubeAlbumQuery, ErrorOr<YouTubeAlbumDetail>>
|
: IQueryHandler<GetYouTubeAlbumQuery, ErrorOr<YouTubeAlbumDetail>>
|
||||||
{
|
{
|
||||||
public async ValueTask<ErrorOr<YouTubeAlbumDetail>> Handle(GetYouTubeAlbumQuery request, CancellationToken cancellationToken)
|
public async ValueTask<ErrorOr<YouTubeAlbumDetail>> Handle(GetYouTubeAlbumQuery request, CancellationToken cancellationToken)
|
||||||
@@ -15,7 +20,20 @@ namespace Musify.Application.YouTube
|
|||||||
if (string.IsNullOrEmpty(request.AlbumId))
|
if (string.IsNullOrEmpty(request.AlbumId))
|
||||||
return Error.Validation(description: "The album id must not be empty.");
|
return Error.Validation(description: "The album id must not be empty.");
|
||||||
|
|
||||||
return await youTubeMusicService.GetAlbumAsync(request.AlbumId, cancellationToken);
|
var result = await youTubeMusicService.GetAlbumAsync(request.AlbumId, cancellationToken);
|
||||||
|
if (result.IsError)
|
||||||
|
return result;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await provisioner.MaterializeAlbumAsync(request.AlbumId, result.Value, cancellationToken);
|
||||||
|
}
|
||||||
|
catch (Exception exception)
|
||||||
|
{
|
||||||
|
logger.LogWarning(exception, "Failed to materialize YouTube album {AlbumId}", request.AlbumId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using Musify.Domain.ValueObjects;
|
||||||
|
|
||||||
|
namespace Musify.Domain.Entities
|
||||||
|
{
|
||||||
|
public class ExternalAlbum : Album
|
||||||
|
{
|
||||||
|
[Required]
|
||||||
|
public required TrackSource Source { get; set; }
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
[MaxLength(64)]
|
||||||
|
public required string ExternalId { get; set; }
|
||||||
|
|
||||||
|
[MaxLength(512)]
|
||||||
|
public string? ThumbnailUrl { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,7 +9,7 @@ namespace Musify.Domain.Entities
|
|||||||
[Required]
|
[Required]
|
||||||
public required long OwnerUserId { get; set; }
|
public required long OwnerUserId { get; set; }
|
||||||
|
|
||||||
[ForeignKey(nameof(OwnerUserId))]
|
[ForeignKey(nameof(OwnerUserId)) ]
|
||||||
public User Owner { get; set; }
|
public User Owner { get; set; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 10 KiB |
+4
-4
@@ -1,6 +1,6 @@
|
|||||||
namespace Musify.Infrastructure.Services
|
namespace Musify.Infrastructure.Helpers
|
||||||
{
|
{
|
||||||
public static class YouTubeThumbnail
|
public static class YouTubeThumbNailHelper
|
||||||
{
|
{
|
||||||
public static bool IsSquare(string url) =>
|
public static bool IsSquare(string url) =>
|
||||||
url.Contains("googleusercontent.com", StringComparison.OrdinalIgnoreCase);
|
url.Contains("googleusercontent.com", StringComparison.OrdinalIgnoreCase);
|
||||||
@@ -38,7 +38,7 @@ namespace Musify.Infrastructure.Services
|
|||||||
if (lastSlash < 0)
|
if (lastSlash < 0)
|
||||||
return path;
|
return path;
|
||||||
|
|
||||||
return $"{path[..lastSlash]}/{YtImgFileName(size)}";
|
return $"{path[..lastSlash] }/{YtImgFileName(size) }";
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string YtImgFileName(int size) => size switch
|
private static string YtImgFileName(int size) => size switch
|
||||||
@@ -50,6 +50,6 @@ namespace Musify.Infrastructure.Services
|
|||||||
};
|
};
|
||||||
|
|
||||||
private static bool IsSizeSuffix(ReadOnlySpan<char> suffix) =>
|
private static bool IsSizeSuffix(ReadOnlySpan<char> suffix) =>
|
||||||
suffix.Length > 1 && (suffix[0] is 'w' or 's' or 'h') && char.IsDigit(suffix[1]);
|
suffix.Length > 1 && (suffix[0] is 'w' or 's' or 'h') && char.IsDigit(suffix[1] );
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -43,4 +43,8 @@
|
|||||||
<Folder Include="Persistence\Migrations\" />
|
<Folder Include="Persistence\Migrations\" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<EmbeddedResource Include="Assets\PlayListDefaultCover.png" LogicalName="Musify.Infrastructure.Assets.PlayListDefaultCover.png" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -117,6 +117,13 @@ namespace Musify.Infrastructure.Persistence
|
|||||||
.HasForeignKey(album => album.OwnerUserId)
|
.HasForeignKey(album => album.OwnerUserId)
|
||||||
.OnDelete(DeleteBehavior.Restrict);
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
modelBuilder.Entity<ExternalAlbum>()
|
||||||
|
.ToTable("ExternalAlbums");
|
||||||
|
|
||||||
|
modelBuilder.Entity<ExternalAlbum>()
|
||||||
|
.HasIndex(album => new { album.Source, album.ExternalId })
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
modelBuilder.Entity<AlbumHasTrack>()
|
modelBuilder.Entity<AlbumHasTrack>()
|
||||||
.HasOne(albumTrack => albumTrack.Album)
|
.HasOne(albumTrack => albumTrack.Album)
|
||||||
.WithMany(album => album.AlbumTracks)
|
.WithMany(album => album.AlbumTracks)
|
||||||
@@ -176,6 +183,8 @@ namespace Musify.Infrastructure.Persistence
|
|||||||
|
|
||||||
public DbSet<UserAlbum> UserAlbums => Set<UserAlbum>();
|
public DbSet<UserAlbum> UserAlbums => Set<UserAlbum>();
|
||||||
|
|
||||||
|
public DbSet<ExternalAlbum> ExternalAlbums => Set<ExternalAlbum>();
|
||||||
|
|
||||||
public DbSet<AlbumHasTrack> AlbumHasTracks => Set<AlbumHasTrack>();
|
public DbSet<AlbumHasTrack> AlbumHasTracks => Set<AlbumHasTrack>();
|
||||||
|
|
||||||
public DbSet<UserHasTrack> UserHasTracks => Set<UserHasTrack>();
|
public DbSet<UserHasTrack> UserHasTracks => Set<UserHasTrack>();
|
||||||
|
|||||||
Generated
+1205
File diff suppressed because it is too large
Load Diff
+47
@@ -0,0 +1,47 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Musify.Infrastructure.Persistence.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddExternalAlbums : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "ExternalAlbums",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
Source = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
ExternalId = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_ExternalAlbums", x => x.Id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_ExternalAlbums_Albums_Id",
|
||||||
|
column: x => x.Id,
|
||||||
|
principalTable: "Albums",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_ExternalAlbums_Source_ExternalId",
|
||||||
|
table: "ExternalAlbums",
|
||||||
|
columns: new[] { "Source", "ExternalId" },
|
||||||
|
unique: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "ExternalAlbums");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+1209
File diff suppressed because it is too large
Load Diff
+29
@@ -0,0 +1,29 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Musify.Infrastructure.Persistence.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddExternalAlbumThumbnail : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "ThumbnailUrl",
|
||||||
|
table: "ExternalAlbums",
|
||||||
|
type: "character varying(512)",
|
||||||
|
maxLength: 512,
|
||||||
|
nullable: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "ThumbnailUrl",
|
||||||
|
table: "ExternalAlbums");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -720,6 +720,28 @@ namespace Musify.Infrastructure.Persistence.Migrations
|
|||||||
b.ToTable("TrackProcessingState");
|
b.ToTable("TrackProcessingState");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Musify.Domain.Entities.ExternalAlbum", b =>
|
||||||
|
{
|
||||||
|
b.HasBaseType("Musify.Domain.Entities.Album");
|
||||||
|
|
||||||
|
b.Property<string>("ExternalId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("character varying(64)");
|
||||||
|
|
||||||
|
b.Property<int>("Source")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<string>("ThumbnailUrl")
|
||||||
|
.HasMaxLength(512)
|
||||||
|
.HasColumnType("character varying(512)");
|
||||||
|
|
||||||
|
b.HasIndex("Source", "ExternalId")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("ExternalAlbums", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Musify.Domain.Entities.UserAlbum", b =>
|
modelBuilder.Entity("Musify.Domain.Entities.UserAlbum", b =>
|
||||||
{
|
{
|
||||||
b.HasBaseType("Musify.Domain.Entities.Album");
|
b.HasBaseType("Musify.Domain.Entities.Album");
|
||||||
@@ -1082,6 +1104,15 @@ namespace Musify.Infrastructure.Persistence.Migrations
|
|||||||
b.Navigation("User");
|
b.Navigation("User");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Musify.Domain.Entities.ExternalAlbum", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Musify.Domain.Entities.Album", null)
|
||||||
|
.WithOne()
|
||||||
|
.HasForeignKey("Musify.Domain.Entities.ExternalAlbum", "Id")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Musify.Domain.Entities.UserAlbum", b =>
|
modelBuilder.Entity("Musify.Domain.Entities.UserAlbum", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("Musify.Domain.Entities.Album", null)
|
b.HasOne("Musify.Domain.Entities.Album", null)
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
using System.Reflection;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Hosting;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Musify.Application.Configuration;
|
||||||
|
using Musify.Application.Contracts;
|
||||||
|
|
||||||
|
namespace Musify.Infrastructure.Services
|
||||||
|
{
|
||||||
|
public class PlayListPresetSeeder(IServiceScopeFactory scopeFactory, ILogger<PlayListPresetSeeder> logger)
|
||||||
|
: BackgroundService
|
||||||
|
{
|
||||||
|
private const string SourceResourceName = "Musify.Infrastructure.Assets.PlayListDefaultCover.png";
|
||||||
|
private const string WebpContentType = "image/webp";
|
||||||
|
|
||||||
|
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||||
|
{
|
||||||
|
using var scope = scopeFactory.CreateScope();
|
||||||
|
var services = scope.ServiceProvider;
|
||||||
|
|
||||||
|
var playListConfiguration = services.GetRequiredService<PlayListConfiguration>();
|
||||||
|
if (!playListConfiguration.SeedPresetPictures)
|
||||||
|
return;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await SeedAsync(services, playListConfiguration, stoppingToken);
|
||||||
|
}
|
||||||
|
catch (Exception exception)
|
||||||
|
{
|
||||||
|
logger.LogError(exception, "Failed to seed playlist preset pictures");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SeedAsync(IServiceProvider services, PlayListConfiguration configuration, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var storage = services.GetRequiredService<IStorageService>();
|
||||||
|
var pictures = services.GetRequiredService<IPictureService>();
|
||||||
|
var bucket = services.GetRequiredService<ApplicationStorageConfiguration>().Bucket;
|
||||||
|
|
||||||
|
var source = LoadSourceBytes();
|
||||||
|
|
||||||
|
var routes = configuration.Routes;
|
||||||
|
var sizes = configuration.PicturesSizes;
|
||||||
|
|
||||||
|
var targets = new (string Key, int Width, int Height)[]
|
||||||
|
{
|
||||||
|
(routes.BuildOriginalPicturePath(routes.PresetOriginalPicture), sizes.LargePictureWidth, sizes.LargePictureHeight),
|
||||||
|
(routes.PresetSmallPicturePath, sizes.SmallPictureWidth, sizes.SmallPictureHeight),
|
||||||
|
(routes.PresetMediumPicturePath, sizes.MediumPictureWidth, sizes.MediumPictureHeight),
|
||||||
|
(routes.PresetLargePicturePath, sizes.LargePictureWidth, sizes.LargePictureHeight),
|
||||||
|
};
|
||||||
|
|
||||||
|
foreach (var target in targets)
|
||||||
|
{
|
||||||
|
if (!configuration.OverwritePresetPictures)
|
||||||
|
{
|
||||||
|
var existing = await storage.HeadObjectAsync(bucket, target.Key, cancellationToken);
|
||||||
|
if (existing is not null)
|
||||||
|
{
|
||||||
|
logger.LogDebug("Playlist preset picture already present at {Bucket}/{Key}", bucket, target.Key);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
using var sourceStream = new MemoryStream(source, writable: false);
|
||||||
|
await using var resized = await pictures.ResizePictureAsWebpAsync(sourceStream, target.Width, target.Height, cancellationToken);
|
||||||
|
await storage.UploadFileAsync(resized, WebpContentType, bucket, target.Key, cancellationToken);
|
||||||
|
|
||||||
|
logger.LogInformation("Seeded playlist preset picture {Bucket}/{Key} ({Width}x{Height})", bucket, target.Key, target.Width, target.Height);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[] LoadSourceBytes()
|
||||||
|
{
|
||||||
|
var assembly = Assembly.GetExecutingAssembly();
|
||||||
|
using var stream = assembly.GetManifestResourceStream(SourceResourceName)
|
||||||
|
?? throw new InvalidOperationException($"Embedded resource '{SourceResourceName}' was not found.");
|
||||||
|
|
||||||
|
using var memory = new MemoryStream();
|
||||||
|
stream.CopyTo(memory);
|
||||||
|
return memory.ToArray();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -45,6 +45,12 @@ namespace Musify.Infrastructure.Services
|
|||||||
return serviceDescriptors;
|
return serviceDescriptors;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static IServiceCollection AddPlayListPresetSeeder(this IServiceCollection serviceDescriptors, IConfiguration configuration)
|
||||||
|
{
|
||||||
|
serviceDescriptors.AddHostedService<PlayListPresetSeeder>();
|
||||||
|
return serviceDescriptors;
|
||||||
|
}
|
||||||
|
|
||||||
public static IServiceCollection AddUploadIntentConfiguration(this IServiceCollection serviceDescriptors, IConfiguration configuration)
|
public static IServiceCollection AddUploadIntentConfiguration(this IServiceCollection serviceDescriptors, IConfiguration configuration)
|
||||||
{
|
{
|
||||||
serviceDescriptors.AddValidatedOptions<UploadIntentConfiguration>(configuration, UploadIntentConfiguration.SectionName);
|
serviceDescriptors.AddValidatedOptions<UploadIntentConfiguration>(configuration, UploadIntentConfiguration.SectionName);
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ using Microsoft.Extensions.Caching.Memory;
|
|||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using Musify.Application.Contracts;
|
using Musify.Application.Contracts;
|
||||||
using Musify.Infrastructure.Configuration;
|
using Musify.Infrastructure.Configuration;
|
||||||
|
using Musify.Infrastructure.Helpers;
|
||||||
using YouTubeMusicAPI.Client;
|
using YouTubeMusicAPI.Client;
|
||||||
using YouTubeMusicAPI.Models.Search;
|
using YouTubeMusicAPI.Models.Search;
|
||||||
using YouTubeMusicAPI.Models.Streaming;
|
using YouTubeMusicAPI.Models.Streaming;
|
||||||
@@ -260,7 +261,7 @@ namespace Musify.Infrastructure.Services
|
|||||||
videoId,
|
videoId,
|
||||||
info.Name,
|
info.Name,
|
||||||
string.Join(", ", info.Artists.Select(artist => artist.Name)),
|
string.Join(", ", info.Artists.Select(artist => artist.Name)),
|
||||||
string.Empty,
|
info.Album?.Name ?? string.Empty,
|
||||||
(int)info.Duration.TotalSeconds,
|
(int)info.Duration.TotalSeconds,
|
||||||
PickThumbnail(info.Thumbnails),
|
PickThumbnail(info.Thumbnails),
|
||||||
info.IsExplicit,
|
info.IsExplicit,
|
||||||
@@ -278,13 +279,13 @@ namespace Musify.Infrastructure.Services
|
|||||||
}
|
}
|
||||||
|
|
||||||
public string ResolveArtworkUrl(string thumbnailUrl) =>
|
public string ResolveArtworkUrl(string thumbnailUrl) =>
|
||||||
YouTubeThumbnail.WithSize(thumbnailUrl, configuration.ArtworkSize);
|
YouTubeThumbNailHelper.WithSize(thumbnailUrl, configuration.ArtworkSize);
|
||||||
|
|
||||||
private string PickThumbnail(IEnumerable<YouTubeMusicAPI.Models.Thumbnail> thumbnails)
|
private string PickThumbnail(IEnumerable<YouTubeMusicAPI.Models.Thumbnail> thumbnails)
|
||||||
{
|
{
|
||||||
var ordered = thumbnails.OrderBy(thumbnail => thumbnail.Width).ToList();
|
var ordered = thumbnails.OrderBy(thumbnail => thumbnail.Width).ToList();
|
||||||
|
|
||||||
var square = ordered.Where(thumbnail => YouTubeThumbnail.IsSquare(thumbnail.Url)).ToList();
|
var square = ordered.Where(thumbnail => YouTubeThumbNailHelper.IsSquare(thumbnail.Url)).ToList();
|
||||||
var candidates = square.Count > 0 ? square : ordered;
|
var candidates = square.Count > 0 ? square : ordered;
|
||||||
|
|
||||||
var chosen = candidates.FirstOrDefault(thumbnail => thumbnail.Width >= configuration.ThumbnailSize)
|
var chosen = candidates.FirstOrDefault(thumbnail => thumbnail.Width >= configuration.ThumbnailSize)
|
||||||
@@ -292,7 +293,7 @@ namespace Musify.Infrastructure.Services
|
|||||||
|
|
||||||
return chosen is null
|
return chosen is null
|
||||||
? string.Empty
|
? string.Empty
|
||||||
: YouTubeThumbnail.WithSize(chosen.Url, configuration.ThumbnailSize);
|
: YouTubeThumbNailHelper.WithSize(chosen.Url, configuration.ThumbnailSize);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string SearchCacheKey(string token) => $"yt-search:{token}";
|
private static string SearchCacheKey(string token) => $"yt-search:{token}";
|
||||||
|
|||||||
@@ -47,7 +47,9 @@
|
|||||||
"MediumPictureHeight": 256,
|
"MediumPictureHeight": 256,
|
||||||
"LargePictureWidth": 512,
|
"LargePictureWidth": 512,
|
||||||
"LargePictureHeight": 512
|
"LargePictureHeight": 512
|
||||||
}
|
},
|
||||||
|
"SeedPresetPictures": true,
|
||||||
|
"OverwritePresetPictures": false
|
||||||
},
|
},
|
||||||
"Track": {
|
"Track": {
|
||||||
"Routes": {
|
"Routes": {
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ services.AddAudioTranscoder(configuration);
|
|||||||
services.AddYouTubeDownloader(configuration);
|
services.AddYouTubeDownloader(configuration);
|
||||||
services.AddHttpClient();
|
services.AddHttpClient();
|
||||||
services.AddPictureService(configuration);
|
services.AddPictureService(configuration);
|
||||||
|
services.AddPlayListPresetSeeder(configuration);
|
||||||
services.AddDatabase(configuration);
|
services.AddDatabase(configuration);
|
||||||
services.AddMassTransitConsumers(configuration);
|
services.AddMassTransitConsumers(configuration);
|
||||||
services.AddUploadIntentJobs(configuration);
|
services.AddUploadIntentJobs(configuration);
|
||||||
|
|||||||
Vendored
+4
@@ -6,6 +6,10 @@ declare global {
|
|||||||
user: SessionUser | null;
|
user: SessionUser | null;
|
||||||
accessToken: string | null;
|
accessToken: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface PageData {
|
||||||
|
section?: string | null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Vendored
+2
@@ -582,6 +582,8 @@ export interface components {
|
|||||||
/** Format: date-time */
|
/** Format: date-time */
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
coverTrackIds: string[];
|
coverTrackIds: string[];
|
||||||
|
youTubeAlbumId: null | string;
|
||||||
|
thumbnailUrl: null | string;
|
||||||
};
|
};
|
||||||
CreateAlbumRequest: {
|
CreateAlbumRequest: {
|
||||||
title: string;
|
title: string;
|
||||||
|
|||||||
@@ -26,7 +26,6 @@
|
|||||||
import { enhance } from '$app/forms';
|
import { enhance } from '$app/forms';
|
||||||
import ChevronRight from '@lucide/svelte/icons/chevron-right';
|
import ChevronRight from '@lucide/svelte/icons/chevron-right';
|
||||||
import PlaylistArt from '$lib/components/ui/PlaylistArt.svelte';
|
import PlaylistArt from '$lib/components/ui/PlaylistArt.svelte';
|
||||||
import { hueFor } from '$lib/theme/color';
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
menu: AlbumMenuState;
|
menu: AlbumMenuState;
|
||||||
@@ -94,7 +93,6 @@
|
|||||||
<PlaylistArt
|
<PlaylistArt
|
||||||
playlistId={playlist.id}
|
playlistId={playlist.id}
|
||||||
trackIds={[]}
|
trackIds={[]}
|
||||||
hue={hueFor(playlist.id)}
|
|
||||||
size="small"
|
size="small"
|
||||||
class="h-9 w-9 shrink-0 rounded-control"
|
class="h-9 w-9 shrink-0 rounded-control"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
|
import { isSectionActive } from '$lib/navigation.svelte';
|
||||||
import Home from '@lucide/svelte/icons/house';
|
import Home from '@lucide/svelte/icons/house';
|
||||||
import Search from '@lucide/svelte/icons/search';
|
import Search from '@lucide/svelte/icons/search';
|
||||||
import Music from '@lucide/svelte/icons/music';
|
import Music from '@lucide/svelte/icons/music';
|
||||||
@@ -13,7 +14,7 @@
|
|||||||
];
|
];
|
||||||
|
|
||||||
function isActive(href: string) {
|
function isActive(href: string) {
|
||||||
return href === '/' ? page.url.pathname === '/' : page.url.pathname.startsWith(href);
|
return isSectionActive(href, page.url.pathname, page.data.section);
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
|
import { isSectionActive } from '$lib/navigation.svelte';
|
||||||
import type { SessionUser } from '$lib/types';
|
import type { SessionUser } from '$lib/types';
|
||||||
import Home from '@lucide/svelte/icons/house';
|
import Home from '@lucide/svelte/icons/house';
|
||||||
import Search from '@lucide/svelte/icons/search';
|
import Search from '@lucide/svelte/icons/search';
|
||||||
@@ -42,7 +43,7 @@
|
|||||||
];
|
];
|
||||||
|
|
||||||
function isActive(href: string) {
|
function isActive(href: string) {
|
||||||
return href === '/' ? page.url.pathname === '/' : page.url.pathname.startsWith(href);
|
return isSectionActive(href, page.url.pathname, page.data.section);
|
||||||
}
|
}
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
|
|||||||
@@ -22,7 +22,6 @@
|
|||||||
import ListPlus from '@lucide/svelte/icons/list-plus';
|
import ListPlus from '@lucide/svelte/icons/list-plus';
|
||||||
import ChevronRight from '@lucide/svelte/icons/chevron-right';
|
import ChevronRight from '@lucide/svelte/icons/chevron-right';
|
||||||
import PlaylistArt from '$lib/components/ui/PlaylistArt.svelte';
|
import PlaylistArt from '$lib/components/ui/PlaylistArt.svelte';
|
||||||
import { hueFor } from '$lib/theme/color';
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
menu: ContextMenuState;
|
menu: ContextMenuState;
|
||||||
@@ -105,7 +104,6 @@
|
|||||||
<PlaylistArt
|
<PlaylistArt
|
||||||
playlistId={playlist.id}
|
playlistId={playlist.id}
|
||||||
trackIds={[]}
|
trackIds={[]}
|
||||||
hue={hueFor(playlist.id)}
|
|
||||||
size="small"
|
size="small"
|
||||||
class="h-9 w-9 shrink-0 rounded-control"
|
class="h-9 w-9 shrink-0 rounded-control"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -35,7 +35,7 @@
|
|||||||
type="button"
|
type="button"
|
||||||
onclick={seek}
|
onclick={seek}
|
||||||
aria-label="Barra de progreso"
|
aria-label="Barra de progreso"
|
||||||
class="group relative h-1.5 flex-1 cursor-pointer rounded-full bg-track transition-[height] hover:h-2"
|
class="group relative h-1.5 flex-1 cursor-pointer rounded-full bg-track"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="absolute inset-y-0 left-0 rounded-full bg-accent/60 group-hover:brightness-110"
|
class="absolute inset-y-0 left-0 rounded-full bg-accent/60 group-hover:brightness-110"
|
||||||
|
|||||||
@@ -9,15 +9,13 @@
|
|||||||
<Cover
|
<Cover
|
||||||
trackId={player.current.id}
|
trackId={player.current.id}
|
||||||
src={player.current.coverUrl}
|
src={player.current.coverUrl}
|
||||||
hue={player.current.hue}
|
|
||||||
size="small"
|
size="small"
|
||||||
alt={player.current.title}
|
alt={player.current.title}
|
||||||
class="h-11 w-11 shrink-0 rounded-lg shadow-[0_6px_16px_-6px_rgba(0,0,0,0.6)] md:h-13 md:w-13"
|
class="h-11 w-11 shrink-0 rounded-lg shadow-[0_6px_16px_-6px_rgba(0,0,0,0.6)] md:h-13 md:w-13"
|
||||||
/>
|
/>
|
||||||
{:else}
|
{:else}
|
||||||
<div
|
<div
|
||||||
class="h-11 w-11 shrink-0 rounded-lg shadow-[0_6px_16px_-6px_rgba(0,0,0,0.6)] md:h-13 md:w-13"
|
class="h-11 w-11 shrink-0 rounded-lg bg-surface shadow-[0_6px_16px_-6px_rgba(0,0,0,0.6)] md:h-13 md:w-13"
|
||||||
style="background:{player.artGradient}"
|
|
||||||
></div>
|
></div>
|
||||||
{/if}
|
{/if}
|
||||||
<div class="min-w-0">
|
<div class="min-w-0">
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
import MediaCard from './MediaCard.svelte';
|
import MediaCard from './MediaCard.svelte';
|
||||||
import PlaylistArt from './PlaylistArt.svelte';
|
import PlaylistArt from './PlaylistArt.svelte';
|
||||||
import Cover from './Cover.svelte';
|
import Cover from './Cover.svelte';
|
||||||
import { hueFor } from '$lib/theme/color';
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -55,16 +54,9 @@
|
|||||||
>
|
>
|
||||||
{#snippet art(artClass)}
|
{#snippet art(artClass)}
|
||||||
{#if coverSrc}
|
{#if coverSrc}
|
||||||
<Cover
|
<Cover trackId={id} src={coverSrc} size="large" alt={title} class={artClass} />
|
||||||
trackId={id}
|
|
||||||
src={coverSrc}
|
|
||||||
hue={hueFor(id)}
|
|
||||||
size="large"
|
|
||||||
alt={title}
|
|
||||||
class={artClass}
|
|
||||||
/>
|
|
||||||
{:else}
|
{:else}
|
||||||
<PlaylistArt {trackIds} hue={hueFor(id)} class={artClass} />
|
<PlaylistArt {trackIds} class={artClass} />
|
||||||
{/if}
|
{/if}
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</MediaCard>
|
</MediaCard>
|
||||||
|
|||||||
@@ -1,13 +1,10 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { gradientForHue, hueFor } from '$lib/theme/color';
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
id: string;
|
|
||||||
name: string;
|
name: string;
|
||||||
size?: number;
|
size?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { id, name, size = 108 }: Props = $props();
|
let { name, size = 108 }: Props = $props();
|
||||||
|
|
||||||
const initials = $derived(
|
const initials = $derived(
|
||||||
name
|
name
|
||||||
@@ -21,8 +18,8 @@
|
|||||||
|
|
||||||
<div class="flex flex-col items-center gap-2.5 text-center" style="width:{size}px">
|
<div class="flex flex-col items-center gap-2.5 text-center" style="width:{size}px">
|
||||||
<div
|
<div
|
||||||
class="relative grid shrink-0 place-items-center overflow-hidden rounded-full shadow-art ring-1 ring-line transition duration-300 ease-out ring-inset group-hover/artist:shadow-art-lg"
|
class="relative grid shrink-0 place-items-center overflow-hidden rounded-full bg-surface shadow-art ring-1 ring-line transition duration-300 ease-out ring-inset group-hover/artist:shadow-art-lg"
|
||||||
style="width:{size}px;height:{size}px;background:{gradientForHue(hueFor(id))}"
|
style="width:{size}px;height:{size}px"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="pointer-events-none absolute inset-0"
|
class="pointer-events-none absolute inset-0"
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import ArrowLeft from '@lucide/svelte/icons/arrow-left';
|
||||||
|
import { previousPage } from '$lib/navigation.svelte';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
href: string;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { href, label }: Props = $props();
|
||||||
|
|
||||||
|
const previous = $derived(previousPage());
|
||||||
|
const target = $derived(previous ?? { url: href, label });
|
||||||
|
|
||||||
|
function onclick(event: MouseEvent) {
|
||||||
|
if (!previous) return;
|
||||||
|
if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey)
|
||||||
|
return;
|
||||||
|
event.preventDefault();
|
||||||
|
history.back();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<a
|
||||||
|
href={target.url}
|
||||||
|
{onclick}
|
||||||
|
class="group inline-flex items-center text-fg-3 transition-colors hover:text-accent-soft"
|
||||||
|
>
|
||||||
|
<span class="grid h-8 w-8 place-items-center transition-colors group-hover:text-accent-soft">
|
||||||
|
<ArrowLeft class="h-4 w-4" />
|
||||||
|
</span>
|
||||||
|
{target.label}
|
||||||
|
</a>
|
||||||
@@ -1,11 +1,9 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { Snippet } from 'svelte';
|
import type { Snippet } from 'svelte';
|
||||||
import { gradientForHue } from '$lib/theme/color';
|
|
||||||
import { thumbnailSrc } from '$lib/thumbnails';
|
import { thumbnailSrc } from '$lib/thumbnails';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
trackId: string | number;
|
trackId: string | number;
|
||||||
hue: number;
|
|
||||||
size?: 'small' | 'medium' | 'large';
|
size?: 'small' | 'medium' | 'large';
|
||||||
src?: string | null;
|
src?: string | null;
|
||||||
alt?: string;
|
alt?: string;
|
||||||
@@ -16,7 +14,6 @@
|
|||||||
|
|
||||||
let {
|
let {
|
||||||
trackId,
|
trackId,
|
||||||
hue,
|
|
||||||
size = 'medium',
|
size = 'medium',
|
||||||
src,
|
src,
|
||||||
alt = '',
|
alt = '',
|
||||||
@@ -26,7 +23,6 @@
|
|||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
let failed = $state(false);
|
let failed = $state(false);
|
||||||
let loaded = $state(false);
|
|
||||||
|
|
||||||
const imageSrc = $derived(
|
const imageSrc = $derived(
|
||||||
src ? (thumbnailSrc(src, size) ?? src) : `/api/tracks/${trackId}/cover?size=${size}`
|
src ? (thumbnailSrc(src, size) ?? src) : `/api/tracks/${trackId}/cover?size=${size}`
|
||||||
@@ -35,16 +31,10 @@
|
|||||||
$effect(() => {
|
$effect(() => {
|
||||||
imageSrc;
|
imageSrc;
|
||||||
failed = false;
|
failed = false;
|
||||||
loaded = false;
|
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<div class="relative overflow-hidden bg-surface {klass}" role="presentation" {oncontextmenu}>
|
||||||
class="relative overflow-hidden {klass}"
|
|
||||||
style={loaded && !failed ? undefined : `background:${gradientForHue(hue)}`}
|
|
||||||
role="presentation"
|
|
||||||
{oncontextmenu}
|
|
||||||
>
|
|
||||||
{#if !failed}
|
{#if !failed}
|
||||||
<img
|
<img
|
||||||
src={imageSrc}
|
src={imageSrc}
|
||||||
@@ -52,7 +42,6 @@
|
|||||||
loading="lazy"
|
loading="lazy"
|
||||||
decoding="async"
|
decoding="async"
|
||||||
fetchpriority="low"
|
fetchpriority="low"
|
||||||
onload={() => (loaded = true)}
|
|
||||||
onerror={() => (failed = true)}
|
onerror={() => (failed = true)}
|
||||||
class="h-full w-full object-cover"
|
class="h-full w-full object-cover"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,15 +1,13 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Cover from './Cover.svelte';
|
import Cover from './Cover.svelte';
|
||||||
import { gradientForHue, hueFor } from '$lib/theme/color';
|
|
||||||
import { mixItemKey, type MixItem } from '$lib/mixes';
|
import { mixItemKey, type MixItem } from '$lib/mixes';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
items: MixItem[];
|
items: MixItem[];
|
||||||
hue: number;
|
|
||||||
class?: string;
|
class?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { items, hue, class: klass = '' }: Props = $props();
|
let { items, class: klass = '' }: Props = $props();
|
||||||
|
|
||||||
const tiles = $derived(items.slice(0, 4));
|
const tiles = $derived(items.slice(0, 4));
|
||||||
</script>
|
</script>
|
||||||
@@ -20,7 +18,6 @@
|
|||||||
<Cover
|
<Cover
|
||||||
trackId={item.trackId ?? item.videoId ?? ''}
|
trackId={item.trackId ?? item.videoId ?? ''}
|
||||||
src={item.thumbnailUrl}
|
src={item.thumbnailUrl}
|
||||||
hue={hueFor(mixItemKey(item))}
|
|
||||||
size="small"
|
size="small"
|
||||||
class="h-full w-full"
|
class="h-full w-full"
|
||||||
/>
|
/>
|
||||||
@@ -30,10 +27,9 @@
|
|||||||
<Cover
|
<Cover
|
||||||
trackId={tiles[0].trackId ?? tiles[0].videoId ?? ''}
|
trackId={tiles[0].trackId ?? tiles[0].videoId ?? ''}
|
||||||
src={tiles[0].thumbnailUrl}
|
src={tiles[0].thumbnailUrl}
|
||||||
hue={hueFor(mixItemKey(tiles[0]))}
|
|
||||||
size="medium"
|
size="medium"
|
||||||
class={klass}
|
class={klass}
|
||||||
/>
|
/>
|
||||||
{:else}
|
{:else}
|
||||||
<div class={klass} style="background:{gradientForHue(hue)}"></div>
|
<div class="bg-surface {klass}"></div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Cover from './Cover.svelte';
|
import Cover from './Cover.svelte';
|
||||||
import { gradientForHue, hueFor } from '$lib/theme/color';
|
import type { Mix } from '$lib/mixes';
|
||||||
import { mixItemKey, type Mix } from '$lib/mixes';
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
mix: Mix;
|
mix: Mix;
|
||||||
@@ -25,40 +24,40 @@
|
|||||||
|
|
||||||
<a
|
<a
|
||||||
href="/mixes/{mix.id}"
|
href="/mixes/{mix.id}"
|
||||||
class="group/tile animate-enter relative block overflow-hidden rounded-art shadow-art ring-1 ring-line transition duration-300 ease-out ring-inset hover:shadow-art-lg focus-visible:ring-2 focus-visible:ring-accent focus-visible:outline-none {klass}"
|
class="group/tile animate-enter relative block overflow-hidden rounded-art ring-1 ring-line transition duration-300 ease-out [clip-path:inset(0_round_var(--radius-art))] ring-inset focus-visible:ring-2 focus-visible:ring-accent focus-visible:outline-none {klass}"
|
||||||
style="animation-delay:{Math.min(index, 10) * 45}ms;background:{gradientForHue(hueFor(mix.id))}"
|
style="animation-delay:{Math.min(index, 10) * 45}ms"
|
||||||
>
|
>
|
||||||
{#if tiles.length > 0}
|
{#if tiles.length > 0}
|
||||||
<div
|
<div class="absolute inset-0 flex transition duration-300 ease-out">
|
||||||
class="absolute inset-0 flex transition duration-300 ease-out group-hover/tile:scale-[1.01]"
|
|
||||||
>
|
|
||||||
{#each tiles as item, i (i)}
|
{#each tiles as item, i (i)}
|
||||||
<Cover
|
<Cover
|
||||||
trackId={item.trackId ?? item.videoId ?? ''}
|
trackId={item.trackId ?? item.videoId ?? ''}
|
||||||
src={item.thumbnailUrl}
|
src={item.thumbnailUrl}
|
||||||
hue={hueFor(mixItemKey(item))}
|
|
||||||
size="medium"
|
size="medium"
|
||||||
class="aspect-square h-full shrink-0"
|
class="aspect-square h-full shrink-0"
|
||||||
/>
|
/>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
<div
|
|
||||||
class="pointer-events-none absolute inset-0 bg-black/10 transition-colors duration-300 group-hover/tile:bg-black/20"
|
|
||||||
></div>
|
|
||||||
<div
|
<div
|
||||||
class="group relative flex h-full flex-col justify-end p-3 sm:p-4 {banner
|
class="pointer-events-none absolute inset-0 bg-linear-to-t from-scrim/85 via-scrim/65 to-scrim/40 {banner
|
||||||
|
? 'sm:bg-linear-to-r'
|
||||||
|
: ''}"
|
||||||
|
></div>
|
||||||
|
<div
|
||||||
|
class="pointer-events-none absolute inset-0 bg-scrim/25 opacity-0 transition-opacity duration-300 group-hover/tile:opacity-100"
|
||||||
|
></div>
|
||||||
|
<div
|
||||||
|
class="relative flex h-full flex-col justify-end p-3 sm:p-4 {banner
|
||||||
? 'sm:flex-row sm:items-center sm:justify-between sm:gap-4'
|
? 'sm:flex-row sm:items-center sm:justify-between sm:gap-4'
|
||||||
: ''}"
|
: ''}"
|
||||||
>
|
>
|
||||||
<div class="min-w-0">
|
<div class="min-w-0">
|
||||||
<div
|
<div class="truncate text-on-art {featured ? 'text-xl sm:text-2xl' : 'text-base'}">
|
||||||
class="truncate text-white {featured ? 'text-xl sm:text-2xl' : 'text-base'} group-hover:text-accent-soft transition-colors"
|
|
||||||
>
|
|
||||||
{mix.title}
|
{mix.title}
|
||||||
</div>
|
</div>
|
||||||
{#if mix.subtitle}
|
{#if mix.subtitle}
|
||||||
<div class="mt-0.5 truncate text-xs text-muted sm:text-sm">{mix.subtitle}</div>
|
<div class="mt-0.5 truncate text-xs text-on-art/75 sm:text-sm">{mix.subtitle}</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,17 +1,15 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Cover from './Cover.svelte';
|
import Cover from './Cover.svelte';
|
||||||
import { gradientForHue, hueFor } from '$lib/theme/color';
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
playlistId?: string;
|
playlistId?: string;
|
||||||
trackIds: (string | number)[];
|
trackIds: (string | number)[];
|
||||||
hue: number;
|
|
||||||
size?: 'small' | 'medium' | 'large';
|
size?: 'small' | 'medium' | 'large';
|
||||||
version?: string;
|
version?: string;
|
||||||
class?: string;
|
class?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { playlistId, trackIds, hue, size = 'medium', version, class: klass = '' }: Props = $props();
|
let { playlistId, trackIds, size = 'medium', version, class: klass = '' }: Props = $props();
|
||||||
|
|
||||||
let coverFailed = $state(false);
|
let coverFailed = $state(false);
|
||||||
|
|
||||||
@@ -20,7 +18,7 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if playlistId && !coverFailed}
|
{#if playlistId && !coverFailed}
|
||||||
<div class="relative overflow-hidden {klass}" style="background:{gradientForHue(hue)}">
|
<div class="relative overflow-hidden bg-surface {klass}">
|
||||||
<img
|
<img
|
||||||
src="/api/playlists/{playlistId}/cover?size={size}{version
|
src="/api/playlists/{playlistId}/cover?size={size}{version
|
||||||
? `&v=${encodeURIComponent(version)}`
|
? `&v=${encodeURIComponent(version)}`
|
||||||
@@ -34,11 +32,11 @@
|
|||||||
{:else if mosaic}
|
{:else if mosaic}
|
||||||
<div class="grid grid-cols-2 grid-rows-2 overflow-hidden {klass}">
|
<div class="grid grid-cols-2 grid-rows-2 overflow-hidden {klass}">
|
||||||
{#each trackIds.slice(0, 4) as id (id)}
|
{#each trackIds.slice(0, 4) as id (id)}
|
||||||
<Cover trackId={id} hue={hueFor(id)} size="small" class="h-full w-full" />
|
<Cover trackId={id} size="small" class="h-full w-full" />
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{:else if single !== null}
|
{:else if single !== null}
|
||||||
<Cover trackId={single} hue={hueFor(single)} size="medium" class={klass} />
|
<Cover trackId={single} size="medium" class={klass} />
|
||||||
{:else}
|
{:else}
|
||||||
<div class={klass} style="background:{gradientForHue(hue)}"></div>
|
<div class="bg-surface {klass}"></div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import MediaCard from './MediaCard.svelte';
|
import MediaCard from './MediaCard.svelte';
|
||||||
import PlaylistArt from './PlaylistArt.svelte';
|
import PlaylistArt from './PlaylistArt.svelte';
|
||||||
import { hueFor } from '$lib/theme/color';
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -17,6 +16,6 @@
|
|||||||
|
|
||||||
<MediaCard href="/playlists/{id}" title={name} subtitle={description} {index}>
|
<MediaCard href="/playlists/{id}" title={name} subtitle={description} {index}>
|
||||||
{#snippet art(artClass)}
|
{#snippet art(artClass)}
|
||||||
<PlaylistArt playlistId={id} {trackIds} hue={hueFor(id)} version={updatedAt} class={artClass} />
|
<PlaylistArt playlistId={id} {trackIds} version={updatedAt} class={artClass} />
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</MediaCard>
|
</MediaCard>
|
||||||
|
|||||||
@@ -18,9 +18,7 @@
|
|||||||
{@render actions()}
|
{@render actions()}
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<h2
|
<h2 class="mb-3 font-display font-medium text-fg sm:mb-4 sm:text-xl {klass}">
|
||||||
class="mb-3 font-display font-medium text-fg sm:mb-4 sm:text-xl {klass}"
|
|
||||||
>
|
|
||||||
{title}
|
{title}
|
||||||
</h2>
|
</h2>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -9,7 +9,6 @@
|
|||||||
artist?: string | null;
|
artist?: string | null;
|
||||||
coverSrc?: string | null;
|
coverSrc?: string | null;
|
||||||
coverSize?: string;
|
coverSize?: string;
|
||||||
hue: number;
|
|
||||||
explicit?: boolean;
|
explicit?: boolean;
|
||||||
titleClass?: string;
|
titleClass?: string;
|
||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
@@ -23,7 +22,6 @@
|
|||||||
artist,
|
artist,
|
||||||
coverSrc,
|
coverSrc,
|
||||||
coverSize = 'h-[42px] w-[42px]',
|
coverSize = 'h-[42px] w-[42px]',
|
||||||
hue,
|
|
||||||
explicit = false,
|
explicit = false,
|
||||||
titleClass = '',
|
titleClass = '',
|
||||||
onClick,
|
onClick,
|
||||||
@@ -36,7 +34,6 @@
|
|||||||
<Cover
|
<Cover
|
||||||
{trackId}
|
{trackId}
|
||||||
src={coverSrc}
|
src={coverSrc}
|
||||||
{hue}
|
|
||||||
size="small"
|
size="small"
|
||||||
alt={title}
|
alt={title}
|
||||||
class="{coverSize} shrink-0 rounded-control"
|
class="{coverSize} shrink-0 rounded-control"
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ export const ALBUM_TRACKS_PAGE_SIZE = 200;
|
|||||||
export const ALBUM_EARLIEST_YEAR = 1877;
|
export const ALBUM_EARLIEST_YEAR = 1877;
|
||||||
export const LIBRARY_PICKER_PAGE_SIZE = 50;
|
export const LIBRARY_PICKER_PAGE_SIZE = 50;
|
||||||
export const EXPLORE_ALBUMS_PAGE_SIZE = 10;
|
export const EXPLORE_ALBUMS_PAGE_SIZE = 10;
|
||||||
|
export const EXPLORE_ALBUMS_LIMIT = 18;
|
||||||
export const HOME_LATEST_PAGE_SIZE = 10;
|
export const HOME_LATEST_PAGE_SIZE = 10;
|
||||||
export const HOME_ALBUMS_LIMIT = 12;
|
export const HOME_ALBUMS_LIMIT = 12;
|
||||||
export const HOME_MIXES_BENTO = 5;
|
export const HOME_MIXES_BENTO = 5;
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { afterNavigate } from '$app/navigation';
|
||||||
|
|
||||||
|
const HISTORY_INDEX = 'sveltekit:history';
|
||||||
|
|
||||||
|
const LABELS: [RegExp, string][] = [
|
||||||
|
[/^\/$/, 'Volver al inicio'],
|
||||||
|
[/^\/explore\/?$/, 'Volver a explorar'],
|
||||||
|
[/^\/playlists\/[^/]+/, 'Volver a la lista'],
|
||||||
|
[/^\/playlists\/?$/, 'Volver a tus listas'],
|
||||||
|
[/^\/albums\/youtube\/[^/]+/, 'Volver al álbum'],
|
||||||
|
[/^\/albums\/[^/]+/, 'Volver al álbum'],
|
||||||
|
[/^\/albums\/?$/, 'Volver a tus álbumes'],
|
||||||
|
[/^\/library\/?$/, 'Volver a tus canciones'],
|
||||||
|
[/^\/upload\/?$/, 'Volver a subir música'],
|
||||||
|
[/^\/mixes\/[^/]+/, 'Volver a la mezcla']
|
||||||
|
];
|
||||||
|
|
||||||
|
export interface PageRef {
|
||||||
|
url: string;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const visited = new Map<number, PageRef>();
|
||||||
|
let index = $state(-1);
|
||||||
|
|
||||||
|
function labelFor(pathname: string): string | null {
|
||||||
|
for (const [pattern, label] of LABELS) {
|
||||||
|
if (pattern.test(pathname)) return label;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function historyIndex(): number | null {
|
||||||
|
const value = history.state?.[HISTORY_INDEX];
|
||||||
|
return typeof value === 'number' ? value : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function trackNavigation() {
|
||||||
|
afterNavigate((nav) => {
|
||||||
|
const at = historyIndex();
|
||||||
|
if (at === null) return;
|
||||||
|
const url = nav.to?.url;
|
||||||
|
const label = url ? labelFor(url.pathname) : null;
|
||||||
|
if (url && label) visited.set(at, { url: url.pathname + url.search, label });
|
||||||
|
else visited.delete(at);
|
||||||
|
index = at;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function previousPage(): PageRef | null {
|
||||||
|
return visited.get(index - 1) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isSectionActive(href: string, pathname: string, section?: string | null): boolean {
|
||||||
|
if (section !== undefined) return section === href;
|
||||||
|
return href === '/' ? pathname === '/' : pathname.startsWith(href);
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { browser } from '$app/environment';
|
import { browser } from '$app/environment';
|
||||||
import { HUES, accentForHue, gradientForHue, hueFor } from '$lib/theme/color';
|
import { DEFAULT_ACCENT } from '$lib/theme/color';
|
||||||
import { extractAccent, type Accent } from '$lib/theme/palette';
|
import { extractAccent, type Accent } from '$lib/theme/palette';
|
||||||
import { thumbnailSrc } from '$lib/thumbnails';
|
import { thumbnailSrc } from '$lib/thumbnails';
|
||||||
|
|
||||||
@@ -10,7 +10,6 @@ export interface PlayerTrack {
|
|||||||
title: string;
|
title: string;
|
||||||
artist: string;
|
artist: string;
|
||||||
duration: number;
|
duration: number;
|
||||||
hue: number;
|
|
||||||
source: TrackSourceKind;
|
source: TrackSourceKind;
|
||||||
coverUrl?: string;
|
coverUrl?: string;
|
||||||
explicit?: boolean;
|
explicit?: boolean;
|
||||||
@@ -91,7 +90,6 @@ const EMPTY: PlayerTrack = {
|
|||||||
title: '',
|
title: '',
|
||||||
artist: '',
|
artist: '',
|
||||||
duration: 0,
|
duration: 0,
|
||||||
hue: HUES[0],
|
|
||||||
source: 'local'
|
source: 'local'
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -101,7 +99,6 @@ function toTrack(item: QueueItem): PlayerTrack {
|
|||||||
title: item.title,
|
title: item.title,
|
||||||
artist: item.artist ?? '',
|
artist: item.artist ?? '',
|
||||||
duration: 0,
|
duration: 0,
|
||||||
hue: hueFor(item.id),
|
|
||||||
explicit: item.explicit,
|
explicit: item.explicit,
|
||||||
source: item.source ?? 'local',
|
source: item.source ?? 'local',
|
||||||
coverUrl: item.coverUrl
|
coverUrl: item.coverUrl
|
||||||
@@ -120,7 +117,6 @@ class PlayerState {
|
|||||||
playlists = $state<Playlist[]>([]);
|
playlists = $state<Playlist[]>([]);
|
||||||
|
|
||||||
accentColor = $state<string | null>(null);
|
accentColor = $state<string | null>(null);
|
||||||
gradientColor = $state<string | null>(null);
|
|
||||||
|
|
||||||
#audio: HTMLAudioElement | null = null;
|
#audio: HTMLAudioElement | null = null;
|
||||||
#loadToken = 0;
|
#loadToken = 0;
|
||||||
@@ -129,8 +125,7 @@ class PlayerState {
|
|||||||
#rafId: number | null = null;
|
#rafId: number | null = null;
|
||||||
|
|
||||||
current = $derived(this.tracks.find((t) => t.id === this.currentId) ?? EMPTY);
|
current = $derived(this.tracks.find((t) => t.id === this.currentId) ?? EMPTY);
|
||||||
accent = $derived(this.accentColor ?? accentForHue(this.current.hue));
|
accent = $derived(this.accentColor ?? DEFAULT_ACCENT);
|
||||||
artGradient = $derived(this.gradientColor ?? gradientForHue(this.current.hue));
|
|
||||||
progressPercent = $derived(
|
progressPercent = $derived(
|
||||||
this.current.duration > 0 ? Math.min(100, (this.progress / this.current.duration) * 100) : 0
|
this.current.duration > 0 ? Math.min(100, (this.progress / this.current.duration) * 100) : 0
|
||||||
);
|
);
|
||||||
@@ -250,7 +245,6 @@ class PlayerState {
|
|||||||
const cached = this.#accentCache.get(key);
|
const cached = this.#accentCache.get(key);
|
||||||
if (cached) {
|
if (cached) {
|
||||||
this.accentColor = cached.accent;
|
this.accentColor = cached.accent;
|
||||||
this.gradientColor = cached.gradient;
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const result = await extractAccent(
|
const result = await extractAccent(
|
||||||
@@ -260,10 +254,8 @@ class PlayerState {
|
|||||||
if (result) {
|
if (result) {
|
||||||
this.#accentCache.set(key, result);
|
this.#accentCache.set(key, result);
|
||||||
this.accentColor = result.accent;
|
this.accentColor = result.accent;
|
||||||
this.gradientColor = result.gradient;
|
|
||||||
} else {
|
} else {
|
||||||
this.accentColor = null;
|
this.accentColor = null;
|
||||||
this.gradientColor = null;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -335,14 +327,6 @@ class PlayerState {
|
|||||||
.filter((t): t is PlayerTrack => Boolean(t));
|
.filter((t): t is PlayerTrack => Boolean(t));
|
||||||
}
|
}
|
||||||
|
|
||||||
mosaicFor(ids: (string | number)[]): string[] {
|
|
||||||
const gradients = this.playlistTracks(ids)
|
|
||||||
.slice(0, 4)
|
|
||||||
.map((t) => gradientForHue(t.hue));
|
|
||||||
while (gradients.length < 4) gradients.push('var(--mf-surface)');
|
|
||||||
return gradients;
|
|
||||||
}
|
|
||||||
|
|
||||||
playQueue(list: QueueItem[], startIndex = 0) {
|
playQueue(list: QueueItem[], startIndex = 0) {
|
||||||
if (list.length === 0) return;
|
if (list.length === 0) return;
|
||||||
this.tracks = list.map(toTrack);
|
this.tracks = list.map(toTrack);
|
||||||
|
|||||||
@@ -1,20 +1,5 @@
|
|||||||
export const HUES = [145, 330, 40, 152, 300, 200, 10, 120, 280];
|
|
||||||
|
|
||||||
export const ACCENT_LIGHTNESS = 60;
|
export const ACCENT_LIGHTNESS = 60;
|
||||||
export const ACCENT_CHROMA = 0.17;
|
export const ACCENT_CHROMA = 0.17;
|
||||||
export const ACCENT_HUE = 145;
|
export const ACCENT_HUE = 145;
|
||||||
|
|
||||||
export function hueFor(id: string | number): number {
|
export const DEFAULT_ACCENT = `oklch(${ACCENT_LIGHTNESS}% ${ACCENT_CHROMA} ${ACCENT_HUE})`;
|
||||||
const text = String(id);
|
|
||||||
let hash = 0;
|
|
||||||
for (let i = 0; i < text.length; i++) hash = (hash * 31 + text.charCodeAt(i)) >>> 0;
|
|
||||||
return HUES[hash % HUES.length];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function accentForHue(hue: number): string {
|
|
||||||
return `oklch(${ACCENT_LIGHTNESS}% ${ACCENT_CHROMA} ${hue})`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function gradientForHue(hue: number): string {
|
|
||||||
return `linear-gradient(135deg, oklch(70% 0.15 ${hue}), oklch(36% 0.13 ${hue + 28}))`;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<title>Álbumes · Musify</title>
|
<title>Álbumes</title>
|
||||||
<meta name="description" content="Los álbumes que has publicado con tu música subida." />
|
<meta name="description" content="Los álbumes que has publicado con tu música subida." />
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
|
|||||||
@@ -38,7 +38,9 @@ export const load: PageServerLoad = async ({ params, locals, url, fetch }) => {
|
|||||||
(track) => track.source === 'Local' && !inAlbum.has(track.id)
|
(track) => track.source === 'Local' && !inAlbum.has(track.id)
|
||||||
);
|
);
|
||||||
|
|
||||||
return { album, tracks, library, isOwner: Number(album.ownerUserId) === Number(user.sub) };
|
const isOwner = Number(album.ownerUserId) === Number(user.sub);
|
||||||
|
|
||||||
|
return { album, tracks, library, isOwner, section: isOwner ? '/albums' : null };
|
||||||
};
|
};
|
||||||
|
|
||||||
export const actions: Actions = {
|
export const actions: Actions = {
|
||||||
|
|||||||
@@ -31,13 +31,12 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<title>{album.title} · Musify</title>
|
<title>{album.title}</title>
|
||||||
<meta name="description" content="Álbum {album.title} en Musify." />
|
<meta name="description" content="Álbum {album.title}." />
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<Page>
|
<Page>
|
||||||
<AlbumHeader
|
<AlbumHeader
|
||||||
id={album.id}
|
|
||||||
title={album.title}
|
title={album.title}
|
||||||
description={album.description ?? undefined}
|
description={album.description ?? undefined}
|
||||||
releaseYear={album.releaseYear === null ? undefined : Number(album.releaseYear)}
|
releaseYear={album.releaseYear === null ? undefined : Number(album.releaseYear)}
|
||||||
|
|||||||
@@ -1,15 +1,13 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import ArrowLeft from '@lucide/svelte/icons/arrow-left';
|
|
||||||
import Play from '@lucide/svelte/icons/play';
|
import Play from '@lucide/svelte/icons/play';
|
||||||
import Pause from '@lucide/svelte/icons/pause';
|
import Pause from '@lucide/svelte/icons/pause';
|
||||||
import SquarePencil from '@lucide/svelte/icons/square-pen';
|
import SquarePencil from '@lucide/svelte/icons/square-pen';
|
||||||
import Trash from '@lucide/svelte/icons/trash';
|
import Trash from '@lucide/svelte/icons/trash';
|
||||||
import PlaylistArt from '$lib/components/ui/PlaylistArt.svelte';
|
import PlaylistArt from '$lib/components/ui/PlaylistArt.svelte';
|
||||||
|
import BackLink from '$lib/components/ui/BackLink.svelte';
|
||||||
import Button from '$lib/components/ui/Button.svelte';
|
import Button from '$lib/components/ui/Button.svelte';
|
||||||
import { hueFor } from '$lib/theme/color';
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
id: string;
|
|
||||||
title: string;
|
title: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
releaseYear?: number;
|
releaseYear?: number;
|
||||||
@@ -22,7 +20,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
let {
|
let {
|
||||||
id,
|
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
releaseYear,
|
releaseYear,
|
||||||
@@ -44,20 +41,14 @@
|
|||||||
);
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<a
|
<BackLink
|
||||||
href="/albums"
|
href={isOwner ? '/albums' : '/'}
|
||||||
class="group inline-flex items-center text-fg-3 transition-colors hover:text-accent-soft"
|
label={isOwner ? 'Volver a tus álbumes' : 'Volver al inicio'}
|
||||||
>
|
/>
|
||||||
<span class="grid h-8 w-8 place-items-center transition-colors group-hover:text-accent-soft">
|
|
||||||
<ArrowLeft class="h-4 w-4" />
|
|
||||||
</span>
|
|
||||||
Volver a tus álbumes
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<div class="mt-5 flex flex-col gap-5 sm:mt-6 sm:flex-row sm:items-end sm:gap-6">
|
<div class="mt-5 flex flex-col gap-5 sm:mt-6 sm:flex-row sm:items-end sm:gap-6">
|
||||||
<PlaylistArt
|
<PlaylistArt
|
||||||
{trackIds}
|
{trackIds}
|
||||||
hue={hueFor(id)}
|
|
||||||
size="large"
|
size="large"
|
||||||
class="h-36 w-36 shrink-0 rounded-art-lg shadow-art-lg sm:h-44 sm:w-44"
|
class="h-36 w-36 shrink-0 rounded-art-lg shadow-art-lg sm:h-44 sm:w-44"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -7,7 +7,6 @@
|
|||||||
toQueueItems,
|
toQueueItems,
|
||||||
type ApiTrackLike
|
type ApiTrackLike
|
||||||
} from '$lib/player/player.svelte';
|
} from '$lib/player/player.svelte';
|
||||||
import { hueFor } from '$lib/theme/color';
|
|
||||||
import { fmtTime } from '$lib/format';
|
import { fmtTime } from '$lib/format';
|
||||||
import IconButton from '$lib/components/ui/IconButton.svelte';
|
import IconButton from '$lib/components/ui/IconButton.svelte';
|
||||||
import TrackTable from '$lib/components/ui/TrackTable.svelte';
|
import TrackTable from '$lib/components/ui/TrackTable.svelte';
|
||||||
@@ -43,7 +42,6 @@
|
|||||||
trackId={track.id}
|
trackId={track.id}
|
||||||
title={track.title}
|
title={track.title}
|
||||||
artist={track.artist}
|
artist={track.artist}
|
||||||
hue={hueFor(track.id)}
|
|
||||||
explicit={track.isExplicit}
|
explicit={track.isExplicit}
|
||||||
onClick={() => playFrom(i)}
|
onClick={() => playFrom(i)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
import { enhance } from '$app/forms';
|
import { enhance } from '$app/forms';
|
||||||
import Plus from '@lucide/svelte/icons/plus';
|
import Plus from '@lucide/svelte/icons/plus';
|
||||||
import { player, toQueueItems, type ApiTrackLike } from '$lib/player/player.svelte';
|
import { player, toQueueItems, type ApiTrackLike } from '$lib/player/player.svelte';
|
||||||
import { hueFor } from '$lib/theme/color';
|
|
||||||
import { fmtTime } from '$lib/format';
|
import { fmtTime } from '$lib/format';
|
||||||
import SectionHeading from '$lib/components/ui/SectionHeading.svelte';
|
import SectionHeading from '$lib/components/ui/SectionHeading.svelte';
|
||||||
import IconButton from '$lib/components/ui/IconButton.svelte';
|
import IconButton from '$lib/components/ui/IconButton.svelte';
|
||||||
@@ -34,7 +33,6 @@
|
|||||||
trackId={track.id}
|
trackId={track.id}
|
||||||
title={track.title}
|
title={track.title}
|
||||||
artist={track.artist}
|
artist={track.artist}
|
||||||
hue={hueFor(track.id)}
|
|
||||||
explicit={track.isExplicit}
|
explicit={track.isExplicit}
|
||||||
onClick={() => playFrom(i)}
|
onClick={() => playFrom(i)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ export const load: PageServerLoad = async ({ params, locals, url, fetch }) => {
|
|||||||
|
|
||||||
const album = unwrapOrError(albumRes, 'No se pudo cargar el álbum de YouTube Music.', 404);
|
const album = unwrapOrError(albumRes, 'No se pudo cargar el álbum de YouTube Music.', 404);
|
||||||
|
|
||||||
return { album, playlists: playlistsRes.data?.items ?? [] };
|
return { album, playlists: playlistsRes.data?.items ?? [], section: null };
|
||||||
};
|
};
|
||||||
|
|
||||||
export const actions: Actions = {
|
export const actions: Actions = {
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import ArrowLeft from '@lucide/svelte/icons/arrow-left';
|
|
||||||
import Play from '@lucide/svelte/icons/play';
|
import Play from '@lucide/svelte/icons/play';
|
||||||
import Pause from '@lucide/svelte/icons/pause';
|
import Pause from '@lucide/svelte/icons/pause';
|
||||||
import { player, type QueueItem } from '$lib/player/player.svelte';
|
import { player, type QueueItem } from '$lib/player/player.svelte';
|
||||||
import { hueFor } from '$lib/theme/color';
|
|
||||||
import { fmtTime } from '$lib/format';
|
import { fmtTime } from '$lib/format';
|
||||||
import Page from '$lib/components/ui/Page.svelte';
|
import Page from '$lib/components/ui/Page.svelte';
|
||||||
|
import BackLink from '$lib/components/ui/BackLink.svelte';
|
||||||
import Button from '$lib/components/ui/Button.svelte';
|
import Button from '$lib/components/ui/Button.svelte';
|
||||||
import Cover from '$lib/components/ui/Cover.svelte';
|
import Cover from '$lib/components/ui/Cover.svelte';
|
||||||
import Alert from '$lib/components/ui/Alert.svelte';
|
import Alert from '$lib/components/ui/Alert.svelte';
|
||||||
@@ -73,26 +72,17 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<title>{album.title} · Musify</title>
|
<title>{album.title}</title>
|
||||||
<meta name="description" content="Álbum {album.title} de {album.artist} en YouTube Music." />
|
<meta name="description" content="Álbum {album.title} de {album.artist}," />
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<Page>
|
<Page>
|
||||||
<a
|
<BackLink href="/explore" label="Volver a explorar" />
|
||||||
href="/explore"
|
|
||||||
class="group inline-flex items-center text-fg-3 transition-colors hover:text-accent-soft"
|
|
||||||
>
|
|
||||||
<span class="grid h-8 w-8 place-items-center transition-colors group-hover:text-accent-soft">
|
|
||||||
<ArrowLeft class="h-4 w-4" />
|
|
||||||
</span>
|
|
||||||
Volver a explorar
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<div class="mt-5 flex flex-col gap-5 sm:mt-6 sm:flex-row sm:items-end sm:gap-6">
|
<div class="mt-5 flex flex-col gap-5 sm:mt-6 sm:flex-row sm:items-end sm:gap-6">
|
||||||
<Cover
|
<Cover
|
||||||
trackId={album.albumId}
|
trackId={album.albumId}
|
||||||
src={album.thumbnailUrl}
|
src={album.thumbnailUrl}
|
||||||
hue={hueFor(album.albumId)}
|
|
||||||
size="large"
|
size="large"
|
||||||
alt={album.title}
|
alt={album.title}
|
||||||
class="h-36 w-36 shrink-0 rounded-art-lg shadow-art-lg sm:h-44 sm:w-44"
|
class="h-36 w-36 shrink-0 rounded-art-lg shadow-art-lg sm:h-44 sm:w-44"
|
||||||
@@ -100,7 +90,7 @@
|
|||||||
/>
|
/>
|
||||||
<div class="min-w-0 flex-1">
|
<div class="min-w-0 flex-1">
|
||||||
<p class="text-sm tracking-[0.14em] text-fg-3 uppercase">
|
<p class="text-sm tracking-[0.14em] text-fg-3 uppercase">
|
||||||
{album.isSingle ? 'Single' : album.isEp ? 'EP' : 'Álbum'} · YouTube Music
|
{album.isSingle ? 'Single' : album.isEp ? 'EP' : 'Álbum'}
|
||||||
</p>
|
</p>
|
||||||
<h1
|
<h1
|
||||||
class="mt-1.5 text-3xl font-semibold tracking-tight break-words text-fg sm:text-5xl md:text-7xl"
|
class="mt-1.5 text-3xl font-semibold tracking-tight break-words text-fg sm:text-5xl md:text-7xl"
|
||||||
@@ -154,7 +144,6 @@
|
|||||||
title={track.title}
|
title={track.title}
|
||||||
artist={album.artist}
|
artist={album.artist}
|
||||||
coverSrc={album.thumbnailUrl}
|
coverSrc={album.thumbnailUrl}
|
||||||
hue={hueFor(track.videoId)}
|
|
||||||
explicit={track.isExplicit}
|
explicit={track.isExplicit}
|
||||||
onClick={() => playFrom(i)}
|
onClick={() => playFrom(i)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -19,8 +19,7 @@
|
|||||||
import NowPlaying from '$lib/components/ui/NowPlaying.svelte';
|
import NowPlaying from '$lib/components/ui/NowPlaying.svelte';
|
||||||
import ExplicitBadge from '$lib/components/ui/ExplicitBadge.svelte';
|
import ExplicitBadge from '$lib/components/ui/ExplicitBadge.svelte';
|
||||||
import SectionHeading from '$lib/components/ui/SectionHeading.svelte';
|
import SectionHeading from '$lib/components/ui/SectionHeading.svelte';
|
||||||
import { EXPLORE_PAGE_SIZE, SEARCH_DEBOUNCE_MS } from '$lib/config';
|
import { EXPLORE_ALBUMS_LIMIT, EXPLORE_PAGE_SIZE, SEARCH_DEBOUNCE_MS } from '$lib/config';
|
||||||
import { hueFor } from '$lib/theme/color';
|
|
||||||
import type { YouTubeSong } from '$lib/types';
|
import type { YouTubeSong } from '$lib/types';
|
||||||
import TrackContextMenu, {
|
import TrackContextMenu, {
|
||||||
contextMenuStateFor,
|
contextMenuStateFor,
|
||||||
@@ -113,10 +112,12 @@
|
|||||||
type YouTubeAlbum = (typeof data.youtubeAlbums)[number];
|
type YouTubeAlbum = (typeof data.youtubeAlbums)[number];
|
||||||
type AlbumEntry = { kind: 'local'; album: LocalAlbum } | { kind: 'youtube'; album: YouTubeAlbum };
|
type AlbumEntry = { kind: 'local'; album: LocalAlbum } | { kind: 'youtube'; album: YouTubeAlbum };
|
||||||
|
|
||||||
const albumEntries = $derived<AlbumEntry[]>([
|
const albumEntries = $derived<AlbumEntry[]>(
|
||||||
|
[
|
||||||
...albums.map((album) => ({ kind: 'local' as const, album })),
|
...albums.map((album) => ({ kind: 'local' as const, album })),
|
||||||
...youtubeAlbums.map((album) => ({ kind: 'youtube' as const, album }))
|
...youtubeAlbums.map((album) => ({ kind: 'youtube' as const, album }))
|
||||||
]);
|
].slice(0, EXPLORE_ALBUMS_LIMIT)
|
||||||
|
);
|
||||||
|
|
||||||
const hasAlbums = $derived(albumEntries.length > 0);
|
const hasAlbums = $derived(albumEntries.length > 0);
|
||||||
const hasUsers = $derived(users.length > 0);
|
const hasUsers = $derived(users.length > 0);
|
||||||
@@ -215,7 +216,7 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<title>Buscar · Musify</title>
|
<title>Buscar</title>
|
||||||
<meta name="description" content="Busca canciones, álbumes y usuarios en Musify." />
|
<meta name="description" content="Busca canciones, álbumes y usuarios en Musify." />
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
@@ -261,7 +262,6 @@
|
|||||||
<div class="relative">
|
<div class="relative">
|
||||||
<PlaylistArt
|
<PlaylistArt
|
||||||
trackIds={entry.album.coverTrackIds}
|
trackIds={entry.album.coverTrackIds}
|
||||||
hue={hueFor(entry.album.id)}
|
|
||||||
class="aspect-square w-full rounded-art shadow-art ring-1 ring-line transition duration-300 ease-out ring-inset group-hover/card:shadow-art-lg"
|
class="aspect-square w-full rounded-art shadow-art ring-1 ring-line transition duration-300 ease-out ring-inset group-hover/card:shadow-art-lg"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -285,7 +285,6 @@
|
|||||||
<Cover
|
<Cover
|
||||||
trackId={entry.album.albumId}
|
trackId={entry.album.albumId}
|
||||||
src={entry.album.thumbnailUrl}
|
src={entry.album.thumbnailUrl}
|
||||||
hue={hueFor(entry.album.albumId)}
|
|
||||||
size="large"
|
size="large"
|
||||||
alt={entry.album.title}
|
alt={entry.album.title}
|
||||||
class="aspect-square w-full rounded-art shadow-art ring-1 ring-line transition duration-300 ease-out ring-inset group-hover/card:shadow-art-lg"
|
class="aspect-square w-full rounded-art shadow-art ring-1 ring-line transition duration-300 ease-out ring-inset group-hover/card:shadow-art-lg"
|
||||||
@@ -316,7 +315,7 @@
|
|||||||
class="group/artist animate-enter shrink-0"
|
class="group/artist animate-enter shrink-0"
|
||||||
style="animation-delay:{Math.min(i, 10) * 45}ms"
|
style="animation-delay:{Math.min(i, 10) * 45}ms"
|
||||||
>
|
>
|
||||||
<ArtistAvatar id={u.id} name={u.name} size={96} />
|
<ArtistAvatar name={u.name} size={96} />
|
||||||
<div class="mt-1 w-24 truncate text-center text-xs text-fg-3">@{u.handle}</div>
|
<div class="mt-1 w-24 truncate text-center text-xs text-fg-3">@{u.handle}</div>
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
@@ -339,7 +338,6 @@
|
|||||||
<Cover
|
<Cover
|
||||||
trackId={targetId(item)}
|
trackId={targetId(item)}
|
||||||
src={targetCoverSrc(item)}
|
src={targetCoverSrc(item)}
|
||||||
hue={hueFor(targetId(item))}
|
|
||||||
size="small"
|
size="small"
|
||||||
alt={targetTitle(item)}
|
alt={targetTitle(item)}
|
||||||
class="h-11 w-11 shrink-0 rounded-control shadow-art ring-1 ring-line ring-inset"
|
class="h-11 w-11 shrink-0 rounded-control shadow-art ring-1 ring-line ring-inset"
|
||||||
|
|||||||
@@ -4,7 +4,6 @@
|
|||||||
import X from '@lucide/svelte/icons/x';
|
import X from '@lucide/svelte/icons/x';
|
||||||
import Music from '@lucide/svelte/icons/music';
|
import Music from '@lucide/svelte/icons/music';
|
||||||
import { player, toQueueItems } from '$lib/player/player.svelte';
|
import { player, toQueueItems } from '$lib/player/player.svelte';
|
||||||
import { hueFor } from '$lib/theme/color';
|
|
||||||
import { fmtTime, fmtDate } from '$lib/format';
|
import { fmtTime, fmtDate } from '$lib/format';
|
||||||
import Page from '$lib/components/ui/Page.svelte';
|
import Page from '$lib/components/ui/Page.svelte';
|
||||||
import PageHeader from '$lib/components/ui/PageHeader.svelte';
|
import PageHeader from '$lib/components/ui/PageHeader.svelte';
|
||||||
@@ -28,7 +27,7 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<title>Tus canciones subidas · Musify</title>
|
<title>Tus canciones subidas</title>
|
||||||
<meta name="description" content="Todas tus canciones subidas a Musify." />
|
<meta name="description" content="Todas tus canciones subidas a Musify." />
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
@@ -65,12 +64,7 @@
|
|||||||
playing={player.playing}
|
playing={player.playing}
|
||||||
onToggle={() => togglePlay(i)}
|
onToggle={() => togglePlay(i)}
|
||||||
/>
|
/>
|
||||||
<TrackTitleCell
|
<TrackTitleCell trackId={track.id} title={track.title} onClick={() => togglePlay(i)} />
|
||||||
trackId={track.id}
|
|
||||||
title={track.title}
|
|
||||||
hue={hueFor(track.id)}
|
|
||||||
onClick={() => togglePlay(i)}
|
|
||||||
/>
|
|
||||||
<TrackMeta class="hidden sm:block">{fmtDate(track.createdAt)}</TrackMeta>
|
<TrackMeta class="hidden sm:block">{fmtDate(track.createdAt)}</TrackMeta>
|
||||||
<TrackMeta class="hidden sm:block">{Number(track.listensCount)}</TrackMeta>
|
<TrackMeta class="hidden sm:block">{Number(track.listensCount)}</TrackMeta>
|
||||||
<TrackMeta>{fmtTime(Number(track.duration))}</TrackMeta>
|
<TrackMeta>{fmtTime(Number(track.duration))}</TrackMeta>
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import ArrowLeft from '@lucide/svelte/icons/arrow-left';
|
|
||||||
import Play from '@lucide/svelte/icons/play';
|
import Play from '@lucide/svelte/icons/play';
|
||||||
import Pause from '@lucide/svelte/icons/pause';
|
import Pause from '@lucide/svelte/icons/pause';
|
||||||
import Shuffle from '@lucide/svelte/icons/shuffle';
|
import Shuffle from '@lucide/svelte/icons/shuffle';
|
||||||
import { player } from '$lib/player/player.svelte';
|
import { player } from '$lib/player/player.svelte';
|
||||||
import { hueFor } from '$lib/theme/color';
|
|
||||||
import { fmtTime } from '$lib/format';
|
import { fmtTime } from '$lib/format';
|
||||||
import { shuffle } from '$lib/collections';
|
import { shuffle } from '$lib/collections';
|
||||||
import Page from '$lib/components/ui/Page.svelte';
|
import Page from '$lib/components/ui/Page.svelte';
|
||||||
|
import BackLink from '$lib/components/ui/BackLink.svelte';
|
||||||
import Button from '$lib/components/ui/Button.svelte';
|
import Button from '$lib/components/ui/Button.svelte';
|
||||||
import Alert from '$lib/components/ui/Alert.svelte';
|
import Alert from '$lib/components/ui/Alert.svelte';
|
||||||
import MixArt from '$lib/components/ui/MixArt.svelte';
|
import MixArt from '$lib/components/ui/MixArt.svelte';
|
||||||
@@ -61,27 +60,15 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<title>{mix.title} · Musify</title>
|
<title>{mix.title}</title>
|
||||||
<meta name="description" content="Mezcla {mix.title}, hecha para ti." />
|
<meta name="description" content="Mezcla {mix.title}, hecha para ti." />
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<Page>
|
<Page>
|
||||||
<a
|
<BackLink href="/" label="Volver al inicio" />
|
||||||
href="/"
|
|
||||||
class="group inline-flex items-center text-fg-3 transition-colors hover:text-accent-soft"
|
|
||||||
>
|
|
||||||
<span class="grid h-8 w-8 place-items-center transition-colors group-hover:text-accent-soft">
|
|
||||||
<ArrowLeft class="h-4 w-4" />
|
|
||||||
</span>
|
|
||||||
Volver al inicio
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<div class="mt-5 flex flex-col gap-5 sm:mt-6 sm:flex-row sm:items-end sm:gap-6">
|
<div class="mt-5 flex flex-col gap-5 sm:mt-6 sm:flex-row sm:items-end sm:gap-6">
|
||||||
<MixArt
|
<MixArt {items} class="h-36 w-36 shrink-0 rounded-art-lg shadow-art-lg sm:h-44 sm:w-44" />
|
||||||
{items}
|
|
||||||
hue={hueFor(mix.id)}
|
|
||||||
class="h-36 w-36 shrink-0 rounded-art-lg shadow-art-lg sm:h-44 sm:w-44"
|
|
||||||
/>
|
|
||||||
<div class="min-w-0 flex-1">
|
<div class="min-w-0 flex-1">
|
||||||
<p class="text-sm tracking-[0.14em] text-fg-3 uppercase">Mezcla</p>
|
<p class="text-sm tracking-[0.14em] text-fg-3 uppercase">Mezcla</p>
|
||||||
<h1
|
<h1
|
||||||
@@ -133,7 +120,6 @@
|
|||||||
title={targetTitle(targets[i])}
|
title={targetTitle(targets[i])}
|
||||||
artist={targetArtist(targets[i])}
|
artist={targetArtist(targets[i])}
|
||||||
coverSrc={targetCoverSrc(targets[i])}
|
coverSrc={targetCoverSrc(targets[i])}
|
||||||
hue={hueFor(mixItemKey(item))}
|
|
||||||
explicit={item.isExplicit}
|
explicit={item.isExplicit}
|
||||||
onClick={() => playFrom(i)}
|
onClick={() => playFrom(i)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -41,7 +41,7 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<title>Mis listas · Musify</title>
|
<title>Mis listas</title>
|
||||||
<meta name="description" content="Mis listas en Musify." />
|
<meta name="description" content="Mis listas en Musify." />
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
|
|||||||
@@ -36,7 +36,7 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<title>{playlist.name} · Musify</title>
|
<title>{playlist.name}</title>
|
||||||
<meta name="description" content="Playlist {playlist.name} en Musify." />
|
<meta name="description" content="Playlist {playlist.name} en Musify." />
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import ArrowLeft from '@lucide/svelte/icons/arrow-left';
|
|
||||||
import Play from '@lucide/svelte/icons/play';
|
import Play from '@lucide/svelte/icons/play';
|
||||||
import Pause from '@lucide/svelte/icons/pause';
|
import Pause from '@lucide/svelte/icons/pause';
|
||||||
import SquarePencil from '@lucide/svelte/icons/square-pen';
|
import SquarePencil from '@lucide/svelte/icons/square-pen';
|
||||||
import Trash from '@lucide/svelte/icons/trash';
|
import Trash from '@lucide/svelte/icons/trash';
|
||||||
import PlaylistArt from '$lib/components/ui/PlaylistArt.svelte';
|
import PlaylistArt from '$lib/components/ui/PlaylistArt.svelte';
|
||||||
|
import BackLink from '$lib/components/ui/BackLink.svelte';
|
||||||
import Button from '$lib/components/ui/Button.svelte';
|
import Button from '$lib/components/ui/Button.svelte';
|
||||||
import { hueFor } from '$lib/theme/color';
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -24,21 +23,12 @@
|
|||||||
$props();
|
$props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<a
|
<BackLink href="/playlists" label="Volver a tus listas" />
|
||||||
href="/playlists"
|
|
||||||
class="group inline-flex items-center text-fg-3 transition-colors hover:text-accent-soft"
|
|
||||||
>
|
|
||||||
<span class="grid h-8 w-8 place-items-center transition-colors group-hover:text-accent-soft">
|
|
||||||
<ArrowLeft class="h-4 w-4" />
|
|
||||||
</span>
|
|
||||||
Volver a tus listas
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<div class="mt-5 flex flex-col gap-5 sm:mt-6 sm:flex-row sm:items-end sm:gap-6">
|
<div class="mt-5 flex flex-col gap-5 sm:mt-6 sm:flex-row sm:items-end sm:gap-6">
|
||||||
<PlaylistArt
|
<PlaylistArt
|
||||||
playlistId={id}
|
playlistId={id}
|
||||||
{trackIds}
|
{trackIds}
|
||||||
hue={hueFor(id)}
|
|
||||||
size="large"
|
size="large"
|
||||||
version={updatedAt}
|
version={updatedAt}
|
||||||
class="h-36 w-36 shrink-0 rounded-art-lg shadow-art-lg sm:h-44 sm:w-44"
|
class="h-36 w-36 shrink-0 rounded-art-lg shadow-art-lg sm:h-44 sm:w-44"
|
||||||
|
|||||||
@@ -9,7 +9,6 @@
|
|||||||
youTubeThumbnailUrl,
|
youTubeThumbnailUrl,
|
||||||
type ApiTrackLike
|
type ApiTrackLike
|
||||||
} from '$lib/player/player.svelte';
|
} from '$lib/player/player.svelte';
|
||||||
import { hueFor } from '$lib/theme/color';
|
|
||||||
import { fmtTime, fmtDate } from '$lib/format';
|
import { fmtTime, fmtDate } from '$lib/format';
|
||||||
import Badge from '$lib/components/ui/Badge.svelte';
|
import Badge from '$lib/components/ui/Badge.svelte';
|
||||||
import IconButton from '$lib/components/ui/IconButton.svelte';
|
import IconButton from '$lib/components/ui/IconButton.svelte';
|
||||||
@@ -48,7 +47,6 @@
|
|||||||
coverSrc={isPendingYouTubeTrack(track) && track.externalId
|
coverSrc={isPendingYouTubeTrack(track) && track.externalId
|
||||||
? youTubeThumbnailUrl(track.externalId)
|
? youTubeThumbnailUrl(track.externalId)
|
||||||
: undefined}
|
: undefined}
|
||||||
hue={hueFor(track.id)}
|
|
||||||
explicit={track.isExplicit}
|
explicit={track.isExplicit}
|
||||||
onClick={() => playFrom(i)}
|
onClick={() => playFrom(i)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -7,7 +7,6 @@
|
|||||||
type ApiTrackLike,
|
type ApiTrackLike,
|
||||||
type QueueItem
|
type QueueItem
|
||||||
} from '$lib/player/player.svelte';
|
} from '$lib/player/player.svelte';
|
||||||
import { hueFor } from '$lib/theme/color';
|
|
||||||
import { fmtTime } from '$lib/format';
|
import { fmtTime } from '$lib/format';
|
||||||
import type { YouTubeSong } from '$lib/types';
|
import type { YouTubeSong } from '$lib/types';
|
||||||
import SectionHeading from '$lib/components/ui/SectionHeading.svelte';
|
import SectionHeading from '$lib/components/ui/SectionHeading.svelte';
|
||||||
@@ -92,7 +91,6 @@
|
|||||||
title={item.kind === 'youtube' ? item.song.title : item.track.title}
|
title={item.kind === 'youtube' ? item.song.title : item.track.title}
|
||||||
artist={item.kind === 'youtube' ? item.song.artist : item.track.artist}
|
artist={item.kind === 'youtube' ? item.song.artist : item.track.artist}
|
||||||
coverSrc={item.kind === 'youtube' ? item.song.thumbnailUrl : undefined}
|
coverSrc={item.kind === 'youtube' ? item.song.thumbnailUrl : undefined}
|
||||||
hue={hueFor(idFor(item))}
|
|
||||||
explicit={item.kind === 'youtube' && item.song.isExplicit}
|
explicit={item.kind === 'youtube' && item.song.isExplicit}
|
||||||
onClick={() => playFrom(i)}
|
onClick={() => playFrom(i)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -102,7 +102,7 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<title>Subir música · Musify</title>
|
<title>Subir música</title>
|
||||||
<meta name="description" content="Sube tus canciones a Musify: portada, audio y título." />
|
<meta name="description" content="Sube tus canciones a Musify: portada, audio y título." />
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
|
|||||||
@@ -10,13 +10,28 @@
|
|||||||
import MobileNav from '$lib/components/MobileNav.svelte';
|
import MobileNav from '$lib/components/MobileNav.svelte';
|
||||||
import { player } from '$lib/player/player.svelte';
|
import { player } from '$lib/player/player.svelte';
|
||||||
import { ACCENT_LIGHTNESS, ACCENT_CHROMA, ACCENT_HUE } from '$lib/theme/color';
|
import { ACCENT_LIGHTNESS, ACCENT_CHROMA, ACCENT_HUE } from '$lib/theme/color';
|
||||||
|
import { trackNavigation } from '$lib/navigation.svelte';
|
||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
|
|
||||||
let { children, data } = $props();
|
let { children, data } = $props();
|
||||||
|
|
||||||
|
trackNavigation();
|
||||||
|
|
||||||
const isAuthPage = $derived(page.url.pathname === '/login');
|
const isAuthPage = $derived(page.url.pathname === '/login');
|
||||||
const hasTrack = $derived(player.currentId !== null);
|
const hasTrack = $derived(player.currentId !== null);
|
||||||
|
|
||||||
|
function isTypingTarget(target: EventTarget | null): boolean {
|
||||||
|
if (!(target instanceof HTMLElement)) return false;
|
||||||
|
if (target.isContentEditable) return true;
|
||||||
|
return ['INPUT', 'TEXTAREA', 'SELECT', 'BUTTON', 'A'].includes(target.tagName);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onWindowKeydown(event: KeyboardEvent) {
|
||||||
|
if (event.code !== 'Space' || isTypingTarget(event.target)) return;
|
||||||
|
event.preventDefault();
|
||||||
|
player.toggle();
|
||||||
|
}
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
document.documentElement.classList.toggle('home', page.url.pathname === '/');
|
document.documentElement.classList.toggle('home', page.url.pathname === '/');
|
||||||
});
|
});
|
||||||
@@ -59,6 +74,8 @@
|
|||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<svelte:window onkeydown={onWindowKeydown} />
|
||||||
|
|
||||||
<svelte:head><link rel="icon" href={favicon} /></svelte:head>
|
<svelte:head><link rel="icon" href={favicon} /></svelte:head>
|
||||||
|
|
||||||
{#if isAuthPage || !data.user}
|
{#if isAuthPage || !data.user}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Music from '@lucide/svelte/icons/music';
|
|
||||||
import ArrowRight from '@lucide/svelte/icons/arrow-right';
|
import ArrowRight from '@lucide/svelte/icons/arrow-right';
|
||||||
import { player, toQueueItems, type QueueItem } from '$lib/player/player.svelte';
|
import { player, toQueueItems, type QueueItem } from '$lib/player/player.svelte';
|
||||||
import {
|
import {
|
||||||
@@ -13,14 +12,12 @@
|
|||||||
targetTitle,
|
targetTitle,
|
||||||
type TrackTarget
|
type TrackTarget
|
||||||
} from '$lib/tracks';
|
} from '$lib/tracks';
|
||||||
import { hueFor } from '$lib/theme/color';
|
|
||||||
import Cover from '$lib/components/ui/Cover.svelte';
|
import Cover from '$lib/components/ui/Cover.svelte';
|
||||||
import PlaylistArt from '$lib/components/ui/PlaylistArt.svelte';
|
import PlaylistArt from '$lib/components/ui/PlaylistArt.svelte';
|
||||||
import MixBentoTile from '$lib/components/ui/MixBentoTile.svelte';
|
import MixBentoTile from '$lib/components/ui/MixBentoTile.svelte';
|
||||||
import Rail from '$lib/components/ui/Rail.svelte';
|
import Rail from '$lib/components/ui/Rail.svelte';
|
||||||
import CoverBadge from '$lib/components/ui/CoverBadge.svelte';
|
import CoverBadge from '$lib/components/ui/CoverBadge.svelte';
|
||||||
import NowPlaying from '$lib/components/ui/NowPlaying.svelte';
|
import NowPlaying from '$lib/components/ui/NowPlaying.svelte';
|
||||||
import EmptyState from '$lib/components/ui/EmptyState.svelte';
|
|
||||||
import ExplicitBadge from '$lib/components/ui/ExplicitBadge.svelte';
|
import ExplicitBadge from '$lib/components/ui/ExplicitBadge.svelte';
|
||||||
import SectionHeading from '$lib/components/ui/SectionHeading.svelte';
|
import SectionHeading from '$lib/components/ui/SectionHeading.svelte';
|
||||||
import TrackContextMenu, {
|
import TrackContextMenu, {
|
||||||
@@ -30,6 +27,8 @@
|
|||||||
import type { YouTubeSong } from '$lib/types';
|
import type { YouTubeSong } from '$lib/types';
|
||||||
import { appendUnique } from '$lib/collections';
|
import { appendUnique } from '$lib/collections';
|
||||||
import { HOME_LATEST_PAGE_SIZE } from '$lib/config';
|
import { HOME_LATEST_PAGE_SIZE } from '$lib/config';
|
||||||
|
import EmptyState from '$lib/components/ui/EmptyState.svelte';
|
||||||
|
import Music from '@lucide/svelte/icons/music';
|
||||||
|
|
||||||
let { data } = $props();
|
let { data } = $props();
|
||||||
|
|
||||||
@@ -138,8 +137,10 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{#if recentlyPlayed.length > 0}
|
<div class="flex flex-col gap-10 page-x py-4 sm:py-6">
|
||||||
<section class="page-x pt-6 sm:pt-8">
|
{#if recentlyPlayed.length > 0}
|
||||||
|
<section>
|
||||||
|
<SectionHeading title="Recientemente reproducidas" />
|
||||||
<div
|
<div
|
||||||
class="grid grid-cols-1 gap-3 sm:grid-cols-2 sm:gap-4 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5"
|
class="grid grid-cols-1 gap-3 sm:grid-cols-2 sm:gap-4 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5"
|
||||||
>
|
>
|
||||||
@@ -156,7 +157,6 @@
|
|||||||
<Cover
|
<Cover
|
||||||
trackId={track.id}
|
trackId={track.id}
|
||||||
src={track.coverUrl}
|
src={track.coverUrl}
|
||||||
hue={hueFor(track.id)}
|
|
||||||
size="small"
|
size="small"
|
||||||
alt={track.title}
|
alt={track.title}
|
||||||
class="h-14 w-14 shrink-0 rounded-control shadow-art transition duration-200"
|
class="h-14 w-14 shrink-0 rounded-control shadow-art transition duration-200"
|
||||||
@@ -182,42 +182,49 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if albums.length > 0}
|
{#if albums.length > 0}
|
||||||
<section class="page-x pt-10">
|
<section>
|
||||||
<SectionHeading title="Álbumes recién escuchados">
|
<SectionHeading title="Álbumes recientes" />
|
||||||
{#snippet actions()}
|
|
||||||
<a href="/albums" class="text-sm text-fg-3 transition hover:text-accent-soft">Ver todos</a>
|
|
||||||
{/snippet}
|
|
||||||
</SectionHeading>
|
|
||||||
<Rail>
|
<Rail>
|
||||||
{#each albums as album, i (album.id)}
|
{#each albums as album, i (album.id)}
|
||||||
<a
|
<a
|
||||||
href="/albums/{album.id}"
|
href={album.youTubeAlbumId
|
||||||
class="group/card animate-enter block w-[168px] shrink-0 rounded-art focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-bg focus-visible:outline-none"
|
? `/albums/youtube/${album.youTubeAlbumId}`
|
||||||
|
: `/albums/${album.id}`}
|
||||||
|
class="group/card animate-enter block w-42 shrink-0 rounded-art focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-bg focus-visible:outline-none"
|
||||||
style="animation-delay:{Math.min(i, 10) * 45}ms"
|
style="animation-delay:{Math.min(i, 10) * 45}ms"
|
||||||
>
|
>
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
|
{#if album.thumbnailUrl}
|
||||||
|
<Cover
|
||||||
|
trackId={album.id}
|
||||||
|
src={album.thumbnailUrl}
|
||||||
|
size="large"
|
||||||
|
alt={album.title}
|
||||||
|
class="h-52 w-42 rounded-art shadow-art ring-line transition duration-300 ease-out"
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
<PlaylistArt
|
<PlaylistArt
|
||||||
trackIds={album.coverTrackIds}
|
trackIds={album.coverTrackIds}
|
||||||
hue={hueFor(album.id)}
|
class="h-56 w-48 rounded-art shadow-art ring-line transition duration-300 ease-out"
|
||||||
class="h-[168px] w-[168px] rounded-art shadow-art ring-1 ring-line transition duration-300 ease-out ring-inset group-hover/card:shadow-art-lg"
|
|
||||||
/>
|
/>
|
||||||
<CoverBadge label="Álbum" />
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-2.5 truncate text-fg transition-colors group-hover/card:text-accent-soft">
|
<div class="mt-2.5 text-fg transition-colors group-hover/card:text-accent-soft">
|
||||||
{album.title}
|
{album.title}
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-0.5 truncate text-sm text-fg-3">{albumCaption(album)}</div>
|
<div class="mt-0.5 text-sm text-fg-3">{albumCaption(album)}</div>
|
||||||
</a>
|
</a>
|
||||||
{/each}
|
{/each}
|
||||||
</Rail>
|
</Rail>
|
||||||
</section>
|
</section>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if mixes.length > 0}
|
{#if mixes.length > 0}
|
||||||
<section class="page-x pt-10">
|
<section>
|
||||||
|
<SectionHeading title="Mezclas" />
|
||||||
<div class="grid grid-cols-2 gap-3 sm:auto-rows-25 sm:grid-cols-4 sm:gap-4">
|
<div class="grid grid-cols-2 gap-3 sm:auto-rows-25 sm:grid-cols-4 sm:gap-4">
|
||||||
{#each mixes as mix, i (mix.id)}
|
{#each mixes as mix, i (mix.id)}
|
||||||
<MixBentoTile
|
<MixBentoTile
|
||||||
@@ -236,28 +243,32 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if playlists.length > 0}
|
{#if playlists.length > 0}
|
||||||
<section class="page-x pt-10">
|
<section>
|
||||||
|
<SectionHeading title="Listas de reproducción" />
|
||||||
<Rail>
|
<Rail>
|
||||||
{#each playlists as playlist, i (playlist.id)}
|
{#each playlists as playlist, i (playlist.id)}
|
||||||
<a
|
<a
|
||||||
href="/playlists/{playlist.id}"
|
href="/playlists/{playlist.id}"
|
||||||
class="group/card animate-enter block w-36 shrink-0 rounded-art focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-bg focus-visible:outline-none"
|
class="group/card animate-enter block w-38 shrink-0 rounded-art"
|
||||||
style="animation-delay:{Math.min(i, 10) * 45}ms"
|
style="animation-delay:{Math.min(i, 10) * 45}ms"
|
||||||
>
|
>
|
||||||
<PlaylistArt
|
<PlaylistArt
|
||||||
playlistId={playlist.id}
|
playlistId={playlist.id}
|
||||||
trackIds={playlist.coverTrackIds}
|
trackIds={playlist.coverTrackIds}
|
||||||
hue={hueFor(playlist.id)}
|
|
||||||
version={playlist.updatedAt}
|
version={playlist.updatedAt}
|
||||||
class="h-36 w-36 rounded-art"
|
class="h-38 w-38 rounded-art"
|
||||||
/>
|
/>
|
||||||
<div class="mt-2.5 truncate text-fg transition-colors group-hover/card:text-accent-soft">
|
<div
|
||||||
|
class="mt-2.5 truncate text-fg transition-colors group-hover/card:text-accent-soft"
|
||||||
|
>
|
||||||
{playlist.name}
|
{playlist.name}
|
||||||
</div>
|
</div>
|
||||||
|
{#if playlist.description}
|
||||||
<div class="mt-0.5 truncate text-sm text-muted">{playlist.description}</div>
|
<div class="mt-0.5 truncate text-sm text-muted">{playlist.description}</div>
|
||||||
|
{/if}
|
||||||
</a>
|
</a>
|
||||||
{/each}
|
{/each}
|
||||||
<a
|
<a
|
||||||
@@ -273,10 +284,10 @@
|
|||||||
</a>
|
</a>
|
||||||
</Rail>
|
</Rail>
|
||||||
</section>
|
</section>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if newTargets.length > 0}
|
{#if newTargets.length > 0}
|
||||||
<section class="page-x pt-10">
|
<section>
|
||||||
<SectionHeading title="Canciones nuevas" />
|
<SectionHeading title="Canciones nuevas" />
|
||||||
<Rail>
|
<Rail>
|
||||||
{#each newTargets as target, i (targetId(target))}
|
{#each newTargets as target, i (targetId(target))}
|
||||||
@@ -292,7 +303,6 @@
|
|||||||
<Cover
|
<Cover
|
||||||
trackId={targetId(target)}
|
trackId={targetId(target)}
|
||||||
src={targetCoverSrc(target)}
|
src={targetCoverSrc(target)}
|
||||||
hue={hueFor(targetId(target))}
|
|
||||||
size="medium"
|
size="medium"
|
||||||
alt={targetTitle(target)}
|
alt={targetTitle(target)}
|
||||||
class="h-full w-full rounded-art shadow-art ring-1 ring-line transition duration-300 ease-out ring-inset group-hover/card:shadow-art-lg"
|
class="h-full w-full rounded-art shadow-art ring-1 ring-line transition duration-300 ease-out ring-inset group-hover/card:shadow-art-lg"
|
||||||
@@ -318,7 +328,11 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</Rail>
|
</Rail>
|
||||||
</section>
|
</section>
|
||||||
{/if}
|
{/if}
|
||||||
|
{#if recentlyPlayed.length === 0 && albums.length === 0 && mixes.length === 0 && playlists.length === 0 && newTargets.length === 0}
|
||||||
|
<EmptyState icon={Music} title="Todavía no hay música" description="Sube tu música o explora nuevas canciones, álbumes y listas de reproducción." />
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
<!---
|
<!---
|
||||||
<section class="page-x pt-10 pb-16">
|
<section class="page-x pt-10 pb-16">
|
||||||
@@ -337,7 +351,6 @@
|
|||||||
<Cover
|
<Cover
|
||||||
trackId={targetId(target)}
|
trackId={targetId(target)}
|
||||||
src={targetCoverSrc(target)}
|
src={targetCoverSrc(target)}
|
||||||
hue={hueFor(targetId(target))}
|
|
||||||
size="medium"
|
size="medium"
|
||||||
alt={targetTitle(target)}
|
alt={targetTitle(target)}
|
||||||
class="h-full w-full rounded-art shadow-art ring-1 ring-line transition duration-300 ease-out ring-inset group-hover/card:shadow-art-lg"
|
class="h-full w-full rounded-art shadow-art ring-1 ring-line transition duration-300 ease-out ring-inset group-hover/card:shadow-art-lg"
|
||||||
|
|||||||
@@ -37,10 +37,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@utility page-x {
|
@utility page-x {
|
||||||
padding-inline: --spacing(6);
|
padding-inline: --spacing(4);
|
||||||
|
|
||||||
@media (width >= 40rem) {
|
@media (width >= 40rem) {
|
||||||
padding-inline: --spacing(10);
|
padding-inline: --spacing(8);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<title>Iniciar sesión · Musify</title>
|
<title>Iniciar sesión</title>
|
||||||
<meta name="description" content="Inicia sesión o crea una cuenta en Musify." />
|
<meta name="description" content="Inicia sesión o crea una cuenta en Musify." />
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user