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:
commit
c02cf15fdf
24 changed files with 450 additions and 372 deletions
|
|
@ -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 -->
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
9
webui/src/platform/shell/globals.d.ts
vendored
9
webui/src/platform/shell/globals.d.ts
vendored
|
|
@ -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;
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
31
webui/src/routes/artist-detail/$source/$id.tsx
Normal file
31
webui/src/routes/artist-detail/$source/$id.tsx
Normal 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;
|
||||
}
|
||||
99
webui/src/routes/artist-detail/-route.test.tsx
Normal file
99
webui/src/routes/artist-detail/-route.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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, '"')})` : ''}">
|
||||
<span>ⓘ</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>💿</span> View Discography
|
||||
</div>
|
||||
</a>
|
||||
<div class="artmap-ctx-item" onclick="_artMapHideContextMenu(); toggleYourArtistWatchlist(0,'${escapeForInlineJs(node.name)}','${escapeForInlineJs(bestId)}','${bestSource}',null)">
|
||||
<span>👁</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') {
|
|||
}
|
||||
|
||||
// ============================================================================
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
// ============================================================================
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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() {
|
|||
}
|
||||
|
||||
// ===============================
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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>`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in a new issue