refactor(webui): route stats interop through shell bridge

- move stats route legacy handoffs onto explicit SoulSyncWebShellBridge methods\n- stop relying on ad hoc window globals from React code for artist navigation and playback\n- update shell bridge tests and route test doubles to enforce the expanded bridge contract
This commit is contained in:
Antti Kettunen 2026-05-14 17:15:25 +03:00
parent 5b82e6c1ba
commit 1e052373a4
No known key found for this signature in database
GPG key ID: C6B2A3D250359BD7
7 changed files with 101 additions and 47 deletions

View file

@ -63,6 +63,11 @@ function createShellBridge(overrides: Partial<ShellBridge> = {}): ShellBridge {
navigateToArtistDetail: vi.fn(),
cancelSimilarArtistsLoad: vi.fn(),
showReactHost: vi.fn(),
navigateToArtistDetail: vi.fn(),
playLibraryTrack: vi.fn(),
startStream: vi.fn(),
showLoadingOverlay: vi.fn(),
hideLoadingOverlay: vi.fn(),
...overrides,
};
}

View file

@ -19,6 +19,11 @@ describe('waitForShellContext', () => {
setActivePageChrome: vi.fn(),
cancelSimilarArtistsLoad: vi.fn(),
showReactHost: vi.fn(),
navigateToArtistDetail: vi.fn(),
playLibraryTrack: vi.fn(),
startStream: vi.fn(),
showLoadingOverlay: vi.fn(),
hideLoadingOverlay: vi.fn(),
} as NonNullable<typeof window.SoulSyncWebShellBridge>;
await expect(waitForShellContext()).resolves.toEqual({
@ -41,6 +46,11 @@ describe('waitForShellContext', () => {
setActivePageChrome: vi.fn(),
cancelSimilarArtistsLoad: vi.fn(),
showReactHost: vi.fn(),
navigateToArtistDetail: vi.fn(),
playLibraryTrack: vi.fn(),
startStream: vi.fn(),
showLoadingOverlay: vi.fn(),
hideLoadingOverlay: vi.fn(),
} as NonNullable<typeof window.SoulSyncWebShellBridge>;
const contextPromise = waitForShellContext();

View file

@ -45,29 +45,29 @@ declare global {
) => void;
cancelSimilarArtistsLoad: () => void;
showReactHost: (pageId: ShellPageId) => void;
navigateToArtistDetail: (
artistId: string | number,
artistName: string,
sourceOverride?: string | null,
options?: Record<string, unknown>,
) => void;
playLibraryTrack: (
track: {
id: string | number;
title: string;
file_path: string;
bitrate?: string | number | null;
artist_id?: string | number | null;
album_id?: string | number | null;
_stats_image?: string | null;
},
albumTitle: string,
artistName: string,
) => void | Promise<void>;
startStream: (searchResult: Record<string, unknown>) => void | Promise<void>;
showLoadingOverlay: (message?: string) => void;
hideLoadingOverlay: () => void;
};
navigateToArtistDetail?: (
artistId: string | number,
artistName: string,
sourceOverride?: string | null,
options?: Record<string, unknown>,
) => void;
playLibraryTrack?: (
track: {
id: string | number;
title: string;
file_path: string;
bitrate?: string | number | null;
artist_id?: string | number | null;
album_id?: string | number | null;
_stats_image?: string | null;
},
albumTitle: string,
artistName: string,
) => void | Promise<void>;
startStream?: (searchResult: Record<string, unknown>) => void | Promise<void>;
showLoadingOverlay?: (message?: string) => void;
hideLoadingOverlay?: () => void;
}
}

View file

@ -25,6 +25,11 @@ function createShellBridge(overrides: Partial<ShellBridge> = {}): ShellBridge {
navigateToArtistDetail: vi.fn(),
cancelSimilarArtistsLoad: vi.fn(),
showReactHost: vi.fn(),
navigateToArtistDetail: vi.fn(),
playLibraryTrack: vi.fn(),
startStream: vi.fn(),
showLoadingOverlay: vi.fn(),
hideLoadingOverlay: vi.fn(),
...overrides,
};
}

View file

