mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 15:25:46 +00:00
refactor(favorites): co-locate favorites feature into features/favorites
This commit is contained in:
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user