From 0d34a40931006d4ca50a5c924cd5db9ff535c5f4 Mon Sep 17 00:00:00 2001 From: XShine555 Date: Tue, 21 Jul 2026 19:06:36 +0200 Subject: [PATCH] feat: rework the home layout Recently played keeps its shape. Mixes and playlists now share one row, half and half on large screens with smaller cards, capped at six each with a link to the full list. Albums are the ones the user listened to most recently, which is a new query joining the listening history with the album tracks. The endless track wall is now "Descubrir". --- .../Musify.Api/Endpoints/AlbumEndpoints.cs | 17 +++++ .../Albums/GetRecentlyListenedAlbums.cs | 61 ++++++++++++++++ web-player/src/lib/api/schema.d.ts | 46 ++++++++++++ web-player/src/lib/config.ts | 3 +- web-player/src/routes/+page.server.ts | 23 +++--- web-player/src/routes/+page.svelte | 72 ++++++++++--------- 6 files changed, 180 insertions(+), 42 deletions(-) create mode 100644 backend/Musify.Application/Albums/GetRecentlyListenedAlbums.cs diff --git a/backend/Musify.Api/Endpoints/AlbumEndpoints.cs b/backend/Musify.Api/Endpoints/AlbumEndpoints.cs index 7de5d9b..e61f8bb 100644 --- a/backend/Musify.Api/Endpoints/AlbumEndpoints.cs +++ b/backend/Musify.Api/Endpoints/AlbumEndpoints.cs @@ -34,6 +34,13 @@ public static class AlbumEndpoints .Produces>() .Produces(StatusCodes.Status404NotFound); + group.MapGet("/recent", GetRecentlyListenedAlbums) + .WithName("GetRecentlyListenedAlbums") + .WithSummary("Get The Albums The Current User Listened To Most Recently.") + .RequireAuthorization() + .Produces>() + .Produces(StatusCodes.Status401Unauthorized); + group.MapGet("/{albumId}/tracks", GetAlbumTracks) .WithName("GetAlbumTracks") .WithSummary("Get The Tracks Of An Album Ordered By Track Number.") @@ -119,6 +126,16 @@ public static class AlbumEndpoints return result.ToHttpResult(); } + private static async Task GetRecentlyListenedAlbums( + IMediator mediator, + CurrentUser currentUser, + CancellationToken cancellationToken, + int limit = 12) + { + var result = await mediator.Send(new GetRecentlyListenedAlbumsQuery(currentUser.RequiredId, limit), cancellationToken); + return result.ToHttpResult(); + } + private static async Task GetAlbumTracks( IMediator mediator, Guid albumId, diff --git a/backend/Musify.Application/Albums/GetRecentlyListenedAlbums.cs b/backend/Musify.Application/Albums/GetRecentlyListenedAlbums.cs new file mode 100644 index 0000000..4db0e34 --- /dev/null +++ b/backend/Musify.Application/Albums/GetRecentlyListenedAlbums.cs @@ -0,0 +1,61 @@ +using ErrorOr; +using Mediator; +using Microsoft.EntityFrameworkCore; +using Musify.Application.Albums.Responses; +using Musify.Application.Contracts; + +namespace Musify.Application.Albums +{ + public record GetRecentlyListenedAlbumsQuery(long UserId, int Limit) + : IQuery>>; + + public class GetRecentlyListenedAlbumsQueryHandler(IDatabase database) + : IQueryHandler>> + { + public async ValueTask>> Handle(GetRecentlyListenedAlbumsQuery request, CancellationToken cancellationToken) + { + var listenedTrackIds = database.ListeningHistories + .AsNoTracking() + .Where(history => history.UserId == request.UserId); + + var ranked = await database.AlbumHasTracks + .AsNoTracking() + .Join( + listenedTrackIds, + albumTrack => albumTrack.TrackId, + history => history.TrackId, + (albumTrack, history) => new { albumTrack.AlbumId, history.ListenedAt }) + .GroupBy(entry => entry.AlbumId) + .Select(group => new { AlbumId = group.Key, LastListenedAt = group.Max(entry => entry.ListenedAt) }) + .OrderByDescending(entry => entry.LastListenedAt) + .Take(request.Limit) + .ToListAsync(cancellationToken); + + if (ranked.Count == 0) + return new List(); + + var albumIds = ranked.Select(entry => entry.AlbumId).ToList(); + + var albums = await database.UserAlbums + .AsNoTracking() + .Where(album => albumIds.Contains(album.Id)) + .Select(album => new + { + Album = album, + TrackCount = album.AlbumTracks.Count, + CoverTrackIds = album.AlbumTracks + .OrderBy(albumTrack => albumTrack.TrackNumber) + .Take(AlbumApplicationResponse.CoverTrackCount) + .Select(albumTrack => albumTrack.TrackId) + .ToList() + }) + .ToDictionaryAsync(entry => entry.Album.Id, cancellationToken); + + return ranked + .Where(entry => albums.ContainsKey(entry.AlbumId)) + .Select(entry => albums[entry.AlbumId]) + .Select(entry => AlbumApplicationResponse.FromEntity(entry.Album, entry.TrackCount, entry.CoverTrackIds)) + .ToList(); + } + } +} diff --git a/web-player/src/lib/api/schema.d.ts b/web-player/src/lib/api/schema.d.ts index 397335a..585a1df 100644 --- a/web-player/src/lib/api/schema.d.ts +++ b/web-player/src/lib/api/schema.d.ts @@ -56,6 +56,23 @@ export interface paths { patch?: never; trace?: never; }; + "/albums/recent": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get The Albums The Current User Listened To Most Recently. */ + get: operations["GetRecentlyListenedAlbums"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/albums/{albumId}/tracks": { parameters: { query?: never; @@ -991,6 +1008,35 @@ export interface operations { }; }; }; + GetRecentlyListenedAlbums: { + parameters: { + query?: { + limit?: number | string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["AlbumApplicationResponse"][]; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; GetAlbumTracks: { parameters: { query?: { diff --git a/web-player/src/lib/config.ts b/web-player/src/lib/config.ts index c38949f..a1c7c41 100644 --- a/web-player/src/lib/config.ts +++ b/web-player/src/lib/config.ts @@ -9,6 +9,7 @@ export const LIBRARY_PICKER_PAGE_SIZE = 50; export const EXPLORE_ALBUMS_PAGE_SIZE = 10; export const EXPLORE_ALBUM_SLOTS = 10; export const HOME_LATEST_PAGE_SIZE = 12; -export const HOME_ALBUMS_PAGE_SIZE = 12; +export const HOME_ALBUMS_LIMIT = 12; +export const HOME_SHELF_LIMIT = 6; export const YOUTUBE_FILLER_LIMIT = 27; export const PLAYLIST_PICKER_PAGE_SIZE = 50; diff --git a/web-player/src/routes/+page.server.ts b/web-player/src/routes/+page.server.ts index d7f18d9..eaedba8 100644 --- a/web-player/src/routes/+page.server.ts +++ b/web-player/src/routes/+page.server.ts @@ -3,7 +3,12 @@ import { createApiClient, requireUser } from '$lib/server/api'; import { fetchYoutubeFiller } from '$lib/server/youtube'; import { shuffle } from '$lib/collections'; import { addTrackAction, addYouTubeToPlaylistAction } from '$lib/server/playlistActions'; -import { HOME_ALBUMS_PAGE_SIZE, HOME_LATEST_PAGE_SIZE, YOUTUBE_FILLER_LIMIT } from '$lib/config'; +import { + HOME_ALBUMS_LIMIT, + HOME_LATEST_PAGE_SIZE, + HOME_SHELF_LIMIT, + YOUTUBE_FILLER_LIMIT +} from '$lib/config'; import { pickGreeting } from '$lib/server/greeting'; export const load: PageServerLoad = async ({ locals, url, fetch }) => { @@ -14,14 +19,14 @@ export const load: PageServerLoad = async ({ locals, url, fetch }) => { params: { query: { pageNumber: 1, pageSize: HOME_LATEST_PAGE_SIZE } } }); const playlistsPromise = api.GET('/playlists/users/{userId}', { - params: { path: { userId: user.sub }, query: { pageNumber: 1, pageSize: 12 } } - }); - const albumsPromise = api.GET('/albums/users/{userId}', { params: { path: { userId: user.sub }, - query: { pageNumber: 1, pageSize: HOME_ALBUMS_PAGE_SIZE } + query: { pageNumber: 1, pageSize: HOME_SHELF_LIMIT + 1 } } }); + const albumsPromise = api.GET('/albums/recent', { + params: { query: { limit: HOME_ALBUMS_LIMIT } } + }); const mixesPromise = api.GET('/mixes'); const recentlyPlayedPromise = api.GET('/users/{id}/listening-history', { params: { path: { id: user.sub } } @@ -47,10 +52,10 @@ export const load: PageServerLoad = async ({ locals, url, fetch }) => { undefined, user.sub ), - playlists: playlistItems, - albums: albums.data?.items ?? [], - albumsHasMore: Boolean(albums.data?.hasNextPage), - mixes: mixes.data ?? [], + playlists: playlistItems.slice(0, HOME_SHELF_LIMIT), + playlistsHasMore: playlistItems.length > HOME_SHELF_LIMIT, + albums: albums.data ?? [], + mixes: (mixes.data ?? []).slice(0, HOME_SHELF_LIMIT), recentlyPlayed: recentlyPlayed.data ?? [] }; }; diff --git a/web-player/src/routes/+page.svelte b/web-player/src/routes/+page.svelte index 71c9b08..698729d 100644 --- a/web-player/src/routes/+page.svelte +++ b/web-player/src/routes/+page.svelte @@ -227,44 +227,52 @@ {/if} -{#if mixes.length > 0} -
- - - {#each mixes as mix, i (mix.id)} - - {/each} - -
-{/if} +{#if mixes.length > 0 || playlists.length > 0} +
+ {#if mixes.length > 0} +
+ + + {#each mixes as mix, i (mix.id)} + + {/each} + +
+ {/if} -{#if playlists.length > 0} -
- - - {#each playlists as playlist, i (playlist.id)} - - {/each} - + {#if playlists.length > 0} +
+ + {#snippet actions()} + {#if data.playlistsHasMore} + + Ver todas + + {/if} + {/snippet} + + + {#each playlists as playlist, i (playlist.id)} + + {/each} + +
+ {/if}
{/if} {#if albums.length > 0}
- + {#snippet actions()} - {#if data.albumsHasMore} - - Ver todos - - {/if} + Ver todos {/snippet} @@ -283,7 +291,7 @@ {/if}
- + {#if latest.length > 0} {#each latest as item, i (targetId(item))}