@ -23,6 +23,11 @@ function createShellBridge(overrides: Partial<ShellBridge> = {}): ShellBridge {
setActivePageChrome: vi.fn(),
activateLegacyPath: vi.fn(),
showReactHost: vi.fn(),
navigateToArtistDetail: vi.fn(),
playLibraryTrack: vi.fn(),
startStream: vi.fn(),
showLoadingOverlay: vi.fn(),
hideLoadingOverlay: vi.fn(),
...overrides,
};
}
@ -41,11 +46,6 @@ function renderStatsRoute(initialEntries = ['/stats']) {
describe('stats route', () => {
beforeEach(() => {
window.SoulSyncWebShellBridge = createShellBridge();
window.navigateToArtistDetail = vi.fn();
window.playLibraryTrack = vi.fn();
window.startStream = vi.fn();
window.showLoadingOverlay = vi.fn();
window.hideLoadingOverlay = vi.fn();
window.showToast = vi.fn();
vi.stubGlobal(
'fetch',

View file

@ -14,6 +14,8 @@ import {
YAxis,
} from 'recharts';
import type { ShellBridge } from '@/platform/shell/bridge';
import { useReactPageShell } from '@/platform/shell/route-controllers';
import type {
@ -72,7 +74,7 @@ const STATS_CHART_CURSOR = {
} as const;
export function StatsPage() {
useReactPageShell('stats');
const bridge = useReactPageShell('stats');
const navigate = useNavigate({ from: Route.fullPath });
const queryClient = useQueryClient();
@ -202,31 +204,42 @@ export function StatsPage() {
</div>
</StatsSectionCard>
<StatsSectionCard title="Recently Played">
<StatsRecentPlays tracks={cachedStats?.recent ?? []} onPlay={playStatsTrack} />
<StatsRecentPlays
tracks={cachedStats?.recent ?? []}
onPlay={(track) => playStatsTrack(bridge, track)}
/>
</StatsSectionCard>
</div>
<div className={styles.statsRightCol}>
<StatsSectionCard title="Top Artists">
<TopArtistsVisual
artists={cachedStats?.top_artists ?? []}
onArtistSelect={openArtistDetail}
onArtistSelect={(artistId, artistName) =>
void openArtistDetail(bridge, artistId, artistName)
}
/>
<StatsRankedArtists
artists={cachedStats?.top_artists ?? []}
onArtistSelect={openArtistDetail}
onArtistSelect={(artistId, artistName) =>
void openArtistDetail(bridge, artistId, artistName)
}
/>
</StatsSectionCard>
<StatsSectionCard title="Top Albums">
<StatsRankedAlbums
albums={cachedStats?.top_albums ?? []}
onArtistSelect={openArtistDetail}
onArtistSelect={(artistId, artistName) =>
void openArtistDetail(bridge, artistId, artistName)
}
/>
</StatsSectionCard>
<StatsSectionCard title="Top Tracks">
<StatsRankedTracks
tracks={cachedStats?.top_tracks ?? []}
onArtistSelect={openArtistDetail}
onPlay={playStatsTrack}
onArtistSelect={(artistId, artistName) =>
void openArtistDetail(bridge, artistId, artistName)
}
onPlay={(track) => playStatsTrack(bridge, track)}
/>
</StatsSectionCard>
</div>
@ -606,7 +619,10 @@ function StatsRecentPlays({
<div id="stats-recent-plays" className={styles.statsRecentList}>
{tracks.length === 0 ? <EmptyListState message="No recent plays" /> : null}
{tracks.map((track, index) => (
<div key={`${track.title}-${track.played_at ?? index}`} className={styles.statsRecentItem}>
<div
key={`${track.title}-${track.artist ?? ''}-${track.album ?? ''}-${track.played_at ?? ''}-${index}`}
className={styles.statsRecentItem}
>
<button
type="button"
className={`${styles.statsPlayButton} ${styles.statsPlayButtonSmall}`}
@ -861,17 +877,24 @@ function EmptyListState({ message }: { message: string }) {
return <div className={styles.emptyListState}>{message}</div>;
}
async function openArtistDetail(artistId: string | number, artistName: string) {
async function openArtistDetail(
bridge: ShellBridge,
artistId: string | number,
artistName: string,
) {
await window.SoulSyncWebRouter?.navigateToPage('library');
window.setTimeout(() => {
window.navigateToArtistDetail?.(artistId, artistName);
bridge.navigateToArtistDetail(artistId, artistName);
}, 300);
}
async function playStatsTrack(track: { title: string; artist: string; album: string }) {
async function playStatsTrack(
bridge: ShellBridge,
track: { title: string; artist: string; album: string },
) {
const resolvedTrack = await resolveStatsTrack(track.title, track.artist);
if (resolvedTrack) {
void window.playLibraryTrack?.(
await bridge.playLibraryTrack(
{
id: resolvedTrack.id,
title: resolvedTrack.title,
@ -887,23 +910,19 @@ async function playStatsTrack(track: { title: string; artist: string; album: str
return;
}
window.showLoadingOverlay?.(`Searching for ${track.title}...`);
bridge.showLoadingOverlay(`Searching for ${track.title}...`);
try {
const streamResult = await streamStatsTrack(track.title, track.artist, track.album);
window.hideLoadingOverlay?.();
bridge.hideLoadingOverlay();
if (streamResult) {
if (typeof window.startStream === 'function') {
await window.startStream(streamResult);
} else {
window.showToast?.('Streaming not available', 'error');
}
await bridge.startStream(streamResult);
return;
}
window.showToast?.('Track not found in library or any source', 'error');
} catch (error) {
window.hideLoadingOverlay?.();
bridge.hideLoadingOverlay();
window.showToast?.(getErrorMessage(error), 'error');
}
}

View file

@ -177,6 +177,21 @@ window.SoulSyncWebShellBridge = {
showReactHost(pageId) {
showReactHost(pageId);
},
navigateToArtistDetail(artistId, artistName, sourceOverride, options) {
return navigateToArtistDetail(artistId, artistName, sourceOverride, options);
},
playLibraryTrack(track, albumTitle, artistName) {
return playLibraryTrack(track, albumTitle, artistName);
},
startStream(searchResult) {
return startStream(searchResult);
},
showLoadingOverlay(message) {
return showLoadingOverlay(message);
},
hideLoadingOverlay() {
return hideLoadingOverlay();
},
};
function _handleShellLinkClick(event) {