Merge pull request #644 from kettui/refactor/artist-detail-react-route

Centralize artist-detail page hand-off logic, make it URL-driven
This commit is contained in:
BoulderBadgeDad 2026-05-19 12:07:12 -07:00 committed by GitHub
commit c02cf15fdf
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
24 changed files with 450 additions and 372 deletions

View file

@ -2418,7 +2418,7 @@
<div class="page" id="artist-detail-page">
<div class="page-header">
<button class="back-btn" id="artist-detail-back-btn">
<span>← Back to Library</span>
<span>← Back</span>
</button>
<button class="library-artist-watchlist-btn" id="library-artist-watchlist-btn">
@ -2769,11 +2769,11 @@
<!-- Popularity and genres will be populated here -->
</div>
<div class="discover-hero-actions">
<button class="discover-hero-button secondary" id="discover-hero-discography"
onclick="viewDiscoverHeroDiscography()">
<a class="discover-hero-button secondary" id="discover-hero-discography" href="#"
style="text-decoration:none;color:inherit;">
<span class="button-icon">📀</span>
<span class="button-text">View Discography</span>
</button>
</a>
<button class="discover-hero-button primary watchlist-toggle-btn"
id="discover-hero-add" onclick="toggleDiscoverHeroWatchlist(event)">
<span class="watchlist-icon">👁️</span>
@ -6977,10 +6977,11 @@
<div class="np-format-badges" id="np-format-badges"></div>
</div>
<div class="np-action-buttons" id="np-action-buttons">
<button class="np-action-btn" id="np-goto-artist" title="Go to Artist">
<a class="np-action-btn" id="np-goto-artist" title="Go to Artist" href="#" tabindex="-1"
style="text-decoration:none;color:inherit;pointer-events:none;">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
<span>View Artist</span>
</button>
</a>
</div>
</div>
<!-- Right: controls -->

View file

@ -60,6 +60,8 @@ function createShellBridge(overrides: Partial<ShellBridge> = {}): ShellBridge {
resolveLegacyPath: vi.fn<(pathname: string) => ShellPageId | null>(() => 'search'),
setActivePageChrome: vi.fn(),
activateLegacyPath: vi.fn(),
navigateToArtistDetail: vi.fn(),
cancelSimilarArtistsLoad: vi.fn(),
showReactHost: vi.fn(),
...overrides,
};

View file

@ -17,6 +17,7 @@ describe('waitForShellContext', () => {
getCurrentProfileContext: vi.fn(() => ({ profileId: 2, isAdmin: true })),
resolveLegacyPath: vi.fn(() => 'issues'),
setActivePageChrome: vi.fn(),
cancelSimilarArtistsLoad: vi.fn(),
showReactHost: vi.fn(),
} as NonNullable<typeof window.SoulSyncWebShellBridge>;
@ -38,6 +39,7 @@ describe('waitForShellContext', () => {
getCurrentProfileContext,
resolveLegacyPath: vi.fn(() => 'issues'),
setActivePageChrome: vi.fn(),
cancelSimilarArtistsLoad: vi.fn(),
showReactHost: vi.fn(),
} as NonNullable<typeof window.SoulSyncWebShellBridge>;
@ -88,4 +90,13 @@ describe('bindWindowWebRouter', () => {
replace: true,
});
});
it('refuses artist detail navigation without an artist id', async () => {
const navigate = vi.fn().mockResolvedValue(undefined);
bindWindowWebRouter({ navigate } as never);
await expect(window.SoulSyncWebRouter?.navigateToPage('artist-detail', {} as never)).resolves.toBe(false);
expect(navigate).not.toHaveBeenCalled();
});
});

View file

