refactor(offline): co-locate offline feature into features/offline

This commit is contained in:
Psychotoxical
2026-06-30 00:56:08 +02:00
parent 70c145db06
commit d6dbb615fd
149 changed files with 521 additions and 466 deletions
@@ -0,0 +1,76 @@
import { useEffect, useRef } from 'react';
import type { Location, NavigateFunction } from 'react-router-dom';
import { resolveOfflineDisconnectNavAction } from '@/features/offline/utils/offlineBrowseRouting';
type ConnStatus = 'connected' | 'disconnected' | 'connecting' | 'unknown';
type OfflineAutoNavContext = {
favoritesOfflineBrowse: boolean;
localLibraryBrowse: boolean;
playerStatsBrowse: boolean;
playlistsOfflineBrowse: boolean;
hasManualOfflineContent: boolean;
};
/**
* On disconnect:
* - No offline browse content → stay on the current page (banner only).
* - Offline-capable route → stay and bump location state so data hooks reload.
* - Otherwise → redirect to All Albums.
*
* Only runs on connection transitions, not every render.
*/
export function useOfflineAutoNav(
connStatus: ConnStatus | string,
ctx: OfflineAutoNavContext,
location: Pick<Location, 'pathname' | 'search' | 'state'>,
navigate: NavigateFunction,
): void {
const prevConnStatus = useRef(connStatus);
useEffect(() => {
const prev = prevConnStatus.current;
prevConnStatus.current = connStatus;
if (connStatus !== 'disconnected' || prev === 'disconnected') return;
const action = resolveOfflineDisconnectNavAction(
location.pathname,
ctx.favoritesOfflineBrowse,
ctx.localLibraryBrowse,
ctx.playerStatsBrowse,
ctx.playlistsOfflineBrowse,
ctx.hasManualOfflineContent,
);
if (action.kind === 'stay') return;
if (action.kind === 'stay-reload') {
navigate(
{ pathname: location.pathname, search: location.search },
{
replace: true,
state: {
...(typeof location.state === 'object' && location.state != null
? location.state as Record<string, unknown>
: {}),
offlineBrowseReloadTs: Date.now(),
},
},
);
return;
}
navigate(action.to, { replace: true });
}, [
connStatus,
ctx.favoritesOfflineBrowse,
ctx.localLibraryBrowse,
ctx.playerStatsBrowse,
ctx.playlistsOfflineBrowse,
ctx.hasManualOfflineContent,
location.pathname,
location.search,
location.state,
navigate,
]);
}
@@ -0,0 +1,36 @@
import { useAuthStore } from '@/store/authStore';
import { useOfflineStore } from '@/features/offline/store/offlineStore';
import { useConnectionStatus } from '@/hooks/useConnectionStatus';
import { usePlayerStatsRecordingEnabled } from '@/features/stats';
import { hasOfflineBrowsingContent } from '@/features/offline/utils/favoritesOfflineBrowse';
import { useOfflineBrowseActive } from '@/features/offline/utils/offlineBrowseMode';
import {
buildOfflineBrowseContext,
computeOfflineBrowseCapabilities,
type OfflineBrowseContext,
} from '@/features/offline/utils/offlineBrowseContext';
/** Single subscription for shell and pages: offline browse mode + capabilities. */
export function useOfflineBrowseContext(): OfflineBrowseContext {
const active = useOfflineBrowseActive();
const serverId = useAuthStore(s => s.activeServerId);
const favoritesOfflineEnabled = useAuthStore(s => s.favoritesOfflineEnabled);
const offlineAlbums = useOfflineStore(s => s.albums);
const playerStats = usePlayerStatsRecordingEnabled();
const { status: connStatus } = useConnectionStatus();
const capabilities = computeOfflineBrowseCapabilities({
activeServerId: serverId,
favoritesOfflineEnabled,
offlineAlbums,
playerStats,
});
return buildOfflineBrowseContext({
active,
serverId,
capabilities,
connStatus,
hasBrowsingContent: hasOfflineBrowsingContent(offlineAlbums),
});
}
@@ -0,0 +1,8 @@
import { useLocation } from 'react-router-dom';
/** Bumps when disconnect fork chooses stay-reload ({@link useOfflineAutoNav}). */
export function useOfflineBrowseReloadToken(): number | undefined {
const location = useLocation();
const state = location.state as { offlineBrowseReloadTs?: number } | null;
return state?.offlineBrowseReloadTs;
}
@@ -0,0 +1,27 @@
import { useEffect, useRef } from 'react';
import { useOfflineBrowseContext } from '@/features/offline/hooks/useOfflineBrowseContext';
import {
restoreMusicLibraryFiltersAfterOffline,
suspendMusicLibraryFiltersForOffline,
} from '@/features/offline/utils/offlineLibraryFilterSuspend';
/** Disable scoped library browse offline; restore the picker value when back online. */
export function useOfflineLibraryFilterSuspend(): void {
const offlineBrowseActive = useOfflineBrowseContext().active;
const prevOfflineRef = useRef<boolean | null>(null);
useEffect(() => {
const prev = prevOfflineRef.current;
prevOfflineRef.current = offlineBrowseActive;
if (prev === null) {
if (offlineBrowseActive) suspendMusicLibraryFiltersForOffline();
return;
}
if (offlineBrowseActive && !prev) {
suspendMusicLibraryFiltersForOffline();
} else if (!offlineBrowseActive && prev) {
restoreMusicLibraryFiltersAfterOffline();
}
}, [offlineBrowseActive]);
}
@@ -0,0 +1,14 @@
import { useEffect } from 'react';
import { listen } from '@tauri-apps/api/event';
import { useZipDownloadStore } from '@/features/offline/store/zipDownloadStore';
/** ZIP download progress events from Rust. */
export function useZipDownloadBridge() {
useEffect(() => {
let unlisten: (() => void) | undefined;
listen<{ id: string; bytes: number; total: number | null }>('download:zip:progress', e => {
useZipDownloadStore.getState().updateProgress(e.payload.id, e.payload.bytes, e.payload.total);
}).then(u => { unlisten = u; });
return () => { unlisten?.(); };
}, []);
}