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".
This commit is contained in:
2026-07-21 19:06:36 +02:00
parent 7e697e423b
commit 0d34a40931
6 changed files with 180 additions and 42 deletions
@@ -34,6 +34,13 @@ public static class AlbumEndpoints
.Produces<PaginatedResponse<AlbumApplicationResponse>>() .Produces<PaginatedResponse<AlbumApplicationResponse>>()
.Produces(StatusCodes.Status404NotFound); .Produces(StatusCodes.Status404NotFound);
group.MapGet("/recent", GetRecentlyListenedAlbums)
.WithName("GetRecentlyListenedAlbums")
.WithSummary("Get The Albums The Current User Listened To Most Recently.")
.RequireAuthorization()
.Produces<IReadOnlyList<AlbumApplicationResponse>>()
.Produces(StatusCodes.Status401Unauthorized);
group.MapGet("/{albumId}/tracks", GetAlbumTracks) group.MapGet("/{albumId}/tracks", GetAlbumTracks)
.WithName("GetAlbumTracks") .WithName("GetAlbumTracks")
.WithSummary("Get The Tracks Of An Album Ordered By Track Number.") .WithSummary("Get The Tracks Of An Album Ordered By Track Number.")
@@ -119,6 +126,16 @@ public static class AlbumEndpoints
return result.ToHttpResult(); return result.ToHttpResult();
} }
private static async Task<IResult> 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<IResult> GetAlbumTracks( private static async Task<IResult> GetAlbumTracks(
IMediator mediator, IMediator mediator,
Guid albumId, Guid albumId,
@@ -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<ErrorOr<IReadOnlyList<AlbumApplicationResponse>>>;
public class GetRecentlyListenedAlbumsQueryHandler(IDatabase database)
: IQueryHandler<GetRecentlyListenedAlbumsQuery, ErrorOr<IReadOnlyList<AlbumApplicationResponse>>>
{
public async ValueTask<ErrorOr<IReadOnlyList<AlbumApplicationResponse>>> 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<AlbumApplicationResponse>();
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();
}
}
}
+46
View File
@@ -56,6 +56,23 @@ export interface paths {
patch?: never; patch?: never;
trace?: 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": { "/albums/{albumId}/tracks": {
parameters: { parameters: {
query?: never; 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: { GetAlbumTracks: {
parameters: { parameters: {
query?: { query?: {
+2 -1
View File
@@ -9,6 +9,7 @@ export const LIBRARY_PICKER_PAGE_SIZE = 50;
export const EXPLORE_ALBUMS_PAGE_SIZE = 10; export const EXPLORE_ALBUMS_PAGE_SIZE = 10;
export const EXPLORE_ALBUM_SLOTS = 10; export const EXPLORE_ALBUM_SLOTS = 10;
export const HOME_LATEST_PAGE_SIZE = 12; 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 YOUTUBE_FILLER_LIMIT = 27;
export const PLAYLIST_PICKER_PAGE_SIZE = 50; export const PLAYLIST_PICKER_PAGE_SIZE = 50;
+14 -9
View File
@@ -3,7 +3,12 @@ import { createApiClient, requireUser } from '$lib/server/api';
import { fetchYoutubeFiller } from '$lib/server/youtube'; import { fetchYoutubeFiller } from '$lib/server/youtube';
import { shuffle } from '$lib/collections'; import { shuffle } from '$lib/collections';
import { addTrackAction, addYouTubeToPlaylistAction } from '$lib/server/playlistActions'; 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'; import { pickGreeting } from '$lib/server/greeting';
export const load: PageServerLoad = async ({ locals, url, fetch }) => { 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 } } params: { query: { pageNumber: 1, pageSize: HOME_LATEST_PAGE_SIZE } }
}); });
const playlistsPromise = api.GET('/playlists/users/{userId}', { 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: { params: {
path: { userId: user.sub }, 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 mixesPromise = api.GET('/mixes');
const recentlyPlayedPromise = api.GET('/users/{id}/listening-history', { const recentlyPlayedPromise = api.GET('/users/{id}/listening-history', {
params: { path: { id: user.sub } } params: { path: { id: user.sub } }
@@ -47,10 +52,10 @@ export const load: PageServerLoad = async ({ locals, url, fetch }) => {
undefined, undefined,
user.sub user.sub
), ),
playlists: playlistItems, playlists: playlistItems.slice(0, HOME_SHELF_LIMIT),
albums: albums.data?.items ?? [], playlistsHasMore: playlistItems.length > HOME_SHELF_LIMIT,
albumsHasMore: Boolean(albums.data?.hasNextPage), albums: albums.data ?? [],
mixes: mixes.data ?? [], mixes: (mixes.data ?? []).slice(0, HOME_SHELF_LIMIT),
recentlyPlayed: recentlyPlayed.data ?? [] recentlyPlayed: recentlyPlayed.data ?? []
}; };
}; };
+40 -32
View File
@@ -227,44 +227,52 @@
</section> </section>
{/if} {/if}
{#if mixes.length > 0} {#if mixes.length > 0 || playlists.length > 0}
<section class="page-x pt-5 pb-3 sm:pt-6"> <section class="grid gap-x-8 gap-y-5 page-x pt-5 pb-3 sm:pt-6 lg:grid-cols-2">
<SectionHeading title="Hechas para ti" /> {#if mixes.length > 0}
<MediaGrid> <div class="min-w-0">
{#each mixes as mix, i (mix.id)} <SectionHeading title="Hechas para ti" />
<MixCard {mix} index={i} /> <MediaGrid min="140px" minMobile="130px">
{/each} {#each mixes as mix, i (mix.id)}
</MediaGrid> <MixCard {mix} index={i} />
</section> {/each}
{/if} </MediaGrid>
</div>
{/if}
{#if playlists.length > 0} {#if playlists.length > 0}
<section class="page-x pt-5 pb-3 sm:pt-6"> <div class="min-w-0">
<SectionHeading title="Mis listas" /> <SectionHeading title="Mis listas">
<MediaGrid> {#snippet actions()}
{#each playlists as playlist, i (playlist.id)} {#if data.playlistsHasMore}
<PlaylistCard <a href="/playlists" class="text-sm text-fg-3 transition hover:text-accent-soft">
id={playlist.id} Ver todas
name={playlist.name} </a>
description={playlist.description} {/if}
trackIds={playlist.coverTrackIds} {/snippet}
updatedAt={playlist.updatedAt} </SectionHeading>
index={i} <MediaGrid min="140px" minMobile="130px">
/> {#each playlists as playlist, i (playlist.id)}
{/each} <PlaylistCard
</MediaGrid> id={playlist.id}
name={playlist.name}
description={playlist.description}
trackIds={playlist.coverTrackIds}
updatedAt={playlist.updatedAt}
index={i}
/>
{/each}
</MediaGrid>
</div>
{/if}
</section> </section>
{/if} {/if}
{#if albums.length > 0} {#if albums.length > 0}
<section class="page-x pt-5 pb-3 sm:pt-6"> <section class="page-x pt-5 pb-3 sm:pt-6">
<SectionHeading title="Tus álbumes"> <SectionHeading title="Álbumes escuchados recientemente">
{#snippet actions()} {#snippet actions()}
{#if data.albumsHasMore} <a href="/albums" class="text-sm text-fg-3 transition hover:text-accent-soft">Ver todos</a>
<a href="/albums" class="text-sm text-fg-3 transition hover:text-accent-soft">
Ver todos
</a>
{/if}
{/snippet} {/snippet}
</SectionHeading> </SectionHeading>
<MediaGrid> <MediaGrid>
@@ -283,7 +291,7 @@
{/if} {/if}
<section class="page-x pt-5 pb-10 sm:pt-6"> <section class="page-x pt-5 pb-10 sm:pt-6">
<SectionHeading title="Novedades" /> <SectionHeading title="Descubrir" />
{#if latest.length > 0} {#if latest.length > 0}
<MediaGrid as="ul"> <MediaGrid as="ul">
{#each latest as item, i (targetId(item))} {#each latest as item, i (targetId(item))}