@ -88,6 +88,9 @@ export function bindWindowWebRouter(router: AnyRouter) {
async navigateToPage(pageId, options) {
const route = getShellRouteByPageId(pageId);
if (!route) return false;
if (pageId === 'artist-detail' && !options?.artistId) {
return false;
}
let href: `/${string}` = route.path;
if (pageId === 'artist-detail' && options?.artistId) {

View file

@ -35,6 +35,15 @@ declare global {
resolveLegacyPath: (pathname: string) => ShellPageId | null;
setActivePageChrome: (pageId: ShellPageId) => void;
activateLegacyPath: (pathname: string) => void;
navigateToArtistDetail: (
artistId: string | number,
artistName: string,
sourceOverride?: string | null,
options?: {
skipRouteChange?: boolean;
},
) => void;
cancelSimilarArtistsLoad: () => void;
showReactHost: (pageId: ShellPageId) => void;
};
}

View file

@ -16,7 +16,7 @@ describe('shellRouteManifest', () => {
expect(resolveShellPageFromPath('/discover')).toBe('discover');
expect(resolveShellPageFromPath('/watchlist')).toBe('watchlist');
expect(resolveShellPageFromPath('/active-downloads')).toBe('active-downloads');
expect(resolveShellPageFromPath('/artist-detail')).toBe('artist-detail');
expect(resolveShellPageFromPath('/artist-detail')).toBeNull();
expect(resolveShellPageFromPath('/artist-detail/spotify/2YZyLoL8N0Wb9xBt1NhZWg')).toBe('artist-detail');
expect(resolveShellPageFromPath('/artists')).toBeNull();
});
@ -50,6 +50,7 @@ describe('shellRouteManifest', () => {
expect(resolveLegacyShellPageFromPath('/search')).toBe('search');
expect(resolveLegacyShellPageFromPath('/active-downloads')).toBe('active-downloads');
expect(resolveLegacyShellPageFromPath('/tools')).toBe('tools');
expect(resolveLegacyShellPageFromPath('/artist-detail')).toBeNull();
expect(resolveLegacyShellPageFromPath('/artist-detail/deezer/12345')).toBe('artist-detail');
expect(resolveLegacyShellPageFromPath('/issues')).toBeNull();
expect(resolveLegacyShellPageFromPath('/does-not-exist')).toBeNull();

View file

@ -72,7 +72,10 @@ export function getShellRouteByPath(pathname: string): ShellRouteDefinition | un
export function resolveShellPageFromPath(pathname: string): ShellPageId | null {
const normalized = normalizeShellPath(pathname);
if (normalized === '/artist-detail' || normalized.startsWith('/artist-detail/')) {
if (normalized === '/artist-detail') {
return null;
}
if (normalized.startsWith('/artist-detail/')) {
return 'artist-detail';
}
return getShellRouteByPath(pathname)?.pageId ?? null;
@ -80,7 +83,10 @@ export function resolveShellPageFromPath(pathname: string): ShellPageId | null {
export function resolveLegacyShellPageFromPath(pathname: string): ShellPageId | null {
const normalized = normalizeShellPath(pathname);
if (normalized === '/artist-detail' || normalized.startsWith('/artist-detail/')) {
if (normalized === '/artist-detail') {
return null;
}
if (normalized.startsWith('/artist-detail/')) {
return 'artist-detail';
}
const route = getShellRouteByPath(pathname);

View file

@ -12,6 +12,7 @@ import { Route as rootRouteImport } from './routes/__root'
import { Route as SplatRouteImport } from './routes/$'
import { Route as IssuesRouteRouteImport } from './routes/issues/route'
import { Route as IndexRouteImport } from './routes/index'
import { Route as ArtistDetailSourceIdRouteImport } from './routes/artist-detail/$source/$id'
const SplatRoute = SplatRouteImport.update({
id: '/$',
@ -28,35 +29,44 @@ const IndexRoute = IndexRouteImport.update({
path: '/',
getParentRoute: () => rootRouteImport,
} as any)
const ArtistDetailSourceIdRoute = ArtistDetailSourceIdRouteImport.update({
id: '/artist-detail/$source/$id',
path: '/artist-detail/$source/$id',
getParentRoute: () => rootRouteImport,
} as any)
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
'/issues': typeof IssuesRouteRoute
'/$': typeof SplatRoute
'/artist-detail/$source/$id': typeof ArtistDetailSourceIdRoute
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
'/issues': typeof IssuesRouteRoute
'/$': typeof SplatRoute
'/artist-detail/$source/$id': typeof ArtistDetailSourceIdRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport
'/': typeof IndexRoute
'/issues': typeof IssuesRouteRoute
'/$': typeof SplatRoute
'/artist-detail/$source/$id': typeof ArtistDetailSourceIdRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths: '/' | '/issues' | '/$'
fullPaths: '/' | '/issues' | '/$' | '/artist-detail/$source/$id'
fileRoutesByTo: FileRoutesByTo
to: '/' | '/issues' | '/$'
id: '__root__' | '/' | '/issues' | '/$'
to: '/' | '/issues' | '/$' | '/artist-detail/$source/$id'
id: '__root__' | '/' | '/issues' | '/$' | '/artist-detail/$source/$id'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
IssuesRouteRoute: typeof IssuesRouteRoute
SplatRoute: typeof SplatRoute
ArtistDetailSourceIdRoute: typeof ArtistDetailSourceIdRoute
}
declare module '@tanstack/react-router' {
@ -82,6 +92,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof IndexRouteImport
parentRoute: typeof rootRouteImport
}
'/artist-detail/$source/$id': {
id: '/artist-detail/$source/$id'
path: '/artist-detail/$source/$id'
fullPath: '/artist-detail/$source/$id'
preLoaderRoute: typeof ArtistDetailSourceIdRouteImport
parentRoute: typeof rootRouteImport
}
}
}
@ -89,6 +106,7 @@ const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
IssuesRouteRoute: IssuesRouteRoute,
SplatRoute: SplatRoute,
ArtistDetailSourceIdRoute: ArtistDetailSourceIdRoute,
}
export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren)

View file

@ -0,0 +1,31 @@
import { createFileRoute } from '@tanstack/react-router';
import { useLayoutEffect } from 'react';
import { useShellBridge } from '@/platform/shell/route-controllers';
export const Route = createFileRoute('/artist-detail/$source/$id')({
component: ArtistDetailPage,
});
// Thin legacy handoff: TanStack owns the URL shape here, but the vanilla JS
// artist-detail page still renders the actual experience for now. The route
// owns cancellation so similar-artist loading stops when this page changes.
function ArtistDetailPage() {
const bridge = useShellBridge();
const { source, id } = Route.useParams();
useLayoutEffect(() => {
if (!bridge) return;
const normalizedSource = source.toLowerCase() === 'library' ? null : source.toLowerCase();
bridge.navigateToArtistDetail(id, '', normalizedSource, {
skipRouteChange: true,
});
return () => {
bridge.cancelSimilarArtistsLoad();
};
}, [bridge, id, source]);
return null;
}

View file

@ -0,0 +1,99 @@
import { createMemoryHistory } from '@tanstack/react-router';
import { render, waitFor } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { ShellBridge, ShellPageId } from '@/platform/shell/bridge';
import { createAppQueryClient } from '@/app/query-client';
import { AppRouterProvider, createAppRouter } from '@/app/router';
function createShellBridge(overrides: Partial<ShellBridge> = {}): ShellBridge {
return {
getCurrentProfileContext: vi.fn(() => ({ profileId: 2, isAdmin: false })),
isPageAllowed: vi.fn(() => true),
getProfileHomePage: vi.fn<() => ShellPageId>(() => 'discover'),
resolveLegacyPath: vi.fn<(pathname: string) => ShellPageId | null>(() => 'artist-detail'),
setActivePageChrome: vi.fn(),
activateLegacyPath: vi.fn(),
navigateToArtistDetail: vi.fn(),
cancelSimilarArtistsLoad: vi.fn(),
showReactHost: vi.fn(),
...overrides,
};
}
function renderArtistDetailRoute(initialEntries = ['/artist-detail/library/42']) {
const queryClient = createAppQueryClient();
const history = createMemoryHistory({ initialEntries });
const router = createAppRouter({ history, queryClient });
return {
history,
router,
...render(<AppRouterProvider router={router} queryClient={queryClient} />),
};
}
describe('artist-detail route', () => {
beforeEach(() => {
window.SoulSyncWebShellBridge = createShellBridge();
});
afterEach(() => {
window.SoulSyncWebShellBridge = undefined;
});
it('hands off canonical artist-detail URLs to the legacy shell', async () => {
renderArtistDetailRoute(['/artist-detail/spotify/2YZyLoL8N0Wb9xBt1NhZWg']);
await waitFor(() => {
expect(window.SoulSyncWebShellBridge?.navigateToArtistDetail).toHaveBeenCalledWith(
'2YZyLoL8N0Wb9xBt1NhZWg',
'',
'spotify',
{
skipRouteChange: true,
},
);
});
});
it('normalizes library sources before handing off', async () => {
renderArtistDetailRoute(['/artist-detail/library/42']);
await waitFor(() => {
expect(window.SoulSyncWebShellBridge?.navigateToArtistDetail).toHaveBeenCalledWith(
'42',
'',
null,
{
skipRouteChange: true,
},
);
});
});
it('cancels the similar artists stream when the route unmounts', async () => {
const { unmount } = renderArtistDetailRoute(['/artist-detail/spotify/2YZyLoL8N0Wb9xBt1NhZWg']);
await waitFor(() => {
expect(window.SoulSyncWebShellBridge?.navigateToArtistDetail).toHaveBeenCalledWith(
'2YZyLoL8N0Wb9xBt1NhZWg',
'',
'spotify',
{
skipRouteChange: true,
},
);
});
const cancelSimilarArtistsLoad = window.SoulSyncWebShellBridge?.cancelSimilarArtistsLoad as ReturnType<typeof vi.fn>;
cancelSimilarArtistsLoad.mockClear();
unmount();
await waitFor(() => {
expect(cancelSimilarArtistsLoad).toHaveBeenCalledTimes(1);
});
});
});

View file

@ -22,6 +22,8 @@ function createShellBridge(overrides: Partial<ShellBridge> = {}): ShellBridge {
resolveLegacyPath: vi.fn<(pathname: string) => ShellPageId | null>(() => 'search'),
setActivePageChrome: vi.fn(),
activateLegacyPath: vi.fn(),
navigateToArtistDetail: vi.fn(),
cancelSimilarArtistsLoad: vi.fn(),
showReactHost: vi.fn(),
...overrides,
};

View file

@ -2316,6 +2316,25 @@ async function openWatchlistArtistDetailView(artistId, artistName) {
if (artist.mood) metaTags.push(`<span class="watchlist-detail-genre-tag">${escapeHtml(artist.mood)}</span>`);
if (artist.label) metaTags.push(`<span class="watchlist-detail-genre-tag">${escapeHtml(artist.label)}</span>`);
let discogId = null;
let discogSource = null;
const activeSrc = (currentMusicSourceName || '').toLowerCase();
if (activeSrc.includes('spotify') && spotify_artist_id) {
discogId = spotify_artist_id; discogSource = 'spotify';
} else if (activeSrc.includes('discogs') && discogs_artist_id) {
discogId = discogs_artist_id; discogSource = 'discogs';
} else if (activeSrc.includes('deezer') && deezer_artist_id) {
discogId = deezer_artist_id; discogSource = 'deezer';
} else if (activeSrc.includes('musicbrainz') && musicbrainz_artist_id) {
discogId = musicbrainz_artist_id; discogSource = 'musicbrainz';
} else if (itunes_artist_id) {
discogId = itunes_artist_id; discogSource = 'itunes';
} else {
discogId = spotify_artist_id || discogs_artist_id || deezer_artist_id || musicbrainz_artist_id || itunes_artist_id;
discogSource = spotify_artist_id ? 'spotify' : discogs_artist_id ? 'discogs' : deezer_artist_id ? 'deezer' : musicbrainz_artist_id ? 'musicbrainz' : 'itunes';
}
const discogHref = discogId ? buildArtistDetailPath(discogId, discogSource) : '#';
overlay.innerHTML = `
${artist.banner_url ? `
<div class="watchlist-detail-banner">
@ -2398,7 +2417,7 @@ async function openWatchlistArtistDetailView(artistId, artistName) {
</div>
<div class="watchlist-detail-actions">
<button class="watchlist-detail-discog-btn watchlist-detail-discog-action">View Discography</button>
<a class="watchlist-detail-discog-btn watchlist-detail-discog-action" href="${discogHref}" ${discogId ? 'onclick="closeWatchlistArtistDetailView()"' : 'aria-disabled="true" tabindex="-1" style="pointer-events:none;opacity:0.5;text-decoration:none;color:inherit;"'}>View Discography</a>
<button class="watchlist-detail-settings-btn watchlist-detail-settings-action">Settings</button>
<button class="watchlist-detail-remove-btn watchlist-detail-remove-action">Remove from Watchlist</button>
</div>
@ -2410,30 +2429,6 @@ async function openWatchlistArtistDetailView(artistId, artistName) {
closeWatchlistArtistDetailView();
});
overlay.querySelector('.watchlist-detail-discog-action').addEventListener('click', () => {
// Use the ID matching the active metadata source
let discogId, source;
const activeSrc = (currentMusicSourceName || '').toLowerCase();
if (activeSrc.includes('spotify') && spotify_artist_id) {
discogId = spotify_artist_id; source = 'spotify';
} else if (activeSrc.includes('discogs') && discogs_artist_id) {
discogId = discogs_artist_id; source = 'discogs';
} else if (activeSrc.includes('deezer') && deezer_artist_id) {
discogId = deezer_artist_id; source = 'deezer';
} else if (activeSrc.includes('musicbrainz') && musicbrainz_artist_id) {
discogId = musicbrainz_artist_id; source = 'musicbrainz';
} else if (itunes_artist_id) {
discogId = itunes_artist_id; source = 'itunes';
} else {
discogId = spotify_artist_id || discogs_artist_id || deezer_artist_id || musicbrainz_artist_id || itunes_artist_id;
source = spotify_artist_id ? 'spotify' : discogs_artist_id ? 'discogs' : deezer_artist_id ? 'deezer' : musicbrainz_artist_id ? 'musicbrainz' : 'itunes';
}
if (discogId) {
closeWatchlistArtistDetailView();
navigateToArtistDetail(discogId, artistName, source);
}
});
overlay.querySelector('.watchlist-detail-settings-action').addEventListener('click', () => {
// Remove overlay immediately so it doesn't block the config modal
const detailOverlay = document.querySelector('.watchlist-artist-detail-overlay');

View file

@ -201,6 +201,13 @@ let artistsSearchController = null;
let artistCompletionController = null; // Track ongoing completion check to cancel when navigating away
let similarArtistsController = null; // Track ongoing similar artists stream to cancel when navigating away
function cancelSimilarArtistsLoad() {
if (similarArtistsController) {
similarArtistsController.abort();
similarArtistsController = null;
}
}
// --- Lazy Background Image Observer ---
// Watches elements with data-bg-src, applies background-image when visible, unobserves after.
const lazyBgObserver = new IntersectionObserver((entries) => {

View file

@ -269,10 +269,9 @@ function displayDiscoverHeroArtist(artist) {
if (discographyBtn && artistId) {
discographyBtn.setAttribute('data-artist-id', artistId);
discographyBtn.setAttribute('data-artist-name', artist.artist_name);
// Source the click handler will pass to navigateToArtistDetail. Without
// this, source-only hero artists (which is the typical case — they
// come from discover similar-artists, not the library) get looked up
// as library IDs and 404. Backend always includes artist.source.
discographyBtn.href = buildArtistDetailPath(artistId, artist.source || null);
// Keep the source on the link so source-only hero artists resolve to
// the correct artist-detail URL instead of being treated as library IDs.
if (artist.source) discographyBtn.setAttribute('data-source', artist.source);
else discographyBtn.removeAttribute('data-source');
// Also store both IDs for cross-source operations
@ -557,7 +556,7 @@ function renderRecommendedArtistsModal(modal, artists, source = null) {
const similarText = artist.occurrence_count > 1
? `Similar to ${artist.occurrence_count} in your watchlist`
: 'Similar to an artist in your watchlist';
const artistSource = artist.source || source || _recommendedArtistsSource || '';
const artistSource = artist.source || source || _recommendedArtistsSource || '';
return `
<div class="recommended-artist-card"
data-artist-name="${escapeHtml(artist.artist_name).toLowerCase()}"
@ -568,21 +567,25 @@ function renderRecommendedArtistsModal(modal, artists, source = null) {
data-artist-name="${escapeHtml(artist.artist_name)}">
Add to Watchlist
</button>
<div class="recommended-card-image">
${artist.image_url ? `
<img src="${artist.image_url}"
alt="${escapeHtml(artist.artist_name)}"
loading="lazy"
onerror="this.parentElement.innerHTML='<div class=\\'recommended-card-image-fallback\\'>🎤</div>';">
` : `
<div class="recommended-card-image-fallback">🎤</div>
`}
</div>
<div class="recommended-card-info">
<span class="recommended-card-name">${escapeHtml(artist.artist_name)}</span>
<span class="recommended-card-similarity">${similarText}</span>
<div class="recommended-card-genres">${genreTags}</div>
</div>
<a class="recommended-card-link" href="${buildArtistDetailPath(artist.artist_id, artistSource || null)}"
onclick="closeRecommendedArtistsModal()"
style="display:block;text-decoration:none;color:inherit;">
<div class="recommended-card-image">
${artist.image_url ? `
<img src="${artist.image_url}"
alt="${escapeHtml(artist.artist_name)}"
loading="lazy"
onerror="this.parentElement.innerHTML='<div class=\\'recommended-card-image-fallback\\'>🎤</div>';">
` : `
<div class="recommended-card-image-fallback">🎤</div>
`}
</div>
<div class="recommended-card-info">
<span class="recommended-card-name">${escapeHtml(artist.artist_name)}</span>
<span class="recommended-card-similarity">${similarText}</span>
<div class="recommended-card-genres">${genreTags}</div>
</div>
</a>
</div>
`;
}).join('')}
@ -599,16 +602,6 @@ function renderRecommendedArtistsModal(modal, artists, source = null) {
if (watchlistBtn) {
e.stopPropagation();
toggleRecommendedWatchlist(watchlistBtn);
return;
}
const card = e.target.closest('.recommended-artist-card');
if (card) {
const artistId = card.getAttribute('data-artist-id');
const artistSource = card.getAttribute('data-artist-source') || _recommendedArtistsSource || null;
const nameEl = card.querySelector('.recommended-card-name');
const artistName = nameEl ? nameEl.textContent : '';
viewRecommendedArtistDiscography(artistId, artistName, artistSource);
}
});
}
@ -739,11 +732,6 @@ async function checkRecommendedWatchlistStatuses(artists) {
}
}
async function viewRecommendedArtistDiscography(artistId, artistName, source = null) {
closeRecommendedArtistsModal();
navigateToArtistDetail(artistId, artistName, source || null);
}
async function checkAllHeroWatchlistStatus() {
const btn = document.getElementById('discover-hero-watch-all');
if (!btn || !discoverHeroArtists || discoverHeroArtists.length === 0) return;
@ -811,26 +799,6 @@ function jumpToDiscoverHeroSlide(index) {
updateDiscoverHeroIndicators();
}
async function viewDiscoverHeroDiscography() {
const button = document.getElementById('discover-hero-discography');
if (!button) return;
const artistId = button.getAttribute('data-artist-id');
const artistName = button.getAttribute('data-artist-name');
// Pass the source so /api/artist-detail knows to synthesize from that
// metadata provider instead of doing a local DB lookup. Hero similar
// artists are almost always source-only (not in the library).
const source = button.getAttribute('data-source') || null;
if (!artistId || !artistName) {
console.error('No artist data found for discography view');
return;
}
console.log(`🎵 Navigating to artist detail for: ${artistName} (source: ${source || 'library'})`);
navigateToArtistDetail(artistId, artistName, source);
}
function showDiscoverHeroEmpty() {
const titleEl = document.getElementById('discover-hero-title');
const subtitleEl = document.getElementById('discover-hero-subtitle');
@ -4514,10 +4482,7 @@ function _renderYourArtistCard(artist) {
const detailSource = _pickArtistDetailSource(artist);
const hasId = detailSource.id && detailSource.id !== '';
// Navigate to Artists page (name click) — source artist id, needs inline view
const navAction = hasId
? `event.stopPropagation(); navigateToArtistDetail('${escapeForInlineJs(detailSource.id)}', '${escapeForInlineJs(artist.artist_name)}', '${escapeForInlineJs(detailSource.source)}' || null)`
: '';
const detailHref = hasId ? buildArtistDetailPath(detailSource.id, detailSource.source) : '';
// Open info modal (card body click) — pass pool ID so we can look up all data
const infoAction = hasId
@ -4545,7 +4510,9 @@ function _renderYourArtistCard(artist) {
<div class="ya-card-info-row">
<div class="ya-origin-dots">${originDots}</div>
</div>
<div class="ya-card-name" ${navAction ? `onclick="${navAction}"` : ''}>${_esc(artist.artist_name)}</div>
${hasId
? `<a class="ya-card-name" href="${detailHref}" onclick="event.stopPropagation(); document.getElementById('ya-info-modal-overlay')?.remove(); document.getElementById('your-artists-modal-overlay')?.remove();" style="display:block;text-decoration:none;color:inherit;">${_esc(artist.artist_name)}</a>`
: `<div class="ya-card-name">${_esc(artist.artist_name)}</div>`}
</div>
</div>
`;
@ -4695,10 +4662,10 @@ async function openYourArtistInfoModal(poolId) {
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
<span>Explore</span>
</button>
<button class="ya-header-btn ya-viewall-btn" onclick="document.getElementById('ya-info-modal-overlay')?.remove(); document.getElementById('your-artists-modal-overlay')?.remove(); navigateToArtistDetail('${escapeForInlineJs(artistId)}', '${escapeForInlineJs(artistName)}', '${escapeForInlineJs(pool.active_source || '')}' || null)">
<a class="ya-header-btn ya-viewall-btn" href="${buildArtistDetailPath(artistId, pool.active_source || null)}" onclick="document.getElementById('ya-info-modal-overlay')?.remove(); document.getElementById('your-artists-modal-overlay')?.remove();" style="text-decoration:none;color:inherit;">
<span>View Discography</span>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M5 12h14"/><path d="M12 5l7 7-7 7"/></svg>
</button>
</a>
`;
}
} catch (err) {
@ -6595,9 +6562,9 @@ function _artMapSetupInteraction(canvas) {
<div class="artmap-ctx-item" onclick="_artMapHideContextMenu(); ${hasId ? `openYourArtistInfoModal_direct(${JSON.stringify(node).replace(/"/g, '&quot;')})` : ''}">
<span>&#9432;</span> Artist Info
</div>
<div class="artmap-ctx-item" onclick="_artMapHideContextMenu(); navigateToArtistDetail('${escapeForInlineJs(bestId)}', '${escapeForInlineJs(node.name)}', '${bestSource}' || null)">
<a class="artmap-ctx-item" href="${bestId ? buildArtistDetailPath(bestId, bestSource) : '#'}" onclick="_artMapHideContextMenu()" ${bestId ? '' : 'aria-disabled="true" style="pointer-events:none;opacity:0.5;text-decoration:none;color:inherit;"'}>
<span>&#128191;</span> View Discography
</div>
</a>
<div class="artmap-ctx-item" onclick="_artMapHideContextMenu(); toggleYourArtistWatchlist(0,'${escapeForInlineJs(node.name)}','${escapeForInlineJs(bestId)}','${bestSource}',null)">
<span>&#128065;</span> ${node.type === 'watchlist' ? 'On Watchlist' : 'Add to Watchlist'}
</div>
@ -7275,9 +7242,9 @@ async function openGenreDeepDive(genre) {
// Always open on Artists page with discography — pass source for correct routing
const imgUrl = _esc(a.image_url || '');
const artSource = _esc(a.source || '');
const clickAction = `onclick="document.getElementById('genre-deep-dive-modal').remove();navigateToArtistDetail('${_esc(a.entity_id)}','${_esc(a.name)}','${artSource}' || null)"`;
const detailHref = a.entity_id ? buildArtistDetailPath(a.entity_id, a.source || null) : '#';
const srcClass = (a.source || '').toLowerCase();
return `<div class="genre-dive-artist" ${clickAction}>
return `<a class="genre-dive-artist" href="${detailHref}" onclick="document.getElementById('genre-deep-dive-modal').remove()" style="text-decoration:none;color:inherit;">
<div class="genre-dive-artist-img" style="${a.image_url ? `background-image:url('${_esc(a.image_url)}')` : ''}">
${!a.image_url ? '<span>🎤</span>' : ''}
</div>
@ -7285,7 +7252,7 @@ async function openGenreDeepDive(genre) {
<div class="genre-dive-artist-name">${_esc(a.name)}</div>
${a.followers ? `<div class="genre-dive-artist-meta">${_fmtNum(a.followers)} followers</div>` : ''}
${a.library_id ? '<div class="genre-dive-artist-badge">In Library</div>' : ''}
</div>`;
</a>`;
}).join('')}
</div>
</div>`;
@ -8768,4 +8735,3 @@ if (document.readyState === 'loading') {
}
// ============================================================================

View file

@ -633,48 +633,6 @@ async function openDownloadMissingModalForYouTube(virtualPlaylistId, playlistNam
hideLoadingOverlay();
}
function _navigateToArtistFromModal(artistId, artistName, imageUrl, source, playlistId) {
if (!artistName) return;
// Close the download modal
const process = playlistId ? activeDownloadProcesses[playlistId] : null;
const artistContext = process?.artist || {};
const inferredSource = artistContext.spotify_artist_id ? 'spotify'
: artistContext.itunes_artist_id ? 'itunes'
: (artistContext.deezer_artist_id || artistContext.deezer_id) ? 'deezer'
: (artistContext.discogs_artist_id || artistContext.discogs_id) ? 'discogs'
: (artistContext.amazon_artist_id || artistContext.amazon_id) ? 'amazon'
: (artistContext.soul_id || artistContext.hydrabase_artist_id) ? 'hydrabase'
: null;
const resolvedSource = source || process?.artist?.source || process?.album?.source || process?.source || inferredSource;
const sourceKey = (resolvedSource || '').toString().toLowerCase();
const sourceIdFields = {
spotify: ['spotify_artist_id', 'id', 'artist_id'],
itunes: ['itunes_artist_id', 'artist_id', 'id'],
deezer: ['deezer_artist_id', 'deezer_id', 'artist_id', 'id'],
discogs: ['discogs_artist_id', 'discogs_id', 'artist_id', 'id'],
amazon: ['amazon_artist_id', 'amazon_id', 'artist_id', 'id'],
hydrabase: ['soul_id', 'hydrabase_artist_id', 'artist_id', 'id'],
musicbrainz: ['musicbrainz_id', 'artist_id', 'id'],
};
let resolvedArtistId = artistId;
for (const field of (sourceIdFields[sourceKey] || ['artist_id', 'id'])) {
const candidate = artistContext?.[field];
if (candidate) {
resolvedArtistId = candidate;
break;
}
}
if (resolvedArtistId && String(resolvedArtistId).toLowerCase() === String(artistName).toLowerCase()) {
resolvedArtistId = null;
}
if (playlistId) closeDownloadMissingModal(playlistId);
if (!resolvedArtistId || !resolvedSource) {
showToast(`Artist details are not available for ${artistName}`, 'warning');
return;
}
navigateToArtistDetail(resolvedArtistId, artistName, resolvedSource);
}
async function closeDownloadMissingModal(playlistId) {
const process = activeDownloadProcesses[playlistId];
if (!process) {
@ -5698,13 +5656,13 @@ function _gsRenderFromState(state) {
if (dbArtists.length) {
h += '<div class="gsearch-section-header">📚 In Your Library</div><div class="gsearch-grid">';
h += dbArtists.map(a => `<div class="gsearch-item" onclick="_gsClickArtist('${a.id}', '${_escAttr(a.name)}', true)"><div class="gsearch-item-art">${a.image_url ? `<img src="${a.image_url}" loading="lazy" onerror="this.parentElement.textContent='🎤'">` : '🎤'}</div><div class="gsearch-item-info"><div class="gsearch-item-title">${_escToast(a.name)}</div><div class="gsearch-item-sub">Library</div></div></div>`).join('');
h += dbArtists.map(a => `<a class="gsearch-item" href="${a.id ? buildArtistDetailPath(a.id, null) : '#'}" onclick="_gsDeactivate()" style="text-decoration:none;color:inherit;">${a.image_url ? `<div class="gsearch-item-art"><img src="${a.image_url}" loading="lazy" onerror="this.parentElement.textContent='🎤'"></div>` : '<div class="gsearch-item-art">🎤</div>'}<div class="gsearch-item-info"><div class="gsearch-item-title">${_escToast(a.name)}</div><div class="gsearch-item-sub">Library</div></div></a>`).join('');
h += '</div>';
}
if (artists.length) {
h += `<div class="gsearch-section-header">🎤 Artists <span class="gsearch-source-badge">${srcLabel}</span></div><div class="gsearch-grid" id="gsearch-artists-grid">`;
h += artists.map(a => `<div class="gsearch-item" onclick="_gsClickArtist('${a.id}', '${_escAttr(a.name)}', false)" ${!a.image_url ? `data-artist-id="${a.id}" data-needs-image="true" data-artist-name="${_escAttr(a.name)}"` : ''}><div class="gsearch-item-art">${a.image_url ? `<img src="${a.image_url}" loading="lazy" onerror="this.parentElement.textContent='🎤'">` : '🎤'}</div><div class="gsearch-item-info"><div class="gsearch-item-title">${_escToast(a.name)}</div></div></div>`).join('');
h += artists.map(a => `<a class="gsearch-item" href="${a.id ? buildArtistDetailPath(a.id, activeSrc || null) : '#'}" onclick="_gsDeactivate()" ${!a.image_url ? `data-artist-id="${a.id}" data-needs-image="true" data-artist-name="${_escAttr(a.name)}"` : ''} style="text-decoration:none;color:inherit;">${a.image_url ? `<div class="gsearch-item-art"><img src="${a.image_url}" loading="lazy" onerror="this.parentElement.textContent='🎤'"></div>` : '<div class="gsearch-item-art">🎤</div>'}<div class="gsearch-item-info"><div class="gsearch-item-title">${_escToast(a.name)}</div></div></a>`).join('');
h += '</div>';
}
@ -5778,13 +5736,6 @@ async function _gsLazyLoadArtistImages() {
}
}
function _gsClickArtist(id, name, isLibrary) {
_gsDeactivate();
const activeSource = _gsController && _gsController.state.activeSource;
const source = isLibrary ? null : (activeSource || null);
navigateToArtistDetail(id, name, source);
}
async function _gsClickAlbum(albumId, albumName, artistName, imageUrl, source) {
_gsDeactivate();
// Same flow as handleEnhancedSearchAlbumClick — fetch album, open download modal
@ -6400,4 +6351,3 @@ const additionalStyles = `
document.head.insertAdjacentHTML('beforeend', additionalStyles);
// ============================================================================

View file

@ -2154,10 +2154,6 @@ function _getPageFromPath() {
const basePage = segs[0];
if (!_DEEPLINK_VALID_PAGES.has(basePage)) return 'dashboard';
// Context-dependent pages fall back to a sensible parent
if (basePage === 'artist-detail') {
// /artist-detail/:id deep-link — keep on artist-detail; bare /artist-detail falls back to library
return (segs.length >= 2 && segs[1]) ? 'artist-detail' : 'library';
}
if (basePage === 'playlist-explorer') return 'library';
return basePage;
}
@ -2168,35 +2164,13 @@ function _normalizeArtistDetailSource(source) {
}
function buildArtistDetailPath(artistId, source = null) {
if (!artistId) return '/artist-detail';
if (!artistId) {
throw new Error('artistId is required for artist-detail navigation');
}
const normalizedSource = _normalizeArtistDetailSource(source);
return '/artist-detail/' + encodeURIComponent(normalizedSource) + '/' + encodeURIComponent(String(artistId));
}
/** Extract artist source + ID from /artist-detail/:source/:id or legacy /artist-detail/:id URLs. */
function _getDeepLinkArtistDetail(pathname = window.location.pathname) {
const path = (pathname || '').replace(/^\/+|\/+$/g, '');
const segs = path.split('/');
if (segs[0] !== 'artist-detail' || !segs[1]) return null;
if (segs[2]) {
return {
source: _normalizeArtistDetailSource(decodeURIComponent(segs[1])),
artistId: decodeURIComponent(segs.slice(2).join('/')),
};
}
return {
source: 'library',
artistId: decodeURIComponent(segs[1]),
};
}
/** Legacy convenience wrapper for callers that only need the artist ID. */
function _getDeepLinkArtistId() {
return _getDeepLinkArtistDetail()?.artistId || null;
}
// ===============================
// MOBILE NAVIGATION
// ===============================
@ -2310,6 +2284,10 @@ function navigateToPage(pageId, options = {}) {
return;
}
if (pageId === 'artist-detail' && !options.artistId) {
return false;
}
const router = getWebRouter();
if (router && !options.skipRouteChange) {
notifyPageWillChange(pageId);
@ -2386,7 +2364,10 @@ async function loadPageData(pageId) {
case 'library':
// Check if we should return to artist detail view instead of list
if (artistDetailPageState.currentArtistId && artistDetailPageState.currentArtistName) {
navigateToPage('artist-detail');
navigateToPage('artist-detail', {
artistId: artistDetailPageState.currentArtistId,
artistSource: artistDetailPageState.currentArtistSource,
});
if (!artistDetailPageState.isInitialized) {
initializeArtistDetailPage();
loadArtistDetailData(artistDetailPageState.currentArtistId, artistDetailPageState.currentArtistName);
@ -2400,7 +2381,7 @@ async function loadPageData(pageId) {
}
break;
case 'artist-detail':
// Artist detail page is handled separately by navigateToArtistDetail()
// Artist detail page is entered through the route handoff and legacy navigator.
break;
case 'discover':
if (!discoverPageInitialized) {

View file

@ -218,6 +218,7 @@ function displayLibraryArtists(artists) {
// Ignore clicks on badge icons (they open external links / toggle watchlist)
const badge = e.target.closest('.source-card-icon');
if (badge) {
e.preventDefault();
e.stopPropagation();
const url = badge.dataset.url;
if (url) { window.open(url, '_blank'); return; }
@ -233,10 +234,6 @@ function displayLibraryArtists(artists) {
}
return;
}
const card = e.target.closest('.library-artist-card');
if (card) {
navigateToArtistDetail(card.dataset.artistId, card.dataset.artistName);
}
};
}
@ -299,14 +296,14 @@ function buildLibraryArtistCardHTML(artist, index) {
// Track stats
const trackStat = artist.track_count > 0 ? `<span class="library-artist-stat">${artist.track_count} track${artist.track_count !== 1 ? 's' : ''}</span>` : '';
return `<div class="library-artist-card" data-artist-id="${_esc(String(artist.id))}" data-artist-name="${_esc(artist.name)}" style="position:relative;animation:cardFadeIn 0.35s cubic-bezier(0.4,0,0.2,1) ${delay}ms both">
return `<a class="library-artist-card" href="${buildArtistDetailPath(artist.id)}" data-artist-id="${_esc(String(artist.id))}" data-artist-name="${_esc(artist.name)}" style="position:relative;display:block;animation:cardFadeIn 0.35s cubic-bezier(0.4,0,0.2,1) ${delay}ms both;text-decoration:none;color:inherit;">
${badgeContainerHTML}
${imageHTML}
<div class="library-artist-info">
<h3 class="library-artist-name" title="${_esc(artist.name)}">${_esc(artist.name)}</h3>
<div class="library-artist-stats">${trackStat}</div>
</div>
</div>`;
</a>`;
}
function updateLibraryPagination(pagination) {
@ -688,11 +685,6 @@ let artistDetailPageState = {
currentArtistId: null,
currentArtistName: null,
currentArtistSource: null,
// Stack of origins captured by navigateToArtistDetail for the back button.
// Each entry is either {type:'page', pageId} or {type:'artist', id, name, source}
// so chained navigation (Search → A → similar B → similar C) walks back one
// step at a time instead of jumping straight to Search.
originStack: [],
enhancedView: false,
enhancedData: null,
expandedAlbums: new Set(),
@ -710,7 +702,6 @@ function clearArtistDetailPageState() {
artistDetailPageState.currentArtistId = null;
artistDetailPageState.currentArtistName = null;
artistDetailPageState.currentArtistSource = null;
artistDetailPageState.originStack = [];
}
if (typeof window !== 'undefined') {
@ -786,22 +777,6 @@ if (typeof window !== 'undefined') {
}
// Friendly labels for the dynamic "← Back to X" button on the artist-detail page.
// Page id (the value of currentPage) -> button label.
const _ARTIST_DETAIL_BACK_LABELS = {
library: 'Back to Library',
search: 'Back to Search',
discover: 'Back to Discover',
watchlist: 'Back to Watchlist',
wishlist: 'Back to Wishlist',
stats: 'Back to Stats',
'playlist-explorer': 'Back to Explorer',
automations: 'Back to Automations',
dashboard: 'Back to Dashboard',
sync: 'Back to Sync',
'active-downloads': 'Back to Downloads',
};
function navigateToArtistDetail(artistId, artistName, sourceOverride = null, options = {}) {
const normalizedSource = sourceOverride || null;
@ -815,51 +790,13 @@ function navigateToArtistDetail(artistId, artistName, sourceOverride = null, opt
navigateToPage('artist-detail', {
artistId,
artistSource: normalizedSource,
skipRouteChange: true
skipRouteChange: options.skipRouteChange === true
});
_updateArtistDetailBackButtonLabel();
}
return;
}
console.log(`🎵 Navigating to artist detail: ${artistName} (ID: ${artistId}${sourceOverride ? `, source: ${sourceOverride}` : ''})`);
// Capture the current location on the origin stack BEFORE navigateToPage
// flips currentPage. The back button walks this stack one step at a time,
// so a chain like Search → A → similar B → similar C steps back through
// C → B → A → Search instead of jumping straight home. `skipOriginPush`
// lets the back button re-enter a prior artist without re-pushing.
if (!options.skipOriginPush) {
// Fresh entry (from a non-artist page) starts a new chain; any stale
// entries from a prior artist-detail visit are dropped.
if (currentPage !== 'artist-detail') {
artistDetailPageState.originStack = [];
}
let entry;
if (currentPage === 'artist-detail' && artistDetailPageState.currentArtistId) {
entry = {
type: 'artist',
id: artistDetailPageState.currentArtistId,
name: artistDetailPageState.currentArtistName,
source: artistDetailPageState.currentArtistSource,
};
} else {
const pageId = (typeof currentPage === 'string' && currentPage && currentPage !== 'artist-detail')
? currentPage : 'library';
entry = { type: 'page', pageId };
}
// Avoid pushing a duplicate top entry on repeated clicks of the same target.
const top = artistDetailPageState.originStack[artistDetailPageState.originStack.length - 1];
const isDuplicate = top && top.type === entry.type && (
(entry.type === 'page' && top.pageId === entry.pageId) ||
(entry.type === 'artist' && String(top.id) === String(entry.id))
);
if (!isDuplicate) {
artistDetailPageState.originStack.push(entry);
}
}
// Abort any in-progress completion stream
if (artistDetailPageState.completionController) {
artistDetailPageState.completionController.abort();
@ -909,9 +846,6 @@ function navigateToArtistDetail(artistId, artistName, sourceOverride = null, opt
skipRouteChange: options.skipRouteChange === true
});
// Update back-button label to reflect where the next pop will land.
_updateArtistDetailBackButtonLabel();
// Initialize if needed and load data
if (!artistDetailPageState.isInitialized) {
initializeArtistDetailPage();
@ -921,27 +855,11 @@ function navigateToArtistDetail(artistId, artistName, sourceOverride = null, opt
loadArtistDetailData(artistId, artistName);
}
function _updateArtistDetailBackButtonLabel() {
const backBtnLabel = document.querySelector('#artist-detail-back-btn span');
if (!backBtnLabel) return;
const stack = artistDetailPageState.originStack || [];
const top = stack[stack.length - 1];
if (!top) {
backBtnLabel.textContent = `${_ARTIST_DETAIL_BACK_LABELS.library}`;
} else if (top.type === 'artist') {
backBtnLabel.textContent = `← Back to ${top.name}`;
} else {
const friendly = _ARTIST_DETAIL_BACK_LABELS[top.pageId] || _ARTIST_DETAIL_BACK_LABELS.library;
backBtnLabel.textContent = `${friendly}`;
}
}
function initializeArtistDetailPage() {
console.log("🔧 Initializing Artist Detail page...");
// Initialize back button — pops the origin stack one step at a time so a
// chain like Search → A → B → C walks back through C → B → A → Search
// instead of jumping straight to the original entry page.
// Initialize back button — use browser history when possible, with a
// simple library fallback if the user lands here without in-app history.
const backBtn = document.getElementById("artist-detail-back-btn");
if (backBtn) {
backBtn.addEventListener("click", () => {
@ -951,27 +869,12 @@ function initializeArtistDetailPage() {
artistDetailPageState.completionController = null;
}
const stack = artistDetailPageState.originStack || [];
if (stack.length > 0) {
const target = stack.pop();
if (target.type === 'artist') {
// Re-enter a prior artist in the chain without re-pushing,
// so the stack keeps shrinking as the user steps back.
navigateToArtistDetail(target.id, target.name, target.source, { skipOriginPush: true });
return;
}
// target.type === 'page' — fully exit the artist-detail chain
artistDetailPageState.currentArtistId = null;
artistDetailPageState.currentArtistName = null;
artistDetailPageState.originStack = [];
navigateToPage(target.pageId);
if (window.history.length > 1) {
window.history.back();
return;
}
// No history — default to library
artistDetailPageState.currentArtistId = null;
artistDetailPageState.currentArtistName = null;
artistDetailPageState.originStack = [];
navigateToPage('library');
});
}
@ -1265,6 +1168,9 @@ function populateArtistDetailPage(data) {
// MusicMap name lookup). Fire-and-forget — the function handles its own
// loading state and errors.
if (artist && artist.name && typeof loadSimilarArtists === 'function') {
if (typeof cancelSimilarArtistsLoad === 'function') {
cancelSimilarArtistsLoad();
}
loadSimilarArtists(artist.name);
}
}

View file

@ -107,6 +107,21 @@ function setTrackInfo(track) {
document.getElementById('no-track-message').classList.add('hidden');
document.getElementById('media-player').classList.remove('idle');
const gotoArtistBtn = document.getElementById('np-goto-artist');
if (gotoArtistBtn) {
if (track.artist_id) {
gotoArtistBtn.href = buildArtistDetailPath(track.artist_id, track.artist_source || null);
gotoArtistBtn.style.pointerEvents = '';
gotoArtistBtn.setAttribute('aria-disabled', 'false');
gotoArtistBtn.tabIndex = 0;
} else {
gotoArtistBtn.href = '#';
gotoArtistBtn.style.pointerEvents = 'none';
gotoArtistBtn.setAttribute('aria-disabled', 'true');
gotoArtistBtn.tabIndex = -1;
}
}
// Sync expanded player and media session
updateNpTrackInfo();
updateMediaSessionMetadata();
@ -184,6 +199,14 @@ function clearTrack() {
document.getElementById('no-track-message').classList.remove('hidden');
document.getElementById('media-player').classList.add('idle');
const gotoArtistBtn = document.getElementById('np-goto-artist');
if (gotoArtistBtn) {
gotoArtistBtn.href = '#';
gotoArtistBtn.style.pointerEvents = 'none';
gotoArtistBtn.setAttribute('aria-disabled', 'true');
gotoArtistBtn.tabIndex = -1;
}
// Reset queue state
npQueue = [];
npQueueIndex = -1;
@ -1231,15 +1254,11 @@ function initExpandedPlayer() {
});
}
// Action button (Go to Artist)
// Action link (Go to Artist)
const gotoArtistBtn = document.getElementById('np-goto-artist');
if (gotoArtistBtn) {
gotoArtistBtn.addEventListener('click', () => {
if (currentTrack && currentTrack.artist_id) {
closeNowPlayingModal();
navigateToArtistDetail(currentTrack.artist_id, currentTrack.artist || '');
}
});
gotoArtistBtn.style.textDecoration = 'none';
gotoArtistBtn.style.color = 'inherit';
}
// Buffering state listeners on audioPlayer
if (audioPlayer) {
@ -2396,4 +2415,3 @@ function updateMediaSessionPlaybackState() {
}
// ===============================

View file

@ -317,11 +317,7 @@ function initializeSearchModeToggle() {
name: artist.name,
meta: 'In Your Library',
badge: { text: 'Library', class: 'enh-badge-library' },
onClick: () => {
console.log(`🎵 Opening library artist detail: ${artist.name} (ID: ${artist.id})`);
hideDropdown();
navigateToArtistDetail(artist.id, artist.name);
}
href: buildArtistDetailPath(artist.id),
})
);
@ -337,12 +333,7 @@ function initializeSearchModeToggle() {
name: artist.name,
meta: 'Artist',
badge: sourceBadge,
onClick: () => {
const sourceOverride = searchController.state.activeSource;
console.log(`🎵 Opening artist detail: ${artist.name} (ID: ${artist.id}, source: ${sourceOverride})`);
hideDropdown();
navigateToArtistDetail(artist.id, artist.name, sourceOverride || null);
}
href: buildArtistDetailPath(artist.id, searchController.state.activeSource || null),
})
);
@ -1197,16 +1188,7 @@ async function loadInitialData() {
return;
}
if (targetPage === 'artist-detail') {
const deepArtist = _getDeepLinkArtistDetail();
if (deepArtist?.artistId && typeof navigateToArtistDetail === 'function') {
navigateToArtistDetail(deepArtist.artistId, '', deepArtist.source === 'library' ? null : deepArtist.source);
} else {
navigateToPage('library', { skipRouteChange: true, forceReload: true });
}
} else {
navigateToPage(targetPage, { skipRouteChange: true, forceReload: true });
}
navigateToPage(targetPage, { forceReload: true });
} catch (error) {
console.error('Error loading initial data:', error);
}

View file

@ -545,7 +545,8 @@ function renderCompactSection(sectionId, listId, countId, items, mapItem) {
items.forEach(item => {
const config = mapItem(item);
const elem = document.createElement('div');
const isLink = isArtist && !!config.href;
const elem = document.createElement(isLink ? 'a' : 'div');
// Add appropriate card class
if (isArtist) {
@ -566,6 +567,13 @@ function renderCompactSection(sectionId, listId, countId, items, mapItem) {
elem.className = 'enh-compact-item track-item';
}
if (isLink) {
elem.href = config.href;
elem.style.color = 'inherit';
elem.style.textDecoration = 'none';
elem.setAttribute('aria-label', config.name || 'Artist');
}
// Build image HTML with type-specific classes
let imageClass = 'enh-item-image';
let placeholderClass = 'enh-item-image-placeholder';
@ -612,7 +620,9 @@ function renderCompactSection(sectionId, listId, countId, items, mapItem) {
${badgeHtml}
`;
elem.addEventListener('click', config.onClick);
if (config.onClick) {
elem.addEventListener('click', config.onClick);
}
// Add play button handler for tracks
if (isTrack && config.onPlay) {
@ -695,7 +705,7 @@ async function checkDiscographyCompletion(artistId, discography) {
} catch (error) {
// Don't show error if it was aborted (user navigated away)
if (error.name === 'AbortError') {
console.log('⏹️ Completion check aborted (user navigated to new artist)');
console.log('⏹️ Completion check aborted (user navigated away)');
return;
}
@ -3574,16 +3584,21 @@ async function loadSimilarArtists(artistName) {
container.innerHTML = '';
section.style.display = 'block';
let controller = null;
let signal = null;
try {
// Create new abort controller for this similar artists stream
similarArtistsController = new AbortController();
controller = new AbortController();
similarArtistsController = controller;
signal = controller.signal;
// Use streaming endpoint for real-time bubble creation
const url = `/api/artist/similar/${encodeURIComponent(artistName)}/stream`;
console.log(`📡 Streaming from: ${url}`);
const response = await fetch(url, {
signal: similarArtistsController.signal
signal
});
if (!response.ok) {
@ -3612,6 +3627,7 @@ async function loadSimilarArtists(artistName) {
buffer = messages.pop() || ''; // Keep incomplete message in buffer
for (const message of messages) {
if (signal?.aborted) return;
if (!message.trim() || !message.startsWith('data: ')) continue;
try {
@ -3622,6 +3638,7 @@ async function loadSimilarArtists(artistName) {
}
if (jsonData.artist) {
if (signal?.aborted) return;
// Hide loading on first artist
if (artistCount === 0) {
loadingEl.classList.add('hidden');
@ -3636,6 +3653,7 @@ async function loadSimilarArtists(artistName) {
}
if (jsonData.complete) {
if (signal?.aborted) return;
console.log(`🎉 Streaming complete: ${jsonData.total} artists`);
if (artistCount === 0) {
@ -3648,7 +3666,7 @@ async function loadSimilarArtists(artistName) {
`;
} else {
// Lazy load images for similar artists that don't have them
lazyLoadSimilarArtistImages(container);
await lazyLoadSimilarArtistImages(container, signal);
}
}
} catch (parseError) {
@ -3658,13 +3676,14 @@ async function loadSimilarArtists(artistName) {
}
// Clear the controller when done
similarArtistsController = null;
if (similarArtistsController === controller) {
similarArtistsController = null;
}
} catch (error) {
// Don't show error if it was aborted (user navigated away)
if (error.name === 'AbortError') {
console.log('⏹️ Similar artists stream aborted (user navigated to new artist)');
loadingEl.classList.add('hidden');
if (error.name === 'AbortError' || signal?.aborted) {
console.log('⏹️ Similar artists stream aborted (user navigated away)');
return;
}
@ -3683,15 +3702,18 @@ async function loadSimilarArtists(artistName) {
`;
} finally {
// Always clear the controller
similarArtistsController = null;
if (similarArtistsController === controller) {
similarArtistsController = null;
}
}
}
/**
* Lazy load images for similar artist bubbles that don't have images
*/
async function lazyLoadSimilarArtistImages(container) {
async function lazyLoadSimilarArtistImages(container, signal) {
if (!container) return;
if (signal?.aborted) return;
const bubblesNeedingImages = container.querySelectorAll('.similar-artist-bubble[data-needs-image="true"]');
@ -3707,9 +3729,11 @@ async function lazyLoadSimilarArtistImages(container) {
const bubbles = Array.from(bubblesNeedingImages);
for (let i = 0; i < bubbles.length; i += batchSize) {
if (signal?.aborted) return;
const batch = bubbles.slice(i, i + batchSize);
await Promise.all(batch.map(async (bubble) => {
if (signal?.aborted) return;
const artistId = bubble.getAttribute('data-artist-id');
const artistSource = bubble.getAttribute('data-artist-source') || '';
const artistPlugin = bubble.getAttribute('data-artist-plugin') || '';
@ -3724,9 +3748,11 @@ async function lazyLoadSimilarArtistImages(container) {
? `/api/artist/${encodeURIComponent(artistId)}/image?${params.toString()}`
: `/api/artist/${encodeURIComponent(artistId)}/image`;
const response = await fetch(imageUrl);
const response = await fetch(imageUrl, { signal });
const data = await response.json();
if (signal?.aborted) return;
if (data.success && data.image_url) {
const imageContainer = bubble.querySelector('.similar-artist-bubble-image');
if (imageContainer) {
@ -3737,6 +3763,9 @@ async function lazyLoadSimilarArtistImages(container) {
}
}
} catch (error) {
if (error?.name === 'AbortError' || signal?.aborted) {
return;
}
console.warn(`⚠️ Failed to load image for similar artist ${artistId}:`, error);
}
}));
@ -3803,10 +3832,23 @@ function displaySimilarArtists(artists) {
* Create a similar artist bubble card element
*/
function createSimilarArtistBubble(artist) {
const artistId = artist.id ? String(artist.id).trim() : '';
const hasArtistDetail = !!artistId;
// Create bubble container
const bubble = document.createElement('div');
const bubble = document.createElement(hasArtistDetail ? 'a' : 'div');
bubble.className = 'similar-artist-bubble';
bubble.setAttribute('data-artist-id', artist.id);
if (hasArtistDetail) {
bubble.href = buildArtistDetailPath(artistId, artist.source || null);
} else {
bubble.setAttribute('aria-disabled', 'true');
bubble.style.cursor = 'default';
bubble.style.pointerEvents = 'none';
}
bubble.style.color = 'inherit';
bubble.style.textDecoration = 'none';
bubble.setAttribute('aria-label', artist.name);
bubble.setAttribute('data-artist-id', artistId);
bubble.setAttribute('data-artist-source', artist.source || '');
if (artist.plugin) {
bubble.setAttribute('data-artist-plugin', artist.plugin);
@ -3858,13 +3900,6 @@ function createSimilarArtistBubble(artist) {
bubble.appendChild(genres);
}
// Click → navigate to the standalone artist-detail page. Works for both
// library and source artists thanks to the source-aware backend endpoint.
bubble.addEventListener('click', () => {
console.log(`🎵 Clicked similar artist: ${artist.name} (ID: ${artist.id})`);
navigateToArtistDetail(artist.id, artist.name, artist.source || null);
});
return bubble;
}

View file

@ -80,16 +80,6 @@ function activateLegacyPath(pathname) {
return;
}
if (targetPage === 'artist-detail') {
const deepArtist = _getDeepLinkArtistDetail(pathname);
if (deepArtist?.artistId && typeof navigateToArtistDetail === 'function') {
navigateToArtistDetail(deepArtist.artistId, '', deepArtist.source === 'library' ? null : deepArtist.source, { skipOriginPush: true, skipRouteChange: true });
return;
}
navigateToPage('library', { replace: true });
return;
}
notifyPageWillChange(targetPage);
activatePage(targetPage, { forceReload: true });
}
@ -107,16 +97,6 @@ function syncActivePageFromLocation() {
return;
}
if (targetPage === 'artist-detail') {
const deepArtist = _getDeepLinkArtistDetail();
if (deepArtist?.artistId && typeof navigateToArtistDetail === 'function') {
navigateToArtistDetail(deepArtist.artistId, '', deepArtist.source === 'library' ? null : deepArtist.source, { skipOriginPush: true, skipRouteChange: true });
return;
}
navigateToPage('library', { replace: true });
return;
}
notifyPageWillChange(targetPage);
const route = router?.routeManifest?.find((entry) => entry.pageId === targetPage);
if (route?.kind === 'react') {
@ -185,10 +165,58 @@ window.SoulSyncWebShellBridge = {
activateLegacyPath(pathname) {
activateLegacyPath(pathname);
},
navigateToArtistDetail,
cancelSimilarArtistsLoad() {
if (typeof cancelSimilarArtistsLoad === 'function') {
cancelSimilarArtistsLoad();
}
},
showReactHost(pageId) {
showReactHost(pageId);
},
};
function _handleShellLinkClick(event) {
if (event.defaultPrevented || event.button !== 0 || _isModifiedLinkClick(event)) return;
const anchor = event.target?.closest?.('a[href]');
if (!anchor || (anchor.target && anchor.target !== '_self')) return;
const href = anchor.getAttribute('href');
if (!href || href === '#' || href.startsWith('javascript:')) return;
const router = getWebRouter();
if (!router?.navigateToPage) return;
const pathname = anchor.pathname || new URL(anchor.href, window.location.href).pathname;
if (pathname.startsWith('/artist-detail/')) {
_handleArtistDetailLinkClick(event, pathname, router);
return;
}
}
function _handleArtistDetailLinkClick(event, pathname, router) {
const parts = pathname.split('/').filter(Boolean);
if (parts.length < 3) return;
// Keep the semantic link, but hand the click back to TanStack so artist
// detail navigations stay in the SPA when the router is available.
const source = decodeURIComponent(parts[1] || '');
const artistId = decodeURIComponent(parts.slice(2).join('/'));
if (!source || !artistId) return;
event.preventDefault();
void router.navigateToPage('artist-detail', {
artistId,
artistSource: source,
});
}
function _isModifiedLinkClick(event) {
return event.metaKey || event.ctrlKey || event.shiftKey || event.altKey;
}
window.addEventListener('popstate', syncActivePageFromLocation);
document.addEventListener('click', _handleShellLinkClick, true);
window.dispatchEvent(new CustomEvent(SHELL_BRIDGE_READY_EVENT));

View file

@ -162,7 +162,7 @@ async function loadStatsData() {
<span class="stats-ranked-num">${i + 1}</span>
${item.image_url ? `<img class="stats-ranked-img" src="${item.image_url}" alt="" onerror="this.style.display='none'">` : ''}
<div class="stats-ranked-info">
<div class="stats-ranked-name">${item.id ? `<a class="stats-artist-link" onclick="navigateToPage('library');setTimeout(()=>navigateToArtistDetail('${item.id}','${_esc(item.name).replace(/'/g, "\\'")}'),300)">${_esc(item.name)}</a>` : _esc(item.name)}${item.soul_id && !String(item.soul_id).startsWith('soul_unnamed_') ? ' <img src="/static/trans2.png" style="width:12px;height:12px;vertical-align:middle;opacity:0.5;" title="SoulID">' : ''}</div>
<div class="stats-ranked-name">${item.id ? `<a class="stats-artist-link" href="${buildArtistDetailPath(item.id)}">${_esc(item.name)}</a>` : _esc(item.name)}${item.soul_id && !String(item.soul_id).startsWith('soul_unnamed_') ? ' <img src="/static/trans2.png" style="width:12px;height:12px;vertical-align:middle;opacity:0.5;" title="SoulID">' : ''}</div>
<div class="stats-ranked-meta">${item.global_listeners ? _fmt(item.global_listeners) + ' global listeners' : ''}</div>
</div>
<span class="stats-ranked-count">${_fmt(item.play_count)} plays</span>
@ -176,7 +176,7 @@ async function loadStatsData() {
${item.image_url ? `<img class="stats-ranked-img" src="${item.image_url}" alt="" onerror="this.style.display='none'">` : ''}
<div class="stats-ranked-info">
<div class="stats-ranked-name">${_esc(item.name)}</div>
<div class="stats-ranked-meta">${item.artist_id ? `<a class="stats-artist-link" onclick="navigateToPage('library');setTimeout(()=>navigateToArtistDetail('${item.artist_id}','${_esc(item.artist || '').replace(/'/g, "\\'")}'),300)">${_esc(item.artist || '')}</a>` : _esc(item.artist || '')}</div>
<div class="stats-ranked-meta">${item.artist_id ? `<a class="stats-artist-link" href="${buildArtistDetailPath(item.artist_id)}">${_esc(item.artist || '')}</a>` : _esc(item.artist || '')}</div>
</div>
<span class="stats-ranked-count">${_fmt(item.play_count)} plays</span>
</div>
@ -189,7 +189,7 @@ async function loadStatsData() {
${item.image_url ? `<img class="stats-ranked-img" src="${item.image_url}" alt="" onerror="this.style.display='none'">` : ''}
<div class="stats-ranked-info">
<div class="stats-ranked-name">${_esc(item.name)}</div>
<div class="stats-ranked-meta">${item.artist_id ? `<a class="stats-artist-link" onclick="navigateToPage('library');setTimeout(()=>navigateToArtistDetail('${item.artist_id}','${_esc(item.artist || '').replace(/'/g, "\\'")}'),300)">${_esc(item.artist || '')}</a>` : _esc(item.artist || '')}${item.album ? ' · ' + _esc(item.album) : ''}</div>
<div class="stats-ranked-meta">${item.artist_id ? `<a class="stats-artist-link" href="${buildArtistDetailPath(item.artist_id)}">${_esc(item.artist || '')}</a>` : _esc(item.artist || '')}${item.album ? ' · ' + _esc(item.album) : ''}</div>
</div>
<button class="stats-play-btn" onclick="event.stopPropagation();playStatsTrack('${_esc(item.name).replace(/'/g, "\\'")}','${_esc(item.artist || '').replace(/'/g, "\\'")}','${_esc(item.album || '').replace(/'/g, "\\'")}')" title="Play"></button>
<span class="stats-ranked-count">${_fmt(item.play_count)} plays</span>
@ -231,7 +231,7 @@ function _renderTopArtistsVisual(artists) {
${top5.map((a, i) => {
const pct = Math.round((a.play_count / maxPlays) * 100);
const size = 44 + (4 - i) * 6; // Largest first: 68, 62, 56, 50, 44
return `<div class="stats-artist-bubble" onclick="${a.id ? `navigateToPage('library');setTimeout(()=>navigateToArtistDetail('${a.id}','${_esc(a.name).replace(/'/g, "\\\\'")}'),300)` : ''}" style="cursor:${a.id ? 'pointer' : 'default'}">
return `<a class="stats-artist-bubble" href="${a.id ? buildArtistDetailPath(a.id, a.source || null) : '#'}" style="cursor:${a.id ? 'pointer' : 'default'};text-decoration:none;color:inherit;">
<div class="stats-bubble-img" style="width:${size}px;height:${size}px;${a.image_url ? `background-image:url('${a.image_url}')` : ''}">
${!a.image_url ? `<span>${(a.name || '?')[0]}</span>` : ''}
</div>
@ -240,7 +240,7 @@ function _renderTopArtistsVisual(artists) {
</div>
<div class="stats-bubble-name">${_esc(a.name)}</div>
<div class="stats-bubble-count">${_fmt(a.play_count)}</div>
</div>`;
</a>`;
}).join('')}
</div>`;
}

View file

@ -16876,7 +16876,7 @@ body.helper-mode-active #dashboard-activity-feed:hover {
margin-top: 32px;
}
.watchlist-detail-actions button {
.watchlist-detail-actions > * {
flex: 1;
padding: 12px 20px;
border-radius: 8px;
@ -16885,9 +16885,14 @@ body.helper-mode-active #dashboard-activity-feed:hover {
font-weight: 600;
cursor: pointer;
transition: background 0.15s ease, transform 0.1s ease;
display: inline-flex;
align-items: center;
justify-content: center;
text-decoration: none;
box-sizing: border-box;
}
.watchlist-detail-actions button:active {
.watchlist-detail-actions > *:active {
transform: scale(0.98);
}

View file

@ -1993,6 +1993,28 @@ function generateDownloadModalHeroSection(context) {
const artistImage = artist?.image_url || artist?.images?.[0]?.url;
const albumImage = album?.image_url || album?.images?.[0]?.url;
const artistSource = artist?.source || album?.source || context.source || '';
const sourceKey = (artistSource || '').toString().toLowerCase();
const sourceIdFields = {
spotify: ['spotify_artist_id', 'id', 'artist_id'],
itunes: ['itunes_artist_id', 'artist_id', 'id'],
deezer: ['deezer_artist_id', 'deezer_id', 'artist_id', 'id'],
discogs: ['discogs_artist_id', 'discogs_id', 'artist_id', 'id'],
amazon: ['amazon_artist_id', 'amazon_id', 'artist_id', 'id'],
hydrabase: ['soul_id', 'hydrabase_artist_id', 'artist_id', 'id'],
musicbrainz: ['musicbrainz_id', 'artist_id', 'id'],
};
let detailArtistId = artist?.id || artist?.artist_id || '';
for (const field of (sourceIdFields[sourceKey] || ['artist_id', 'id'])) {
const candidate = artist?.[field];
if (candidate) {
detailArtistId = candidate;
break;
}
}
if (detailArtistId && String(detailArtistId).toLowerCase() === String(artist?.name || '').toLowerCase()) {
detailArtistId = '';
}
const artistHref = detailArtistId ? buildArtistDetailPath(detailArtistId, artistSource || null) : '#';
// Use album image as background if available
if (albumImage) {
@ -2007,7 +2029,7 @@ function generateDownloadModalHeroSection(context) {
</div>
<div class="download-missing-modal-hero-metadata">
<h1 class="download-missing-modal-hero-title">${escapeHtml(album.name || 'Unknown Album')}</h1>
<div class="download-missing-modal-hero-subtitle">by <a href="#" class="hero-artist-link" onclick="event.preventDefault();_navigateToArtistFromModal('${escapeHtml(artist.id || '')}','${escapeForInlineJs(artist.name || '')}','${escapeHtml(artist.image_url || '')}','${escapeHtml(artistSource)}','${escapeHtml(context.playlistId || '')}')">${escapeHtml(artist.name || 'Unknown Artist')}</a></div>
<div class="download-missing-modal-hero-subtitle">by <a href="${artistHref}" class="hero-artist-link" onclick="closeDownloadMissingModal('${escapeForInlineJs(context.playlistId || '')}')" style="text-decoration:none;color:inherit;">${escapeHtml(artist.name || 'Unknown Artist')}</a></div>
<div class="download-missing-modal-hero-details">
<span class="download-missing-modal-hero-detail">${album.album_type || 'Album'}</span>
<span class="download-missing-modal-hero-detail">${trackCount} tracks</span>