mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 07:15:47 +00:00
refactor(playlist): G.68 — extract runPlaylistSaveMeta + 3 hooks (song search / mutations / star+rating) (#635)
Four-cut cluster. 572 → 471 LOC (−101). Each handler on the page
collapses to a one-line delegate; pure code move otherwise.
runPlaylistSaveMeta — the meta save flow (updatePlaylistMeta then
optional uploadPlaylistCoverArt + getPlaylist refresh + cover toast,
or coverRemoved → null, then metaSaved toast + closes the modal).
Takes the deps separately from the opts object so the call-site
just passes through the modal's payload.
usePlaylistSongSearch — searchOpen + searchQuery driven debounced
search against subsonic. Owns the 350 ms timeout, the filter-out
of songs already in the playlist, and the searching state. Returns
{ searchResults, setSearchResults, searching } so addSong on the
page can still drop a just-added song out of the result list.
usePlaylistSongMutations — addSong / removeSong. removeSong is
trivial (filter + setSongs + savePlaylist with prevCount).
addSong preserves the .main-content scrollTop save / requestAnimationFrame
restore trick + drops the song out of both suggestions and search
results + fires the add toast with playlist name interpolation.
usePlaylistStarRating — handleRate (local override + playerStore
userRatingOverride + setRating API) + handleToggleStar (e.stopPropagation,
local set + playerStore starredOverride + star/unstar API). Reads
starredOverrides / setStarredOverride from playerStore directly so
the page doesn't have to thread them through.
PlaylistDetail drops these direct imports (now consumed in the
hooks/utility): updatePlaylistMeta, uploadPlaylistCoverArt, search,
setRating, star, unstar, showToast, useRef. Pure code move.
This commit is contained in:
committed by
GitHub
parent
d0a270d90a
commit
d08875dc70
@@ -0,0 +1,47 @@
|
||||
import type React from 'react';
|
||||
import type { TFunction } from 'i18next';
|
||||
import type { SubsonicPlaylist, SubsonicSong } from '../api/subsonicTypes';
|
||||
import { showToast } from '../utils/toast';
|
||||
|
||||
export interface PlaylistSongMutationsDeps {
|
||||
songs: SubsonicSong[];
|
||||
setSongs: React.Dispatch<React.SetStateAction<SubsonicSong[]>>;
|
||||
savePlaylist: (updatedSongs: SubsonicSong[], prevCount?: number) => Promise<void>;
|
||||
setSuggestions: React.Dispatch<React.SetStateAction<SubsonicSong[]>>;
|
||||
setSearchResults: React.Dispatch<React.SetStateAction<SubsonicSong[]>>;
|
||||
playlist: SubsonicPlaylist | null;
|
||||
t: TFunction;
|
||||
}
|
||||
|
||||
export interface PlaylistSongMutations {
|
||||
removeSong: (idx: number) => void;
|
||||
addSong: (song: SubsonicSong) => void;
|
||||
}
|
||||
|
||||
export function usePlaylistSongMutations(deps: PlaylistSongMutationsDeps): PlaylistSongMutations {
|
||||
const { songs, setSongs, savePlaylist, setSuggestions, setSearchResults, playlist, t } = deps;
|
||||
|
||||
const removeSong = (idx: number) => {
|
||||
const prevCount = songs.length;
|
||||
const next = songs.filter((_, i) => i !== idx);
|
||||
setSongs(next);
|
||||
savePlaylist(next, prevCount);
|
||||
};
|
||||
|
||||
const addSong = (song: SubsonicSong) => {
|
||||
if (songs.some(s => s.id === song.id)) return;
|
||||
const scrollHost = document.querySelector('.main-content') as HTMLElement | null;
|
||||
const savedScroll = scrollHost?.scrollTop ?? 0;
|
||||
const next = [...songs, song];
|
||||
setSongs(next);
|
||||
savePlaylist(next);
|
||||
setSuggestions(prev => prev.filter(s => s.id !== song.id));
|
||||
setSearchResults(prev => prev.filter(s => s.id !== song.id));
|
||||
if (scrollHost) {
|
||||
requestAnimationFrame(() => { scrollHost.scrollTop = savedScroll; });
|
||||
}
|
||||
showToast(t('playlists.addSuccess', { count: 1, playlist: playlist?.name }));
|
||||
};
|
||||
|
||||
return { removeSong, addSong };
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import type React from 'react';
|
||||
import { search } from '../api/subsonicSearch';
|
||||
import type { SubsonicSong } from '../api/subsonicTypes';
|
||||
|
||||
export interface PlaylistSongSearchResult {
|
||||
searchResults: SubsonicSong[];
|
||||
setSearchResults: React.Dispatch<React.SetStateAction<SubsonicSong[]>>;
|
||||
searching: boolean;
|
||||
}
|
||||
|
||||
export function usePlaylistSongSearch(
|
||||
songs: SubsonicSong[],
|
||||
searchOpen: boolean,
|
||||
searchQuery: string,
|
||||
): PlaylistSongSearchResult {
|
||||
const [searchResults, setSearchResults] = useState<SubsonicSong[]>([]);
|
||||
const [searching, setSearching] = useState(false);
|
||||
const searchDebounce = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!searchOpen || !searchQuery.trim()) { setSearchResults([]); return; }
|
||||
if (searchDebounce.current) clearTimeout(searchDebounce.current);
|
||||
searchDebounce.current = setTimeout(async () => {
|
||||
setSearching(true);
|
||||
try {
|
||||
const res = await search(searchQuery, { songCount: 20, artistCount: 0, albumCount: 0 });
|
||||
const existingIds = new Set(songs.map(s => s.id));
|
||||
setSearchResults(res.songs.filter(s => !existingIds.has(s.id)));
|
||||
} catch {}
|
||||
setSearching(false);
|
||||
}, 350);
|
||||
return () => { if (searchDebounce.current) clearTimeout(searchDebounce.current); };
|
||||
}, [searchQuery, searchOpen, songs]);
|
||||
|
||||
return { searchResults, setSearchResults, searching };
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import React from 'react';
|
||||
import { setRating, star, unstar } from '../api/subsonicStarRating';
|
||||
import type { SubsonicSong } from '../api/subsonicTypes';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
|
||||
export interface PlaylistStarRatingDeps {
|
||||
ratings: Record<string, number>;
|
||||
setRatings: React.Dispatch<React.SetStateAction<Record<string, number>>>;
|
||||
starredSongs: Set<string>;
|
||||
setStarredSongs: React.Dispatch<React.SetStateAction<Set<string>>>;
|
||||
}
|
||||
|
||||
export interface PlaylistStarRatingActions {
|
||||
handleRate: (songId: string, rating: number) => void;
|
||||
handleToggleStar: (song: SubsonicSong, e: React.MouseEvent) => void;
|
||||
}
|
||||
|
||||
export function usePlaylistStarRating(deps: PlaylistStarRatingDeps): PlaylistStarRatingActions {
|
||||
const { setRatings, starredSongs, setStarredSongs } = deps;
|
||||
const starredOverrides = usePlayerStore(s => s.starredOverrides);
|
||||
const setStarredOverride = usePlayerStore(s => s.setStarredOverride);
|
||||
|
||||
const handleRate = (songId: string, rating: number) => {
|
||||
setRatings(prev => ({ ...prev, [songId]: rating }));
|
||||
usePlayerStore.getState().setUserRatingOverride(songId, rating);
|
||||
setRating(songId, rating).catch(() => {});
|
||||
};
|
||||
|
||||
const handleToggleStar = (song: SubsonicSong, e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
const isStarred = song.id in starredOverrides ? starredOverrides[song.id] : starredSongs.has(song.id);
|
||||
setStarredSongs(prev => {
|
||||
const next = new Set(prev);
|
||||
isStarred ? next.delete(song.id) : next.add(song.id);
|
||||
return next;
|
||||
});
|
||||
setStarredOverride(song.id, !isStarred);
|
||||
(isStarred ? unstar(song.id, 'song') : star(song.id, 'song')).catch(() => {});
|
||||
};
|
||||
|
||||
return { handleRate, handleToggleStar };
|
||||
}
|
||||
Reference in New Issue
Block a user