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:
parent
5b82e6c1ba
commit
1e052373a4
7 changed files with 101 additions and 47 deletions
|
|
@ -63,6 +63,11 @@ function createShellBridge(overrides: Partial<ShellBridge> = {}): ShellBridge {
|
||||||
navigateToArtistDetail: vi.fn(),
|
navigateToArtistDetail: vi.fn(),
|
||||||
cancelSimilarArtistsLoad: vi.fn(),
|
cancelSimilarArtistsLoad: vi.fn(),
|
||||||
showReactHost: vi.fn(),
|
showReactHost: vi.fn(),
|
||||||
|
navigateToArtistDetail: vi.fn(),
|
||||||
|
playLibraryTrack: vi.fn(),
|
||||||
|
startStream: vi.fn(),
|
||||||
|
showLoadingOverlay: vi.fn(),
|
||||||
|
hideLoadingOverlay: vi.fn(),
|
||||||
...overrides,
|
...overrides,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,11 @@ describe('waitForShellContext', () => {
|
||||||
setActivePageChrome: vi.fn(),
|
setActivePageChrome: vi.fn(),
|
||||||
cancelSimilarArtistsLoad: vi.fn(),
|
cancelSimilarArtistsLoad: vi.fn(),
|
||||||
showReactHost: 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>;
|
} as NonNullable<typeof window.SoulSyncWebShellBridge>;
|
||||||
|
|
||||||
await expect(waitForShellContext()).resolves.toEqual({
|
await expect(waitForShellContext()).resolves.toEqual({
|
||||||
|
|
@ -41,6 +46,11 @@ describe('waitForShellContext', () => {
|
||||||
setActivePageChrome: vi.fn(),
|
setActivePageChrome: vi.fn(),
|
||||||
cancelSimilarArtistsLoad: vi.fn(),
|
cancelSimilarArtistsLoad: vi.fn(),
|
||||||
showReactHost: 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>;
|
} as NonNullable<typeof window.SoulSyncWebShellBridge>;
|
||||||
|
|
||||||
const contextPromise = waitForShellContext();
|
const contextPromise = waitForShellContext();
|
||||||
|
|
|
||||||
44
webui/src/platform/shell/globals.d.ts
vendored
44
webui/src/platform/shell/globals.d.ts
vendored
|
|
@ -45,29 +45,29 @@ declare global {
|
||||||
) => void;
|
) => void;
|
||||||
cancelSimilarArtistsLoad: () => void;
|
cancelSimilarArtistsLoad: () => void;
|
||||||
showReactHost: (pageId: ShellPageId) => 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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,11 @@ function createShellBridge(overrides: Partial<ShellBridge> = {}): ShellBridge {
|
||||||
navigateToArtistDetail: vi.fn(),
|
navigateToArtistDetail: vi.fn(),
|
||||||
cancelSimilarArtistsLoad: vi.fn(),
|
cancelSimilarArtistsLoad: vi.fn(),
|
||||||
showReactHost: vi.fn(),
|
showReactHost: vi.fn(),
|
||||||
|
navigateToArtistDetail: vi.fn(),
|
||||||
|
playLibraryTrack: vi.fn(),
|
||||||
|
startStream: vi.fn(),
|
||||||
|
showLoadingOverlay: vi.fn(),
|
||||||
|
hideLoadingOverlay: vi.fn(),
|
||||||
...overrides,
|
...overrides,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,11 @@ function createShellBridge(overrides: Partial<ShellBridge> = {}): ShellBridge {
|
||||||
setActivePageChrome: vi.fn(),
|
setActivePageChrome: vi.fn(),
|
||||||
activateLegacyPath: vi.fn(),
|
activateLegacyPath: vi.fn(),
|
||||||
showReactHost: vi.fn(),
|
showReactHost: vi.fn(),
|
||||||
|
navigateToArtistDetail: vi.fn(),
|
||||||
|
playLibraryTrack: vi.fn(),
|
||||||
|
startStream: vi.fn(),
|
||||||
|
showLoadingOverlay: vi.fn(),
|
||||||
|
hideLoadingOverlay: vi.fn(),
|
||||||
...overrides,
|
...overrides,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -41,11 +46,6 @@ function renderStatsRoute(initialEntries = ['/stats']) {
|
||||||
describe('stats route', () => {
|
describe('stats route', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
window.SoulSyncWebShellBridge = createShellBridge();
|
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();
|
window.showToast = vi.fn();
|
||||||
vi.stubGlobal(
|
vi.stubGlobal(
|
||||||
'fetch',
|
'fetch',
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,8 @@ import {
|
||||||
YAxis,
|
YAxis,
|
||||||
} from 'recharts';
|
} from 'recharts';
|
||||||
|
|
||||||
|
import type { ShellBridge } from '@/platform/shell/bridge';
|
||||||
|
|
||||||
import { useReactPageShell } from '@/platform/shell/route-controllers';
|
import { useReactPageShell } from '@/platform/shell/route-controllers';
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
|
|
@ -72,7 +74,7 @@ const STATS_CHART_CURSOR = {
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export function StatsPage() {
|
export function StatsPage() {
|
||||||
useReactPageShell('stats');
|
const bridge = useReactPageShell('stats');
|
||||||
|
|
||||||
const navigate = useNavigate({ from: Route.fullPath });
|
const navigate = useNavigate({ from: Route.fullPath });
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
@ -202,31 +204,42 @@ export function StatsPage() {
|
||||||
</div>
|
</div>
|
||||||
</StatsSectionCard>
|
</StatsSectionCard>
|
||||||
<StatsSectionCard title="Recently Played">
|
<StatsSectionCard title="Recently Played">
|
||||||
<StatsRecentPlays tracks={cachedStats?.recent ?? []} onPlay={playStatsTrack} />
|
<StatsRecentPlays
|
||||||
|
tracks={cachedStats?.recent ?? []}
|
||||||
|
onPlay={(track) => playStatsTrack(bridge, track)}
|
||||||
|
/>
|
||||||
</StatsSectionCard>
|
</StatsSectionCard>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.statsRightCol}>
|
<div className={styles.statsRightCol}>
|
||||||
<StatsSectionCard title="Top Artists">
|
<StatsSectionCard title="Top Artists">
|
||||||
<TopArtistsVisual
|
<TopArtistsVisual
|
||||||
artists={cachedStats?.top_artists ?? []}
|
artists={cachedStats?.top_artists ?? []}
|
||||||
onArtistSelect={openArtistDetail}
|
onArtistSelect={(artistId, artistName) =>
|
||||||
|
void openArtistDetail(bridge, artistId, artistName)
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<StatsRankedArtists
|
<StatsRankedArtists
|
||||||
artists={cachedStats?.top_artists ?? []}
|
artists={cachedStats?.top_artists ?? []}
|
||||||
onArtistSelect={openArtistDetail}
|
onArtistSelect={(artistId, artistName) =>
|
||||||
|
void openArtistDetail(bridge, artistId, artistName)
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</StatsSectionCard>
|
</StatsSectionCard>
|
||||||
<StatsSectionCard title="Top Albums">
|
<StatsSectionCard title="Top Albums">
|
||||||
<StatsRankedAlbums
|
<StatsRankedAlbums
|
||||||
albums={cachedStats?.top_albums ?? []}
|
albums={cachedStats?.top_albums ?? []}
|
||||||
onArtistSelect={openArtistDetail}
|
onArtistSelect={(artistId, artistName) =>
|
||||||
|
void openArtistDetail(bridge, artistId, artistName)
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</StatsSectionCard>
|
</StatsSectionCard>
|
||||||
<StatsSectionCard title="Top Tracks">
|
<StatsSectionCard title="Top Tracks">
|
||||||
<StatsRankedTracks
|
<StatsRankedTracks
|
||||||
tracks={cachedStats?.top_tracks ?? []}
|
tracks={cachedStats?.top_tracks ?? []}
|
||||||
onArtistSelect={openArtistDetail}
|
onArtistSelect={(artistId, artistName) =>
|
||||||
onPlay={playStatsTrack}
|
void openArtistDetail(bridge, artistId, artistName)
|
||||||
|
}
|
||||||
|
onPlay={(track) => playStatsTrack(bridge, track)}
|
||||||
/>
|
/>
|
||||||
</StatsSectionCard>
|
</StatsSectionCard>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -606,7 +619,10 @@ function StatsRecentPlays({
|
||||||
<div id="stats-recent-plays" className={styles.statsRecentList}>
|
<div id="stats-recent-plays" className={styles.statsRecentList}>
|
||||||
{tracks.length === 0 ? <EmptyListState message="No recent plays" /> : null}
|
{tracks.length === 0 ? <EmptyListState message="No recent plays" /> : null}
|
||||||
{tracks.map((track, index) => (
|
{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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={`${styles.statsPlayButton} ${styles.statsPlayButtonSmall}`}
|
className={`${styles.statsPlayButton} ${styles.statsPlayButtonSmall}`}
|
||||||
|
|
@ -861,17 +877,24 @@ function EmptyListState({ message }: { message: string }) {
|
||||||
return <div className={styles.emptyListState}>{message}</div>;
|
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');
|
await window.SoulSyncWebRouter?.navigateToPage('library');
|
||||||
window.setTimeout(() => {
|
window.setTimeout(() => {
|
||||||
window.navigateToArtistDetail?.(artistId, artistName);
|
bridge.navigateToArtistDetail(artistId, artistName);
|
||||||
}, 300);
|
}, 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);
|
const resolvedTrack = await resolveStatsTrack(track.title, track.artist);
|
||||||
if (resolvedTrack) {
|
if (resolvedTrack) {
|
||||||
void window.playLibraryTrack?.(
|
await bridge.playLibraryTrack(
|
||||||
{
|
{
|
||||||
id: resolvedTrack.id,
|
id: resolvedTrack.id,
|
||||||
title: resolvedTrack.title,
|
title: resolvedTrack.title,
|
||||||
|
|
@ -887,23 +910,19 @@ async function playStatsTrack(track: { title: string; artist: string; album: str
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
window.showLoadingOverlay?.(`Searching for ${track.title}...`);
|
bridge.showLoadingOverlay(`Searching for ${track.title}...`);
|
||||||
try {
|
try {
|
||||||
const streamResult = await streamStatsTrack(track.title, track.artist, track.album);
|
const streamResult = await streamStatsTrack(track.title, track.artist, track.album);
|
||||||
window.hideLoadingOverlay?.();
|
bridge.hideLoadingOverlay();
|
||||||
|
|
||||||
if (streamResult) {
|
if (streamResult) {
|
||||||
if (typeof window.startStream === 'function') {
|
await bridge.startStream(streamResult);
|
||||||
await window.startStream(streamResult);
|
|
||||||
} else {
|
|
||||||
window.showToast?.('Streaming not available', 'error');
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
window.showToast?.('Track not found in library or any source', 'error');
|
window.showToast?.('Track not found in library or any source', 'error');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
window.hideLoadingOverlay?.();
|
bridge.hideLoadingOverlay();
|
||||||
window.showToast?.(getErrorMessage(error), 'error');
|
window.showToast?.(getErrorMessage(error), 'error');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -177,6 +177,21 @@ window.SoulSyncWebShellBridge = {
|
||||||
showReactHost(pageId) {
|
showReactHost(pageId) {
|
||||||
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) {
|
function _handleShellLinkClick(event) {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue