diff --git a/src/renderer/app.tsx b/src/renderer/app.tsx index 7fd38407..494fdb27 100644 --- a/src/renderer/app.tsx +++ b/src/renderer/app.tsx @@ -11,7 +11,6 @@ import { useEffect, useMemo, useRef, useState } from 'react'; import i18n from '/@/i18n/i18n'; import { WebAudioContext } from '/@/renderer/features/player/context/webaudio-context'; -import { useServerVersion } from '/@/renderer/hooks/use-server-version'; import { useSyncSettingsToMain } from '/@/renderer/hooks/use-sync-settings-to-main'; import { ReleaseNotesModal } from './release-notes-modal'; import { AppRouter } from '/@/renderer/router/app-router'; @@ -32,7 +31,7 @@ export const App = () => { const { content, enabled } = useCssSettings(); const { bindings } = useHotkeySettings(); const cssRef = useRef(null); - useServerVersion(); + useSyncSettingsToMain(); const [webAudio, setWebAudio] = useState(); diff --git a/src/renderer/features/action-required/routes/no-network-route.tsx b/src/renderer/features/action-required/routes/no-network-route.tsx new file mode 100644 index 00000000..2ea5cdd4 --- /dev/null +++ b/src/renderer/features/action-required/routes/no-network-route.tsx @@ -0,0 +1,60 @@ +import { useTranslation } from 'react-i18next'; +import { useNavigate } from 'react-router'; + +import { PageHeader } from '/@/renderer/components/page-header/page-header'; +import { AnimatedPage } from '/@/renderer/features/shared/components/animated-page'; +import { PageErrorBoundary } from '/@/renderer/features/shared/components/page-error-boundary'; +import { AppRoute } from '/@/renderer/router/routes'; +import { Button } from '/@/shared/components/button/button'; +import { Center } from '/@/shared/components/center/center'; +import { Icon } from '/@/shared/components/icon/icon'; +import { Stack } from '/@/shared/components/stack/stack'; +import { Text } from '/@/shared/components/text/text'; + +const NoNetworkRoute = () => { + const { t } = useTranslation(); + const navigate = useNavigate(); + + const handleRetry = () => { + // Navigate to home which will trigger authentication again + navigate(AppRoute.HOME); + }; + + return ( + + +
+ + + + + {t('error.noNetwork', { postProcess: 'sentenceCase' })} + + + {t('error.noNetworkDescription', { + postProcess: 'sentenceCase', + })} + + + + +
+
+ ); +}; + +const NoNetworkRouteWithBoundary = () => { + return ( + + + + ); +}; + +export default NoNetworkRouteWithBoundary; diff --git a/src/renderer/hooks/use-server-authenticated.ts b/src/renderer/hooks/use-server-authenticated.ts index 9dfaf72b..b3edd566 100644 --- a/src/renderer/hooks/use-server-authenticated.ts +++ b/src/renderer/hooks/use-server-authenticated.ts @@ -1,8 +1,13 @@ +import { isAxiosError } from 'axios'; import isElectron from 'is-electron'; import debounce from 'lodash/debounce'; +import isEqual from 'lodash/isEqual'; import { useCallback, useEffect, useRef, useState } from 'react'; +import { useNavigate } from 'react-router'; import { api } from '/@/renderer/api'; +import { controller } from '/@/renderer/api/controller'; +import { AppRoute } from '/@/renderer/router/routes'; import { getServerById, useAuthStoreActions, useCurrentServer } from '/@/renderer/store'; import { LogCategory, logFn } from '/@/renderer/utils/logger'; import { logMsg } from '/@/renderer/utils/logger-message'; @@ -11,15 +16,35 @@ import { AuthState } from '/@/shared/types/types'; const localSettings = isElectron() ? window.api.localSettings : null; +const MIN_AUTH_DELAY_MS = 1000; +const MAX_NETWORK_RETRIES = 3; +const NETWORK_RETRY_DELAY_MS = 2000; + +const isNetworkError = (error: any): boolean => { + return ( + isAxiosError(error) && + (error.code === 'ERR_NETWORK' || + error.code === 'ECONNABORTED' || + error.code === 'ETIMEDOUT' || + error.message?.toLowerCase().includes('network') || + error.message?.toLowerCase().includes('timeout') || + !navigator.onLine) + ); +}; + export const useServerAuthenticated = () => { const priorServerId = useRef(undefined); const server = useCurrentServer(); - const [ready, setReady] = useState(AuthState.VALID); + const [ready, setReady] = useState(AuthState.LOADING); + const navigate = useNavigate(); + const retryCountRef = useRef(0); const { setCurrentServer, updateServer } = useAuthStoreActions(); const authenticateServer = useCallback( - async (serverWithAuth: NonNullable>) => { + async (serverWithAuth: NonNullable>, retryAttempt = 0) => { + const authStartTime = Date.now(); + try { setReady(AuthState.LOADING); @@ -61,6 +86,42 @@ export const useServerAuthenticated = () => { isAdmin: userInfo.isAdmin, }); + // Fetch and update server version and features + try { + const serverInfo = await controller.getServerInfo({ + apiClientProps: { + serverId: serverWithAuth.id, + }, + }); + + if (serverInfo && serverInfo.id === serverWithAuth.id) { + const { features, version } = serverInfo; + const currentServer = getServerById(serverWithAuth.id); + + if ( + currentServer && + (version !== currentServer.version || + !isEqual(features, currentServer.features)) + ) { + updateServer(serverWithAuth.id, { + features, + version, + }); + } + } + } catch (serverInfoError) { + // Log but don't fail authentication if server info fetch fails + logFn.warn(logMsg[LogCategory.SYSTEM].serverAuthenticationSuccess, { + category: LogCategory.SYSTEM, + meta: { + action: 'server_info_fetch_failed', + error: (serverInfoError as Error).message, + serverId: serverWithAuth.id, + serverName: serverWithAuth.name, + }, + }); + } + logFn.info(logMsg[LogCategory.SYSTEM].serverAuthenticationSuccess, { category: LogCategory.SYSTEM, meta: { @@ -73,6 +134,13 @@ export const useServerAuthenticated = () => { }, }); + const elapsedTime = Date.now() - authStartTime; + const remainingDelay = Math.max(0, MIN_AUTH_DELAY_MS - elapsedTime); + + if (remainingDelay > 0) { + await new Promise((resolve) => setTimeout(resolve, remainingDelay)); + } + setReady(AuthState.VALID); return; } catch (getUserInfoError: any) { @@ -128,6 +196,42 @@ export const useServerAuthenticated = () => { updateServer(serverWithAuth.id, updatedServer); + // Fetch and update server version and features + try { + const serverInfo = await controller.getServerInfo({ + apiClientProps: { + serverId: serverWithAuth.id, + }, + }); + + if (serverInfo && serverInfo.id === serverWithAuth.id) { + const { features, version } = serverInfo; + const currentServer = getServerById(serverWithAuth.id); + + if ( + currentServer && + (version !== currentServer.version || + !isEqual(features, currentServer.features)) + ) { + updateServer(serverWithAuth.id, { + features, + version, + }); + } + } + } catch (serverInfoError) { + // Log but don't fail authentication if server info fetch fails + logFn.warn(logMsg[LogCategory.SYSTEM].serverAuthenticationSuccess, { + category: LogCategory.SYSTEM, + meta: { + action: 'server_info_fetch_failed', + error: (serverInfoError as Error).message, + serverId: serverWithAuth.id, + serverName: serverWithAuth.name, + }, + }); + } + logFn.info(logMsg[LogCategory.SYSTEM].serverAuthenticationSuccess, { category: LogCategory.SYSTEM, meta: { @@ -141,6 +245,14 @@ export const useServerAuthenticated = () => { }, }); + // Ensure minimum delay before completing authentication + const elapsedTime = Date.now() - authStartTime; + const remainingDelay = Math.max(0, MIN_AUTH_DELAY_MS - elapsedTime); + + if (remainingDelay > 0) { + await new Promise((resolve) => setTimeout(resolve, remainingDelay)); + } + setReady(AuthState.VALID); return; } @@ -151,7 +263,54 @@ export const useServerAuthenticated = () => { } } catch (error) { const errorMessage = (error as Error).message || 'Authentication failed'; + const isNetwork = isNetworkError(error); + // If it's a network error and we haven't exhausted retries, retry + if (isNetwork && retryAttempt < MAX_NETWORK_RETRIES) { + const nextRetry = retryAttempt + 1; + + logFn.warn(logMsg[LogCategory.SYSTEM].serverAuthenticationFailed, { + category: LogCategory.SYSTEM, + meta: { + action: 'network_error_retry', + attempt: nextRetry, + error: errorMessage, + maxRetries: MAX_NETWORK_RETRIES, + retryDelayMs: NETWORK_RETRY_DELAY_MS, + serverId: serverWithAuth.id, + serverName: serverWithAuth.name, + serverType: serverWithAuth.type, + }, + }); + + // Wait before retrying + await new Promise((resolve) => setTimeout(resolve, NETWORK_RETRY_DELAY_MS)); + + // Retry authentication + return authenticateServer(serverWithAuth, nextRetry); + } + + // If network error and retries exhausted, redirect to no-network page + if (isNetwork && retryAttempt >= MAX_NETWORK_RETRIES) { + logFn.error(logMsg[LogCategory.SYSTEM].serverAuthenticationFailed, { + category: LogCategory.SYSTEM, + meta: { + action: 'network_error_max_retries_exceeded', + attempts: retryAttempt + 1, + error: errorMessage, + serverId: serverWithAuth.id, + serverName: serverWithAuth.name, + serverType: serverWithAuth.type, + }, + }); + + // Don't clear credentials on network failure - preserve them for when network returns + setReady(AuthState.INVALID); + navigate(AppRoute.NO_NETWORK, { replace: true }); + return; + } + + // For non-network errors, handle normally logFn.error(logMsg[LogCategory.SYSTEM].serverAuthenticationFailed, { category: LogCategory.SYSTEM, meta: { @@ -176,7 +335,7 @@ export const useServerAuthenticated = () => { setReady(AuthState.INVALID); } }, - [updateServer, setCurrentServer], + [updateServer, setCurrentServer, navigate], ); const debouncedAuth = debounce( @@ -201,6 +360,7 @@ export const useServerAuthenticated = () => { if (priorServerId.current !== server.id) { const serverWithAuth = getServerById(server.id); priorServerId.current = server.id; + retryCountRef.current = 0; // Reset retry count when server changes if (!serverWithAuth) { logFn.error(logMsg[LogCategory.SYSTEM].serverAuthenticationError, { diff --git a/src/renderer/hooks/use-server-version.ts b/src/renderer/hooks/use-server-version.ts deleted file mode 100644 index 7cc2c1e9..00000000 --- a/src/renderer/hooks/use-server-version.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { useQuery } from '@tanstack/react-query'; -import isEqual from 'lodash/isEqual'; -import { useEffect } from 'react'; - -import { controller } from '/@/renderer/api/controller'; -import { queryKeys } from '/@/renderer/api/query-keys'; -import { useAuthStoreActions, useCurrentServer } from '/@/renderer/store'; - -export const useServerVersion = () => { - const { updateServer } = useAuthStoreActions(); - const server = useCurrentServer(); - - const serverInfo = useQuery({ - enabled: !!server, - queryFn: async ({ signal }) => { - return controller.getServerInfo({ - apiClientProps: { - serverId: server?.id || '', - signal, - }, - }); - }, - queryKey: queryKeys.server.root(server?.id), - }); - - useEffect(() => { - if (!server?.id) { - return; - } - - if (server?.id === serverInfo.data?.id) { - const { features, version } = serverInfo.data || {}; - if (version !== server?.version || !isEqual(features, server?.features)) { - updateServer(server.id, { - features, - version, - }); - } - } - }, [serverInfo?.data, server?.features, server?.id, server?.version, updateServer]); -}; diff --git a/src/renderer/layouts/authentication-outlet.tsx b/src/renderer/layouts/authentication-outlet.tsx new file mode 100644 index 00000000..c0ff025b --- /dev/null +++ b/src/renderer/layouts/authentication-outlet.tsx @@ -0,0 +1,20 @@ +import { Outlet } from 'react-router'; + +import { useServerAuthenticated } from '/@/renderer/hooks/use-server-authenticated'; +import { Center } from '/@/shared/components/center/center'; +import { Spinner } from '/@/shared/components/spinner/spinner'; +import { AuthState } from '/@/shared/types/types'; + +export const AuthenticationOutlet = () => { + const authState = useServerAuthenticated(); + + if (authState === AuthState.LOADING) { + return ( +
+ +
+ ); + } + + return ; +}; diff --git a/src/renderer/router/app-outlet.tsx b/src/renderer/router/app-outlet.tsx index af7d7aa4..6efb71d3 100644 --- a/src/renderer/router/app-outlet.tsx +++ b/src/renderer/router/app-outlet.tsx @@ -1,16 +1,11 @@ import { useMemo } from 'react'; import { Navigate, Outlet } from 'react-router'; -import { useServerAuthenticated } from '/@/renderer/hooks/use-server-authenticated'; import { AppRoute } from '/@/renderer/router/routes'; import { useCurrentServer } from '/@/renderer/store'; -import { Center } from '/@/shared/components/center/center'; -import { Spinner } from '/@/shared/components/spinner/spinner'; -import { AuthState } from '/@/shared/types/types'; export const AppOutlet = () => { const currentServer = useCurrentServer(); - const authState = useServerAuthenticated(); const isActionsRequired = useMemo(() => { const isServerRequired = !currentServer; @@ -21,15 +16,7 @@ export const AppOutlet = () => { return isActionRequired; }, [currentServer]); - if (authState === AuthState.LOADING) { - return ( -
- -
- ); - } - - if (isActionsRequired || authState === AuthState.INVALID) { + if (isActionsRequired) { return ; } diff --git a/src/renderer/router/app-router.tsx b/src/renderer/router/app-router.tsx index c23c52f3..f508a8c6 100644 --- a/src/renderer/router/app-router.tsx +++ b/src/renderer/router/app-router.tsx @@ -8,6 +8,7 @@ import { UpdatePlaylistContextModal } from '/@/renderer/features/playlists/compo import { SettingsContextModal } from '/@/renderer/features/settings/components/settings-modal'; import { RouterErrorBoundary } from '/@/renderer/features/shared/components/router-error-boundary'; import { ShareItemContextModal } from '/@/renderer/features/sharing/components/share-item-context-modal'; +import { AuthenticationOutlet } from '/@/renderer/layouts/authentication-outlet'; import { ResponsiveLayout } from '/@/renderer/layouts/responsive-layout'; import { AppOutlet } from '/@/renderer/router/app-outlet'; import { AppRoute } from '/@/renderer/router/routes'; @@ -40,6 +41,10 @@ const InvalidRoute = lazy( const LoginRoute = lazy(() => import('/@/renderer/features/login/routes/login-route')); +const NoNetworkRoute = lazy( + () => import('/@/renderer/features/action-required/routes/no-network-route'), +); + const HomeRoute = lazy(() => import('/@/renderer/features/home/routes/home-route')); const ArtistListRoute = lazy(() => import('/@/renderer/features/artists/routes/artist-list-route')); @@ -96,96 +101,106 @@ export const AppRouter = () => { > - }> - }> - }> - } index /> - } path={AppRoute.HOME} /> - } path={AppRoute.SEARCH} /> - } path={AppRoute.FAVORITES} /> - } path={AppRoute.SETTINGS} /> - } - path={AppRoute.NOW_PLAYING} - /> - - } index /> + }> + }> + }> + }> + } index /> + } path={AppRoute.HOME} /> + } path={AppRoute.SEARCH} /> } - path={AppRoute.LIBRARY_GENRES_DETAIL} + element={} + path={AppRoute.FAVORITES} /> - - } - path={AppRoute.LIBRARY_ALBUMS} - /> - } - path={AppRoute.LIBRARY_ALBUMS_DETAIL} - /> - } - path={AppRoute.LIBRARY_ARTISTS} - /> - - } index /> + } + path={AppRoute.SETTINGS} + /> + } + path={AppRoute.NOW_PLAYING} + /> + + } index /> + } + path={AppRoute.LIBRARY_GENRES_DETAIL} + /> + } - path={AppRoute.LIBRARY_ARTISTS_DETAIL_DISCOGRAPHY} + path={AppRoute.LIBRARY_ALBUMS} /> } - path={AppRoute.LIBRARY_ARTISTS_DETAIL_SONGS} + element={} + path={AppRoute.LIBRARY_ALBUMS_DETAIL} /> } - path={AppRoute.LIBRARY_ARTISTS_DETAIL_TOP_SONGS} + element={} + path={AppRoute.LIBRARY_ARTISTS} /> - - } - path={AppRoute.FAKE_LIBRARY_ALBUM_DETAILS} - /> - } - path={AppRoute.LIBRARY_SONGS} - /> - } - path={AppRoute.LIBRARY_FOLDERS} - /> - } - path={AppRoute.PLAYLISTS} - /> - } path={AppRoute.RADIO} /> - } - path={AppRoute.PLAYLISTS_DETAIL_SONGS} - /> - - } index /> - + } index /> } - path={ - AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL_DISCOGRAPHY - } + path={AppRoute.LIBRARY_ARTISTS_DETAIL_DISCOGRAPHY} /> } - path={AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL_SONGS} + path={AppRoute.LIBRARY_ARTISTS_DETAIL_SONGS} /> } - path={ - AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL_TOP_SONGS - } + path={AppRoute.LIBRARY_ARTISTS_DETAIL_TOP_SONGS} /> + } + path={AppRoute.FAKE_LIBRARY_ALBUM_DETAILS} + /> + } + path={AppRoute.LIBRARY_SONGS} + /> + } + path={AppRoute.LIBRARY_FOLDERS} + /> + } + path={AppRoute.PLAYLISTS} + /> + } path={AppRoute.RADIO} /> + } + path={AppRoute.PLAYLISTS_DETAIL_SONGS} + /> + + } index /> + + } index /> + } + path={ + AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL_DISCOGRAPHY + } + /> + } + path={ + AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL_SONGS + } + /> + } + path={ + AppRoute.LIBRARY_ALBUM_ARTISTS_DETAIL_TOP_SONGS + } + /> + + + } path="*" /> - } path="*" /> @@ -196,6 +211,7 @@ export const AppRouter = () => { path={AppRoute.ACTION_REQUIRED} /> } path={AppRoute.LOGIN} /> + } path={AppRoute.NO_NETWORK} /> diff --git a/src/renderer/router/routes.ts b/src/renderer/router/routes.ts index 894fddd9..d7b08f2d 100644 --- a/src/renderer/router/routes.ts +++ b/src/renderer/router/routes.ts @@ -21,6 +21,7 @@ export enum AppRoute { LIBRARY_GENRES_DETAIL = '/library/genres/:genreId', LIBRARY_SONGS = '/library/songs', LOGIN = '/login', + NO_NETWORK = '/no-network', NOW_PLAYING = '/now-playing', PLAYING = '/playing', PLAYLISTS = '/playlists', diff --git a/src/shared/components/icon/icon.tsx b/src/shared/components/icon/icon.tsx index a724dca8..794949cf 100644 --- a/src/shared/components/icon/icon.tsx +++ b/src/shared/components/icon/icon.tsx @@ -109,6 +109,8 @@ import { LuVolume1, LuVolume2, LuVolumeX, + LuWifi, + LuWifiOff, LuX, } from 'react-icons/lu'; import { MdOutlineVisibility, MdOutlineVisibilityOff } from 'react-icons/md'; @@ -241,6 +243,8 @@ export const AppIcon = { volumeMute: LuVolumeX, volumeNormal: LuVolume1, warn: LuTriangleAlert, + wifiOff: LuWifiOff, + wifiOn: LuWifi, x: LuX, xCircle: LuCircleX, } as const; diff --git a/src/shared/components/table/table.module.css b/src/shared/components/table/table.module.css index f511206e..b166d9db 100644 --- a/src/shared/components/table/table.module.css +++ b/src/shared/components/table/table.module.css @@ -4,4 +4,5 @@ .th { padding: var(--theme-spacing-xs) var(--theme-spacing-sm); + background-color: initial; }