feat(home): Songs-like → dedicated row + wider spread (#1491)
Promote the best-performing surface ("Songs like {artist}", ~8% skip /
~86% completion) out of the shared Playlists carousel into its own Home
row on both Android and web, and widen the daily build from 3 to 6 mixes
so the dedicated row shows a wider spread.
Server (internal/playlists):
- PickSeedArtists candidate pool 5 → 12; pickSeedArtistsForDay now takes
songsLikeSeedCount (6) instead of a hardcoded 3. Graceful degradation
and daily rotation preserved.
Android (HomeScreen.kt):
- New songsLikeSection + buildSongsLikeRow; PlaylistsRow takes a title so
it renders both the "Playlists" and "Songs like…" rows. buildOnlineRow
/ orderedRealPlaylists no longer reserve the 3 songs-like slots.
Offline shows cached mixes (available-first), hides the row when none.
Web (+page.svelte):
- Dedicated "Songs like…" row from songsLikeRow; dropped the 3-slot cap
and removed songs-like from the Playlists carousel.
Tests: seed_selection_test.go, BuildPlaylistsRowTest.kt, page.test.ts.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -43,10 +43,11 @@
|
||||
(systemPlaylistsQ.data?.owned ?? []).find((p) => p.system_variant === 'discover') ?? null
|
||||
);
|
||||
|
||||
// Songs-like mixes get their own dedicated row (#1491) — no longer
|
||||
// capped to 3 carousel slots, so take all the server generated.
|
||||
const songsLikePlaylists = $derived(
|
||||
(systemPlaylistsQ.data?.owned ?? [])
|
||||
.filter((p) => p.system_variant === 'songs_like_artist')
|
||||
.slice(0, 3)
|
||||
);
|
||||
|
||||
// Secondary system kinds the server generates that don't get pinned
|
||||
@@ -108,14 +109,7 @@
|
||||
out.push({ kind: 'placeholder', label: 'Discover', variant: placeholderVariant('discover') });
|
||||
}
|
||||
|
||||
// Slots 3-5: Songs-like (real first, padded with placeholders).
|
||||
for (let i = 0; i < 3; i++) {
|
||||
if (songsLikePlaylists[i]) {
|
||||
out.push({ kind: 'real', playlist: songsLikePlaylists[i] });
|
||||
} else {
|
||||
out.push({ kind: 'placeholder', label: 'Songs like…', variant: placeholderVariant('songs-like') });
|
||||
}
|
||||
}
|
||||
// Songs-like moved to its own dedicated row below (#1491).
|
||||
|
||||
// Secondary system kinds in server-registry order, when generated.
|
||||
for (const p of secondarySystemPlaylists) {
|
||||
@@ -129,6 +123,25 @@
|
||||
|
||||
return out;
|
||||
});
|
||||
|
||||
// Dedicated Songs-like row (#1491): every generated "Songs like {artist}"
|
||||
// mix — the best-performing surface, promoted out of the Playlists
|
||||
// carousel to show a wider spread. Shows a few placeholders while the
|
||||
// mixes haven't generated yet (building / seed-needed).
|
||||
const SONGS_LIKE_PLACEHOLDER_SLOTS = 3;
|
||||
const songsLikeRow = $derived.by((): PlaylistRowItem[] => {
|
||||
if (songsLikePlaylists.length > 0) {
|
||||
return songsLikePlaylists.map((playlist) => ({ kind: 'real', playlist }) as PlaylistRowItem);
|
||||
}
|
||||
return Array.from(
|
||||
{ length: SONGS_LIKE_PLACEHOLDER_SLOTS },
|
||||
(): PlaylistRowItem => ({
|
||||
kind: 'placeholder',
|
||||
label: 'Songs like…',
|
||||
variant: placeholderVariant('songs-like')
|
||||
})
|
||||
);
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head><title>{pageTitle('Home')}</title></svelte:head>
|
||||
@@ -156,6 +169,27 @@
|
||||
</HorizontalScrollRow>
|
||||
</section>
|
||||
|
||||
<!-- Songs like…: the best-performing surface, its own row (#1491).
|
||||
Depends on the system-playlists query (same as Playlists above), so
|
||||
it sits outside the home-sections {#if data} block. -->
|
||||
<section class="space-y-3">
|
||||
<HorizontalScrollRow
|
||||
rows={[songsLikeRow]}
|
||||
title="Songs like…"
|
||||
ariaLabel="Songs like"
|
||||
>
|
||||
{#snippet item(rowItem: PlaylistRowItem)}
|
||||
<div class="w-56">
|
||||
{#if rowItem.kind === 'real'}
|
||||
<PlaylistCard playlist={rowItem.playlist} />
|
||||
{:else}
|
||||
<PlaylistPlaceholderCard label={rowItem.label} variant={rowItem.variant} />
|
||||
{/if}
|
||||
</div>
|
||||
{/snippet}
|
||||
</HorizontalScrollRow>
|
||||
</section>
|
||||
|
||||
{#if query.isError}
|
||||
<ApiErrorBanner error={query.error} onRetry={query.refetch} />
|
||||
{:else if showSkeleton.value && !data}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
import { render, screen } from '@testing-library/svelte';
|
||||
import { render, screen, within } from '@testing-library/svelte';
|
||||
import { readable } from 'svelte/store';
|
||||
import { emptyLikesMock } from '../test-utils/mocks/likes';
|
||||
import type { Playlist } from '$lib/api/types';
|
||||
@@ -57,7 +57,8 @@ afterEach(() => vi.clearAllMocks());
|
||||
|
||||
describe('home Playlists section', () => {
|
||||
test('renders 5 placeholder cards when no playlists exist', () => {
|
||||
// Slot layout: For-You, Discover, 3× Songs-like.
|
||||
// For-You + Discover in the Playlists row, plus 3 in the dedicated
|
||||
// Songs-like row (#1491) = 5 placeholders total.
|
||||
render(Page);
|
||||
const placeholders = screen.queryAllByTestId('playlist-placeholder-card');
|
||||
expect(placeholders).toHaveLength(5);
|
||||
@@ -107,12 +108,52 @@ describe('home Playlists section', () => {
|
||||
render(Page);
|
||||
expect(screen.getByText('For You')).toBeInTheDocument();
|
||||
const placeholders = screen.queryAllByTestId('playlist-placeholder-card');
|
||||
// 1 Discover + 3 Songs-like slots = 4 placeholders alongside the
|
||||
// real For-You tile.
|
||||
// 1 Discover placeholder (Playlists row) + 3 Songs-like placeholders
|
||||
// (dedicated row) = 4 alongside the real For-You tile.
|
||||
expect(placeholders).toHaveLength(4);
|
||||
});
|
||||
|
||||
test('renders secondary system kinds (deep_cuts / new_for_you) after Songs-like slots', () => {
|
||||
test('dedicated Songs-like row shows every generated mix, uncapped and out of Playlists', () => {
|
||||
const makeSongsLike = (id: string, name: string): Playlist => ({
|
||||
id,
|
||||
user_id: 'u1',
|
||||
owner_username: 'u1',
|
||||
name,
|
||||
description: '',
|
||||
is_public: false,
|
||||
kind: 'system',
|
||||
system_variant: 'songs_like_artist',
|
||||
refreshable: false,
|
||||
seed_artist_id: 'a1',
|
||||
cover_url: '',
|
||||
track_count: 25,
|
||||
duration_sec: 1500,
|
||||
created_at: '2026-05-04T00:00:00Z',
|
||||
updated_at: '2026-05-04T00:00:00Z'
|
||||
});
|
||||
// Six generated mixes — the old carousel capped at 3; the dedicated row shows all.
|
||||
const owned = Array.from({ length: 6 }, (_, i) => makeSongsLike(`sl${i}`, `Songs like ${i}`));
|
||||
(createPlaylistsQuery as unknown as ReturnType<typeof vi.fn>).mockImplementation(
|
||||
(kind?: string) =>
|
||||
kind === 'system'
|
||||
? readable({ data: { owned, public: [] }, isPending: false, isError: false })
|
||||
: readable({ data: emptyPlaylistsResponse, isPending: false, isError: false })
|
||||
);
|
||||
const { container } = render(Page);
|
||||
|
||||
for (let i = 0; i < 6; i++) {
|
||||
expect(screen.getByText(`Songs like ${i}`)).toBeInTheDocument();
|
||||
}
|
||||
// The mixes live in the Songs-like row, not the Playlists carousel.
|
||||
const playlistsSection = container.querySelector('[aria-label="Playlists"]') as HTMLElement;
|
||||
expect(within(playlistsSection).queryByText('Songs like 0')).toBeNull();
|
||||
const songsSection = container.querySelector('[aria-label="Songs like"]') as HTMLElement;
|
||||
expect(within(songsSection).getByText('Songs like 0')).toBeInTheDocument();
|
||||
// No placeholders once real mixes exist.
|
||||
expect(within(songsSection).queryByTestId('playlist-placeholder-card')).toBeNull();
|
||||
});
|
||||
|
||||
test('renders secondary system kinds (deep_cuts / new_for_you) in the Playlists row', () => {
|
||||
// Operator backflow 2026-06-01: web Home surfaces the 5 secondary
|
||||
// system kinds when generated. No placeholders for them — they
|
||||
// depend on library shape, so missing means "not enough data."
|
||||
|
||||
Reference in New Issue
Block a user