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

This commit is contained in:
Psychotoxical
2026-06-29 22:59:21 +02:00
parent ea3e3f1adf
commit dbffe59e58
13 changed files with 91 additions and 78 deletions
-147
View File
@@ -1,147 +0,0 @@
import { useEffect, useMemo, useState } from 'react';
import { getInternetRadioStations } from '../api/subsonicRadio';
import { getStarred } from '../api/subsonicStarRating';
import type {
InternetRadioStation, SubsonicAlbum, SubsonicArtist, SubsonicSong,
} from '../api/subsonicTypes';
import { useAuthStore } from '../store/authStore';
import { usePlayerStore } from '../store/playerStore';
import type { TopFavoriteArtist } from '../components/favorites/TopFavoriteArtists';
import { useConnectionStatus } from './useConnectionStatus';
import { isActiveServerReachable } from '../utils/network/activeServerReachability';
import { useOfflineBrowseContext } from './useOfflineBrowseContext';
import { useOfflineBrowseReloadToken } from './useOfflineBrowseReloadToken';
import {
loadStarredFromAllLibraryIndexes,
loadStarredFromAllServersOnline,
} from '../utils/offline/offlineStarredLoad';
export interface FavoritesDataResult {
albums: SubsonicAlbum[];
artists: SubsonicArtist[];
songs: SubsonicSong[];
setSongs: React.Dispatch<React.SetStateAction<SubsonicSong[]>>;
radioStations: InternetRadioStation[];
setRadioStations: React.Dispatch<React.SetStateAction<InternetRadioStation[]>>;
loading: boolean;
topFavoriteArtists: TopFavoriteArtist[];
unfavoriteStation: (id: string) => void;
}
function topArtistKey(song: SubsonicSong): string {
const artistKey = song.artistId || song.artist;
if (!artistKey) return '';
return song.serverId ? `${song.serverId}:${artistKey}` : artistKey;
}
export function useFavoritesData(): FavoritesDataResult {
const [albums, setAlbums] = useState<SubsonicAlbum[]>([]);
const [artists, setArtists] = useState<SubsonicArtist[]>([]);
const [songs, setSongs] = useState<SubsonicSong[]>([]);
const [radioStations, setRadioStations] = useState<InternetRadioStation[]>([]);
const [loading, setLoading] = useState(true);
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
const favoritesOfflineEnabled = useAuthStore(s => s.favoritesOfflineEnabled);
const servers = useAuthStore(s => s.servers);
const { status: connStatus } = useConnectionStatus();
const offlineBrowseActive = useOfflineBrowseContext().active;
const offlineBrowseReloadTs = useOfflineBrowseReloadToken();
const starredOverrides = usePlayerStore(s => s.starredOverrides);
useEffect(() => {
let cancelled = false;
const applyStarred = (starred: {
albums: SubsonicAlbum[];
artists: SubsonicArtist[];
songs: SubsonicSong[];
}) => {
if (cancelled) return;
setAlbums(starred.albums);
setArtists(starred.artists);
setSongs(starred.songs);
};
const loadRadioFavorites = async () => {
if (!isActiveServerReachable()) return;
try {
const favIds = new Set<string>(JSON.parse(localStorage.getItem('psysonic_radio_favorites') ?? '[]'));
if (favIds.size === 0) return;
const all = await getInternetRadioStations();
if (!cancelled) {
setRadioStations(all.filter(s => favIds.has(s.id)));
}
} catch { /* ignore */ }
};
const loadAll = async () => {
setLoading(true);
if (favoritesOfflineEnabled) {
try {
applyStarred(await loadStarredFromAllLibraryIndexes(offlineBrowseActive));
} catch { /* ignore */ }
if (!cancelled) setLoading(false);
if (connStatus === 'connected' && isActiveServerReachable()) {
try {
applyStarred(await loadStarredFromAllServersOnline());
} catch { /* keep library snapshot */ }
}
} else {
if (connStatus === 'connected' && isActiveServerReachable()) {
const [starredResult] = await Promise.allSettled([getStarred()]);
if (starredResult.status === 'fulfilled') {
applyStarred(starredResult.value);
}
}
if (!cancelled) setLoading(false);
}
void loadRadioFavorites();
};
void loadAll();
return () => { cancelled = true; };
}, [musicLibraryFilterVersion, connStatus, favoritesOfflineEnabled, offlineBrowseActive, offlineBrowseReloadTs, servers]);
const topFavoriteArtists = useMemo<TopFavoriteArtist[]>(() => {
const counts = new Map<string, TopFavoriteArtist>();
for (const s of songs) {
if (starredOverrides[s.id] === false) continue;
const key = topArtistKey(s);
if (!key) continue;
const existing = counts.get(key);
if (existing) {
existing.count += 1;
} else {
counts.set(key, {
id: key,
name: s.artist || key,
count: 1,
coverArtId: s.artistId || '',
serverId: s.serverId,
artistId: s.artistId || s.artist,
});
}
}
return Array.from(counts.values())
.sort((a, b) => b.count - a.count)
.slice(0, 12);
}, [songs, starredOverrides]);
function unfavoriteStation(id: string) {
setRadioStations(prev => prev.filter(s => s.id !== id));
try {
const next = new Set<string>(JSON.parse(localStorage.getItem('psysonic_radio_favorites') ?? '[]'));
next.delete(id);
localStorage.setItem('psysonic_radio_favorites', JSON.stringify([...next]));
} catch { /* ignore */ }
}
return {
albums, artists, songs, setSongs, radioStations, setRadioStations,
loading, topFavoriteArtists, unfavoriteStation,
};
}
-87
View File
@@ -1,87 +0,0 @@
import { useMemo } from 'react';
import { useAuthStore } from '../store/authStore';
import { useFavoritesOfflineSyncStore } from '../store/favoritesOfflineSyncStore';
import { useLocalPlaybackStore } from '../store/localPlaybackStore';
import { useOfflineJobStore } from '../store/offlineJobStore';
import { FAVORITES_OFFLINE_JOB_ID } from '../utils/offline/favoritesOfflineConstants';
import { entryBelongsToServer } from '../utils/offline/offlineLibraryHelpers';
export type FavoritesOfflineUiStatus =
| 'disabled'
| 'syncing'
| 'complete'
| 'partial'
| 'error'
| 'idle';
export type FavoritesOfflineSemaphore = 'red' | 'yellow' | 'green';
export interface FavoritesOfflineStatusResult {
enabled: boolean;
status: FavoritesOfflineUiStatus;
semaphore: FavoritesOfflineSemaphore | null;
savedCount: number;
targetCount: number;
jobDone: number;
jobTotal: number;
}
export function useFavoritesOfflineStatus(): FavoritesOfflineStatusResult {
const enabled = useAuthStore(s => s.favoritesOfflineEnabled);
const serverId = useAuthStore(s => s.activeServerId);
const entries = useLocalPlaybackStore(s => s.entries);
const running = useFavoritesOfflineSyncStore(s => s.running);
const lastError = useFavoritesOfflineSyncStore(s => s.lastError);
const targetTrackIds = useFavoritesOfflineSyncStore(s => s.targetTrackIds);
const jobs = useOfflineJobStore(s => s.jobs);
return useMemo(() => {
if (!enabled) {
return {
enabled: false,
status: 'disabled' as const,
semaphore: null,
savedCount: 0,
targetCount: 0,
jobDone: 0,
jobTotal: 0,
};
}
const favJobs = jobs.filter(j => j.albumId === FAVORITES_OFFLINE_JOB_ID);
const jobDone = favJobs.filter(j => j.status === 'done').length;
const jobTotal = favJobs.length;
const hasActiveJobs = favJobs.some(j => j.status === 'downloading' || j.status === 'queued');
const hasJobErrors = favJobs.some(j => j.status === 'error');
const savedCount = serverId
? Object.values(entries).filter(
e => e.tier === 'favorite-auto' && entryBelongsToServer(e, serverId),
).length
: 0;
const targetCount = targetTrackIds.length;
let status: FavoritesOfflineUiStatus = 'idle';
if (running || hasActiveJobs) {
status = 'syncing';
} else if (lastError || hasJobErrors) {
status = 'error';
} else if (targetCount > 0 && savedCount >= targetCount) {
status = 'complete';
} else if (savedCount > 0 && targetCount > 0 && savedCount < targetCount) {
status = 'partial';
} else if (savedCount > 0) {
status = 'complete';
}
let semaphore: FavoritesOfflineSemaphore = 'green';
if (lastError || hasJobErrors) {
semaphore = 'red';
} else if (running || hasActiveJobs || (targetCount > 0 && savedCount < targetCount)) {
semaphore = 'yellow';
}
return { enabled, status, semaphore, savedCount, targetCount, jobDone, jobTotal };
}, [enabled, serverId, entries, running, lastError, targetTrackIds, jobs]);
}
-56
View File
@@ -1,56 +0,0 @@
import React, { useCallback, useEffect, useRef } from 'react';
import type { SubsonicSong } from '../api/subsonicTypes';
import { useSelectionStore } from '../store/selectionStore';
export interface FavoritesSelectionResult {
toggleSelect: (id: string, idx: number, shift: boolean) => void;
}
export function useFavoritesSelection(
visibleSongs: SubsonicSong[],
inSelectMode: boolean,
tracklistRef: React.RefObject<HTMLDivElement | null>,
): FavoritesSelectionResult {
const lastSelectedIdxRef = useRef<number | null>(null);
// Clear selection when song list changes
useEffect(() => {
useSelectionStore.getState().clearAll();
lastSelectedIdxRef.current = null;
}, [visibleSongs]);
// Clear selection on click outside tracklist
useEffect(() => {
if (!inSelectMode) return;
const handler = (e: MouseEvent) => {
const target = e.target as HTMLElement;
if (!tracklistRef.current || tracklistRef.current.contains(target)) return;
// Toolbar (play/enqueue, filters, bulk actions) sits outside the tracklist
// DOM but belongs to the selection — don't clear before its click runs.
if (target.closest('.favorites-songs-toolbar, .bulk-pl-picker-wrap, .context-submenu')) return;
useSelectionStore.getState().clearAll();
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, [inSelectMode, tracklistRef]);
const toggleSelect = useCallback((id: string, idx: number, shift: boolean) => {
useSelectionStore.getState().setSelectedIds(prev => {
const next = new Set(prev);
if (shift && lastSelectedIdxRef.current !== null) {
const from = Math.min(lastSelectedIdxRef.current, idx);
const to = Math.max(lastSelectedIdxRef.current, idx);
for (let j = from; j <= to; j++) {
const sid = visibleSongs[j]?.id;
if (sid) next.add(sid);
}
} else {
if (next.has(id)) { next.delete(id); }
else { next.add(id); lastSelectedIdxRef.current = idx; }
}
return next;
});
}, [visibleSongs]);
return { toggleSelect };
}
-151
View File
@@ -1,151 +0,0 @@
import React, { useMemo } from 'react';
import { ArrowDown, ArrowUp } from 'lucide-react';
import type { SubsonicSong } from '../api/subsonicTypes';
import { usePlayerStore } from '../store/playerStore';
const CURRENT_YEAR = new Date().getFullYear();
const MIN_YEAR = 1950;
// Columns that support 3-state sorting (asc → desc → reset).
// Single source of truth — the tracklist header imports this to decide which
// headers show the sortable affordance, so the cursor and the click behaviour
// can never drift apart. Every key here is handled in the comparator below.
export const SORTABLE_COLUMNS = new Set(['title', 'artist', 'album', 'rating', 'duration', 'playCount', 'lastPlayed', 'bpm']);
export type SortDir = 'asc' | 'desc';
export interface FavoritesSongFilteringDeps {
songs: SubsonicSong[];
sortKey: string;
setSortKey: React.Dispatch<React.SetStateAction<string>>;
sortDir: SortDir;
setSortDir: React.Dispatch<React.SetStateAction<SortDir>>;
sortClickCount: number;
setSortClickCount: React.Dispatch<React.SetStateAction<number>>;
selectedArtist: string | null;
selectedGenres: string[];
yearRange: [number, number];
ratings: Record<string, number>;
}
export interface FavoritesSongFilteringResult {
filteredSongs: SubsonicSong[];
visibleSongs: SubsonicSong[];
handleSortClick: (key: string) => void;
getSortIndicator: (key: string) => React.ReactNode;
}
export function useFavoritesSongFiltering(deps: FavoritesSongFilteringDeps): FavoritesSongFilteringResult {
const {
songs, sortKey, setSortKey, sortDir, setSortDir, sortClickCount, setSortClickCount,
selectedArtist, selectedGenres, yearRange, ratings,
} = deps;
const starredOverrides = usePlayerStore(s => s.starredOverrides);
const userRatingOverrides = usePlayerStore(s => s.userRatingOverrides);
const handleSortClick = (key: string) => {
if (!SORTABLE_COLUMNS.has(key)) return;
if (sortKey === key) {
const nextCount = sortClickCount + 1;
if (nextCount >= 3) {
// Reset to natural order (favorite addition order)
setSortKey('natural');
setSortDir('asc');
setSortClickCount(0);
} else {
// Toggle direction
setSortDir(d => d === 'asc' ? 'desc' : 'asc');
setSortClickCount(nextCount);
}
} else {
// Start new sort on this column
setSortKey(key);
setSortDir('asc');
setSortClickCount(1);
}
};
const getSortIndicator = (key: string) => {
if (sortKey !== key) return null;
if (sortClickCount === 0) return null;
return sortDir === 'asc' ? <ArrowUp size={12} style={{ marginLeft: 4, opacity: 0.7 }} /> : <ArrowDown size={12} style={{ marginLeft: 4, opacity: 0.7 }} />;
};
// ── Filter logic ─────────────────────────────────────────────────────────
const filteredSongs = useMemo(() => {
return songs.filter(s => {
// Remove unfavorited
if (starredOverrides[s.id] === false) return false;
// Artist filter (composite key when favorites span servers)
if (selectedArtist) {
const compositeKey = s.serverId
? `${s.serverId}:${s.artistId || s.artist}`
: (s.artistId || s.artist);
const artistMatch = selectedArtist === compositeKey
|| s.artistId === selectedArtist
|| s.artist === selectedArtist
|| s.albumArtist === selectedArtist;
if (!artistMatch) return false;
}
// Genre filter
if (selectedGenres.length > 0) {
const songGenre = s.genre || '';
const hasMatchingGenre = selectedGenres.some(g =>
songGenre.toLowerCase().includes(g.toLowerCase())
);
if (!hasMatchingGenre) return false;
}
// Year range filter — only applied when range is non-default; songs without year are excluded
if (yearRange[0] !== MIN_YEAR || yearRange[1] !== CURRENT_YEAR) {
if (s.year === undefined || s.year < yearRange[0] || s.year > yearRange[1]) return false;
}
return true;
});
}, [songs, starredOverrides, selectedArtist, selectedGenres, yearRange]);
// ── Sort logic ───────────────────────────────────────────────────────────
const visibleSongs = useMemo(() => {
if (sortKey === 'natural' || sortClickCount === 0) {
return filteredSongs;
}
const sorted = [...filteredSongs];
const multiplier = sortDir === 'asc' ? 1 : -1;
return sorted.sort((a, b) => {
switch (sortKey) {
case 'title':
return multiplier * (a.title || '').localeCompare(b.title || '');
case 'artist':
return multiplier * ((a.artist || '').localeCompare(b.artist || ''));
case 'album':
return multiplier * ((a.album || '').localeCompare(b.album || ''));
case 'rating': {
const ratingA = ratings[a.id] ?? userRatingOverrides[a.id] ?? a.userRating ?? 0;
const ratingB = ratings[b.id] ?? userRatingOverrides[b.id] ?? b.userRating ?? 0;
return multiplier * (ratingA - ratingB);
}
case 'duration':
return multiplier * ((a.duration || 0) - (b.duration || 0));
case 'playCount':
return multiplier * ((a.playCount || 0) - (b.playCount || 0));
case 'lastPlayed': {
const ta = a.played ? Date.parse(a.played) || 0 : 0;
const tb = b.played ? Date.parse(b.played) || 0 : 0;
return multiplier * (ta - tb);
}
case 'bpm':
return multiplier * ((a.bpm || 0) - (b.bpm || 0));
default:
return 0;
}
});
}, [filteredSongs, sortKey, sortDir, sortClickCount, ratings, userRatingOverrides]);
return { filteredSongs, visibleSongs, handleSortClick, getSortIndicator };
}