diff --git a/src/App.tsx b/src/App.tsx
index 5c462d6d..8c67093a 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -1,6 +1,6 @@
-import React, { useEffect, useState, useCallback, useRef, lazy, Suspense } from 'react';
+import React, { useEffect, useState, useCallback, useRef, Suspense } from 'react';
import { showToast } from './utils/toast';
-import { Routes, Route, Navigate, useNavigate, useLocation } from 'react-router-dom';
+import { useNavigate, useLocation } from 'react-router-dom';
import { listen } from '@tauri-apps/api/event';
import { invoke } from '@tauri-apps/api/core';
import { getCurrentWindow } from '@tauri-apps/api/window';
@@ -9,46 +9,11 @@ import { useTranslation } from 'react-i18next';
import Sidebar from './components/Sidebar';
import PlayerBar from './components/PlayerBar';
import BottomNav from './components/BottomNav';
-import MobilePlayerView from './components/MobilePlayerView';
import { useIsMobile } from './hooks/useIsMobile';
import LiveSearch from './components/LiveSearch';
import NowPlayingDropdown from './components/NowPlayingDropdown';
import QueuePanel from './components/QueuePanel';
-
-// Route-level lazy loading: keeps the non-page graph (shell, player, stores) in
-// the entry chunk; each page is fetched when its route is first visited.
-const Home = lazy(() => import('./pages/Home'));
-const Albums = lazy(() => import('./pages/Albums'));
-const Artists = lazy(() => import('./pages/Artists'));
-const ArtistDetail = lazy(() => import('./pages/ArtistDetail'));
-const Composers = lazy(() => import('./pages/Composers'));
-const ComposerDetail = lazy(() => import('./pages/ComposerDetail'));
-const NewReleases = lazy(() => import('./pages/NewReleases'));
-const Favorites = lazy(() => import('./pages/Favorites'));
-const RandomMix = lazy(() => import('./pages/RandomMix'));
-const RandomLanding = lazy(() => import('./pages/RandomLanding'));
-const AlbumDetail = lazy(() => import('./pages/AlbumDetail'));
-const MostPlayed = lazy(() => import('./pages/MostPlayed'));
-const LosslessAlbums = lazy(() => import('./pages/LosslessAlbums'));
-const RandomAlbums = lazy(() => import('./pages/RandomAlbums'));
-const LuckyMixPage = lazy(() => import('./pages/LuckyMix'));
-const SearchResults = lazy(() => import('./pages/SearchResults'));
-const Playlists = lazy(() => import('./pages/Playlists'));
-const PlaylistDetail = lazy(() => import('./pages/PlaylistDetail'));
-const NowPlayingPage = lazy(() => import('./pages/NowPlaying'));
-const Tracks = lazy(() => import('./pages/Tracks'));
-const Settings = lazy(() => import('./pages/Settings'));
-const Statistics = lazy(() => import('./pages/Statistics'));
-const Help = lazy(() => import('./pages/Help'));
-const WhatsNew = lazy(() => import('./pages/WhatsNew'));
-const DeviceSync = lazy(() => import('./pages/DeviceSync'));
-const OfflineLibrary = lazy(() => import('./pages/OfflineLibrary'));
-const LabelAlbums = lazy(() => import('./pages/LabelAlbums'));
-const AdvancedSearch = lazy(() => import('./pages/AdvancedSearch'));
-const FolderBrowser = lazy(() => import('./pages/FolderBrowser'));
-const InternetRadio = lazy(() => import('./pages/InternetRadio'));
-const Genres = lazy(() => import('./pages/Genres'));
-const GenreDetail = lazy(() => import('./pages/GenreDetail'));
+import AppRoutes from './app/AppRoutes';
import FullscreenPlayer from './components/FullscreenPlayer';
import ContextMenu from './components/ContextMenu';
import SongInfoModal from './components/SongInfoModal';
@@ -71,7 +36,6 @@ import { useOrbitGuest } from './hooks/useOrbitGuest';
import { cleanupOrphanedOrbitPlaylists, endOrbitSession, leaveOrbitSession } from './utils/orbit';
import { useOrbitStore } from './store/orbitStore';
import { IS_LINUX, IS_MACOS, IS_WINDOWS } from './utils/platform';
-import { version } from '../package.json';
import { useConnectionStatus } from './hooks/useConnectionStatus';
import { useAuthStore } from './store/authStore';
import {
@@ -86,7 +50,6 @@ import { switchActiveServer } from './utils/switchActiveServer';
import {
usePlayerStore,
getPlaybackProgressSnapshot,
- initAudioListeners,
songToTrack,
shuffleArray,
flushPlayQueuePosition,
@@ -126,12 +89,6 @@ function persistSidebarCollapsed(collapsed: boolean): void {
}
}
-export function RequireAuth({ children }: { children: React.ReactNode }) {
- const { isLoggedIn, servers, activeServerId } = useAuthStore();
- if (!isLoggedIn || !activeServerId || servers.length === 0) return ;
- return <>{children}>;
-}
-
/**
* Avoid grabbing the queue resizer when aiming at the main overlay scrollbar.
* Uses the real main viewport edge (not innerWidth − queueWidth — sidebar/zoom skew that).
@@ -679,40 +636,7 @@ export function AppShell() {
{perfFlags.disableMainRouteContentMount ? (
) : (
-
- } />
- } />
- } />
- } />
- } />
- } />
- } />
- } />
- } />
- } />
- } />
- } />
- } />
- } />
- } />
- } />
- } />
- } />
- } />
- } />
- : } />
- } />
- } />
- } />
- } />
- } />
- } />
- } />
- } />
- } />
- } />
- } />
-
+
)}
diff --git a/src/app/AppRoutes.tsx b/src/app/AppRoutes.tsx
new file mode 100644
index 00000000..074267de
--- /dev/null
+++ b/src/app/AppRoutes.tsx
@@ -0,0 +1,85 @@
+import { lazy } from 'react';
+import { Route, Routes } from 'react-router-dom';
+import MobilePlayerView from '../components/MobilePlayerView';
+import { useIsMobile } from '../hooks/useIsMobile';
+
+// Route-level lazy loading: keeps the non-page graph (shell, player, stores) in
+// the entry chunk; each page is fetched when its route is first visited.
+const Home = lazy(() => import('../pages/Home'));
+const Albums = lazy(() => import('../pages/Albums'));
+const Artists = lazy(() => import('../pages/Artists'));
+const ArtistDetail = lazy(() => import('../pages/ArtistDetail'));
+const Composers = lazy(() => import('../pages/Composers'));
+const ComposerDetail = lazy(() => import('../pages/ComposerDetail'));
+const NewReleases = lazy(() => import('../pages/NewReleases'));
+const Favorites = lazy(() => import('../pages/Favorites'));
+const RandomMix = lazy(() => import('../pages/RandomMix'));
+const RandomLanding = lazy(() => import('../pages/RandomLanding'));
+const AlbumDetail = lazy(() => import('../pages/AlbumDetail'));
+const MostPlayed = lazy(() => import('../pages/MostPlayed'));
+const LosslessAlbums = lazy(() => import('../pages/LosslessAlbums'));
+const RandomAlbums = lazy(() => import('../pages/RandomAlbums'));
+const LuckyMixPage = lazy(() => import('../pages/LuckyMix'));
+const SearchResults = lazy(() => import('../pages/SearchResults'));
+const Playlists = lazy(() => import('../pages/Playlists'));
+const PlaylistDetail = lazy(() => import('../pages/PlaylistDetail'));
+const NowPlayingPage = lazy(() => import('../pages/NowPlaying'));
+const Tracks = lazy(() => import('../pages/Tracks'));
+const Settings = lazy(() => import('../pages/Settings'));
+const Statistics = lazy(() => import('../pages/Statistics'));
+const Help = lazy(() => import('../pages/Help'));
+const WhatsNew = lazy(() => import('../pages/WhatsNew'));
+const DeviceSync = lazy(() => import('../pages/DeviceSync'));
+const OfflineLibrary = lazy(() => import('../pages/OfflineLibrary'));
+const LabelAlbums = lazy(() => import('../pages/LabelAlbums'));
+const AdvancedSearch = lazy(() => import('../pages/AdvancedSearch'));
+const FolderBrowser = lazy(() => import('../pages/FolderBrowser'));
+const InternetRadio = lazy(() => import('../pages/InternetRadio'));
+const Genres = lazy(() => import('../pages/Genres'));
+const GenreDetail = lazy(() => import('../pages/GenreDetail'));
+
+/**
+ * The main application route table. Rendered inside `AppShell`'s scroll
+ * viewport. `/now-playing` swaps to the mobile player view on narrow widths;
+ * `MobilePlayerView` is intentionally not lazy because the mobile breakpoint
+ * is detected synchronously and the layout swap should be flicker-free.
+ */
+export default function AppRoutes() {
+ const isMobile = useIsMobile();
+ return (
+
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ : } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+
+ );
+}
diff --git a/src/app/MainApp.tsx b/src/app/MainApp.tsx
index 7c785d24..cb534a45 100644
--- a/src/app/MainApp.tsx
+++ b/src/app/MainApp.tsx
@@ -14,7 +14,8 @@ import { initAudioListeners } from '../store/playerStore';
import { initHotCachePrefetch } from '../hotCachePrefetch';
import { initMiniPlayerBridgeOnMain } from '../utils/miniPlayerBridge';
import { IS_WINDOWS } from '../utils/platform';
-import { AppShell, RequireAuth, TauriEventBridge } from '../App';
+import { AppShell, TauriEventBridge } from '../App';
+import RequireAuth from './RequireAuth';
const Login = lazy(() => import('../pages/Login'));
diff --git a/src/app/RequireAuth.test.tsx b/src/app/RequireAuth.test.tsx
new file mode 100644
index 00000000..5bee93fe
--- /dev/null
+++ b/src/app/RequireAuth.test.tsx
@@ -0,0 +1,77 @@
+/**
+ * Route guard for the main webview. Verifies that each auth-store precondition
+ * (logged-in flag, an active server id, at least one stored server) is enforced
+ * independently before the children render.
+ */
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import { render, cleanup } from '@testing-library/react';
+
+const { authState } = vi.hoisted(() => ({
+ authState: {
+ isLoggedIn: true,
+ activeServerId: 'srv-1',
+ servers: [{ id: 'srv-1', name: 'home' }],
+ },
+}));
+
+vi.mock('../store/authStore', () => ({
+ useAuthStore: () => authState,
+}));
+
+vi.mock('react-router-dom', () => ({
+ Navigate: ({ to }: { to: string }) => ,
+}));
+
+import { RequireAuth } from './RequireAuth';
+
+afterEach(() => {
+ cleanup();
+ authState.isLoggedIn = true;
+ authState.activeServerId = 'srv-1';
+ authState.servers = [{ id: 'srv-1', name: 'home' }];
+});
+
+describe('RequireAuth', () => {
+ it('renders children when fully authenticated', () => {
+ const { getByTestId, queryByTestId } = render(
+
+
+ ,
+ );
+ expect(getByTestId('protected')).toBeTruthy();
+ expect(queryByTestId('redirect')).toBeNull();
+ });
+
+ it('redirects to /login when not logged in', () => {
+ authState.isLoggedIn = false;
+ const { getByTestId, queryByTestId } = render(
+
+
+ ,
+ );
+ expect(queryByTestId('protected')).toBeNull();
+ expect(getByTestId('redirect').getAttribute('data-to')).toBe('/login');
+ });
+
+ it('redirects to /login when no active server id is set', () => {
+ authState.activeServerId = null as unknown as string;
+ const { queryByTestId, getByTestId } = render(
+
+
+ ,
+ );
+ expect(queryByTestId('protected')).toBeNull();
+ expect(getByTestId('redirect').getAttribute('data-to')).toBe('/login');
+ });
+
+ it('redirects to /login when the server list is empty', () => {
+ authState.servers = [];
+ const { queryByTestId, getByTestId } = render(
+
+
+ ,
+ );
+ expect(queryByTestId('protected')).toBeNull();
+ expect(getByTestId('redirect').getAttribute('data-to')).toBe('/login');
+ });
+});
diff --git a/src/app/RequireAuth.tsx b/src/app/RequireAuth.tsx
new file mode 100644
index 00000000..986ffe55
--- /dev/null
+++ b/src/app/RequireAuth.tsx
@@ -0,0 +1,17 @@
+import React from 'react';
+import { Navigate } from 'react-router-dom';
+import { useAuthStore } from '../store/authStore';
+
+/**
+ * Route guard for everything under `/*`. Redirects to `/login` until the user
+ * has at least one configured server and is marked logged-in. Kept as its own
+ * file so MainApp can wrap `` without pulling the rest of App.tsx
+ * through a re-export.
+ */
+export function RequireAuth({ children }: { children: React.ReactNode }) {
+ const { isLoggedIn, servers, activeServerId } = useAuthStore();
+ if (!isLoggedIn || !activeServerId || servers.length === 0) return ;
+ return <>{children}>;
+}
+
+export default RequireAuth;