mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 23:05:46 +00:00
fix(favorites): bulk add-to-playlist and play/enqueue selected (#1140)
This commit is contained in:
@@ -152,6 +152,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
* The "new version available" popup no longer shows blurry, unfocused text on some Linux setups (the background blur could bleed onto the dialog). The version arrow now lines up with the heading, and the Skip / Remind me later buttons read clearly — Remind me later is the highlighted action when there's no in-app installer.
|
* The "new version available" popup no longer shows blurry, unfocused text on some Linux setups (the background blur could bleed onto the dialog). The version arrow now lines up with the heading, and the Skip / Remind me later buttons read clearly — Remind me later is the highlighted action when there's no in-app installer.
|
||||||
|
|
||||||
|
### Favorites — bulk add to playlist and play/enqueue selected
|
||||||
|
|
||||||
|
**By [@cucadmuh](https://github.com/cucadmuh), reported by zunoz on the Psysonic Discord, PR [#1140](https://github.com/Psychotoxical/psysonic/pull/1140)**
|
||||||
|
|
||||||
|
* Bulk **Add to playlist** no longer cleared the selection on `mousedown` before the click ran, so chosen tracks were not actually added.
|
||||||
|
* With rows selected, **Play all** / **Add all to queue** become **Play selected** / **Add selected to queue** and act on the checked tracks only.
|
||||||
|
* Bulk add now snapshots every checked row when the picker opens so all selected tracks land in the playlist, not just the last one.
|
||||||
|
|
||||||
|
|
||||||
## [1.48.1] - 2026-06-15
|
## [1.48.1] - 2026-06-15
|
||||||
|
|
||||||
|
|||||||
@@ -12,15 +12,19 @@ import {
|
|||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
songIds: string[];
|
songIds: string[];
|
||||||
|
/** When set (bulk toolbar pickers), read IDs at action time — avoids stale props if selection changes after open. */
|
||||||
|
resolveSongIds?: () => readonly string[];
|
||||||
onDone: () => void;
|
onDone: () => void;
|
||||||
dropDown?: boolean;
|
dropDown?: boolean;
|
||||||
triggerId?: string;
|
triggerId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AddToPlaylistSubmenu({ songIds, onDone, dropDown, triggerId }: Props) {
|
export function AddToPlaylistSubmenu({ songIds, resolveSongIds, onDone, dropDown, triggerId }: Props) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const subRef = useRef<HTMLDivElement>(null);
|
const subRef = useRef<HTMLDivElement>(null);
|
||||||
const newNameRef = useRef<HTMLInputElement>(null);
|
const newNameRef = useRef<HTMLInputElement>(null);
|
||||||
|
const songIdsRef = useRef(songIds);
|
||||||
|
songIdsRef.current = songIds;
|
||||||
const [adding, setAdding] = useState<string | null>(null);
|
const [adding, setAdding] = useState<string | null>(null);
|
||||||
const [creating, setCreating] = useState(false);
|
const [creating, setCreating] = useState(false);
|
||||||
const [newName, setNewName] = useState('');
|
const [newName, setNewName] = useState('');
|
||||||
@@ -62,24 +66,27 @@ export function AddToPlaylistSubmenu({ songIds, onDone, dropDown, triggerId }: P
|
|||||||
if (creating) newNameRef.current?.focus();
|
if (creating) newNameRef.current?.focus();
|
||||||
}, [creating]);
|
}, [creating]);
|
||||||
|
|
||||||
|
const idsForAction = () => [...(resolveSongIds?.() ?? songIdsRef.current)];
|
||||||
|
|
||||||
const handleAdd = async (pl: SubsonicPlaylist) => {
|
const handleAdd = async (pl: SubsonicPlaylist) => {
|
||||||
|
const ids = idsForAction();
|
||||||
setAdding(pl.id);
|
setAdding(pl.id);
|
||||||
try {
|
try {
|
||||||
const { songs } = await getPlaylist(pl.id);
|
const { songs } = await getPlaylist(pl.id);
|
||||||
const existingIds = new Set(songs.map((s) => s.id));
|
const existingIds = new Set(songs.map((s) => s.id));
|
||||||
const newIds = songIds.filter((id) => !existingIds.has(id));
|
const newIds = ids.filter((id) => !existingIds.has(id));
|
||||||
if (newIds.length > 0) {
|
if (newIds.length > 0) {
|
||||||
await updatePlaylist(pl.id, [...songs.map((s) => s.id), ...newIds]);
|
await updatePlaylist(pl.id, [...songs.map((s) => s.id), ...newIds]);
|
||||||
showToast(t('playlists.addSuccess', { count: newIds.length, playlist: pl.name }));
|
showToast(t('playlists.addSuccess', { count: newIds.length, playlist: pl.name }));
|
||||||
touchPlaylist(pl.id);
|
touchPlaylist(pl.id);
|
||||||
} else {
|
} else {
|
||||||
const accepted = await confirmAddAllDuplicates(pl.name, songIds.length, t);
|
const accepted = await confirmAddAllDuplicates(pl.name, ids.length, t);
|
||||||
if (accepted) {
|
if (accepted) {
|
||||||
await updatePlaylist(pl.id, [...songs.map((s) => s.id), ...songIds]);
|
await updatePlaylist(pl.id, [...songs.map((s) => s.id), ...ids]);
|
||||||
showToast(t('playlists.addedAsDuplicates', { count: songIds.length, playlist: pl.name }), 3000, 'info');
|
showToast(t('playlists.addedAsDuplicates', { count: ids.length, playlist: pl.name }), 3000, 'info');
|
||||||
touchPlaylist(pl.id);
|
touchPlaylist(pl.id);
|
||||||
} else {
|
} else {
|
||||||
showToast(t('playlists.addAllSkipped', { count: songIds.length, playlist: pl.name }), 3000, 'info');
|
showToast(t('playlists.addAllSkipped', { count: ids.length, playlist: pl.name }), 3000, 'info');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
@@ -90,11 +97,12 @@ export function AddToPlaylistSubmenu({ songIds, onDone, dropDown, triggerId }: P
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleCreate = async () => {
|
const handleCreate = async () => {
|
||||||
|
const ids = idsForAction();
|
||||||
const name = newName.trim() || t('playlists.unnamed');
|
const name = newName.trim() || t('playlists.unnamed');
|
||||||
try {
|
try {
|
||||||
const pl = await createPlaylist(name, songIds);
|
const pl = await createPlaylist(name, ids);
|
||||||
if (pl?.id) {
|
if (pl?.id) {
|
||||||
showToast(t('playlists.createAndAddSuccess', { count: songIds.length, playlist: pl.name || name }));
|
showToast(t('playlists.createAndAddSuccess', { count: ids.length, playlist: pl.name || name }));
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
showToast(t('playlists.createError'), 3000, 'error');
|
showToast(t('playlists.createError'), 3000, 'error');
|
||||||
@@ -111,7 +119,13 @@ export function AddToPlaylistSubmenu({ songIds, onDone, dropDown, triggerId }: P
|
|||||||
: { left: '100%', right: 'auto', top: flipUp ? 'auto' : -4, bottom: flipUp ? 0 : 'auto' };
|
: { left: '100%', right: 'auto', top: flipUp ? 'auto' : -4, bottom: flipUp ? 0 : 'auto' };
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="context-submenu" data-parent-trigger-id={triggerId ?? ''} ref={subRef} style={subStyle}>
|
<div
|
||||||
|
className="context-submenu"
|
||||||
|
data-parent-trigger-id={triggerId ?? ''}
|
||||||
|
ref={subRef}
|
||||||
|
style={subStyle}
|
||||||
|
onMouseDown={dropDown ? (e) => e.stopPropagation() : undefined}
|
||||||
|
>
|
||||||
{!creating ? (
|
{!creating ? (
|
||||||
<div
|
<div
|
||||||
className="context-menu-item context-submenu-new"
|
className="context-menu-item context-submenu-new"
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React from 'react';
|
import React, { useMemo, useRef } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { ListPlus, Play, SlidersHorizontal, X } from 'lucide-react';
|
import { ListPlus, Play, SlidersHorizontal, X } from 'lucide-react';
|
||||||
import type { SubsonicSong } from '../../api/subsonicTypes';
|
import type { SubsonicSong } from '../../api/subsonicTypes';
|
||||||
@@ -29,6 +29,7 @@ interface Props {
|
|||||||
currentYear: number;
|
currentYear: number;
|
||||||
inSelectMode: boolean;
|
inSelectMode: boolean;
|
||||||
selectedCount: number;
|
selectedCount: number;
|
||||||
|
selectedIds: ReadonlySet<string>;
|
||||||
showPlPicker: boolean;
|
showPlPicker: boolean;
|
||||||
setShowPlPicker: React.Dispatch<React.SetStateAction<boolean>>;
|
setShowPlPicker: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
}
|
}
|
||||||
@@ -38,10 +39,19 @@ export default function FavoritesSongsSectionHeader({
|
|||||||
selectedGenres, setSelectedGenres, yearRange, setYearRange,
|
selectedGenres, setSelectedGenres, yearRange, setYearRange,
|
||||||
showFilters, setShowFilters, setSortKey, setSortClickCount,
|
showFilters, setShowFilters, setSortKey, setSortClickCount,
|
||||||
playTrack, enqueue, starredOverrides, minYear, currentYear,
|
playTrack, enqueue, starredOverrides, minYear, currentYear,
|
||||||
inSelectMode, selectedCount, showPlPicker, setShowPlPicker,
|
inSelectMode, selectedCount, selectedIds, showPlPicker, setShowPlPicker,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const targetSongs = useMemo(() => {
|
||||||
|
if (!inSelectMode) return visibleSongs;
|
||||||
|
return visibleSongs.filter(s => selectedIds.has(s.id));
|
||||||
|
}, [inSelectMode, visibleSongs, selectedIds]);
|
||||||
|
|
||||||
|
// Snapshot selection when the picker opens so add-to-playlist still sees every
|
||||||
|
// checked row if a document mousedown races ahead of the playlist click.
|
||||||
|
const pickerSongIdsRef = useRef<string[]>([]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem', marginBottom: '0.75rem' }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem', marginBottom: '0.75rem' }}>
|
||||||
{/* Title Row with showing X of Y indicator */}
|
{/* Title Row with showing X of Y indicator */}
|
||||||
@@ -57,30 +67,30 @@ export default function FavoritesSongsSectionHeader({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Action Buttons */}
|
{/* Action Buttons */}
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', flexWrap: 'wrap' }}>
|
<div className="favorites-songs-toolbar" style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', flexWrap: 'wrap' }}>
|
||||||
<button
|
<button
|
||||||
className="btn btn-primary"
|
className="btn btn-primary"
|
||||||
disabled={visibleSongs.length === 0}
|
disabled={targetSongs.length === 0}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (visibleSongs.length === 0) return;
|
if (targetSongs.length === 0) return;
|
||||||
const tracks = visibleSongs.map(songToTrack);
|
const tracks = targetSongs.map(songToTrack);
|
||||||
playTrack(tracks[0], tracks);
|
playTrack(tracks[0], tracks);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Play size={15} />
|
<Play size={15} />
|
||||||
{t('favorites.playAll')}
|
{inSelectMode ? t('favorites.playSelected') : t('favorites.playAll')}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="btn btn-surface"
|
className="btn btn-surface"
|
||||||
disabled={visibleSongs.length === 0}
|
disabled={targetSongs.length === 0}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (visibleSongs.length === 0) return;
|
if (targetSongs.length === 0) return;
|
||||||
const tracks = visibleSongs.map(songToTrack);
|
const tracks = targetSongs.map(songToTrack);
|
||||||
enqueue(tracks);
|
enqueue(tracks);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<ListPlus size={15} />
|
<ListPlus size={15} />
|
||||||
{t('favorites.enqueueAll')}
|
{inSelectMode ? t('favorites.enqueueSelected') : t('favorites.enqueueAll')}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Filter Toggle Button */}
|
{/* Filter Toggle Button */}
|
||||||
@@ -111,21 +121,27 @@ export default function FavoritesSongsSectionHeader({
|
|||||||
{/* Bulk action chips — inline at row end so a selection does not
|
{/* Bulk action chips — inline at row end so a selection does not
|
||||||
push the column header / rows downward (matches Album toolbar). */}
|
push the column header / rows downward (matches Album toolbar). */}
|
||||||
{inSelectMode && (
|
{inSelectMode && (
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', marginLeft: 'auto' }}>
|
<div className="bulk-action-toolbar" style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', marginLeft: 'auto' }}>
|
||||||
<span className="bulk-action-count">
|
<span className="bulk-action-count">
|
||||||
{t('common.bulkSelected', { count: selectedCount })}
|
{t('common.bulkSelected', { count: selectedCount })}
|
||||||
</span>
|
</span>
|
||||||
<div className="bulk-pl-picker-wrap">
|
<div className="bulk-pl-picker-wrap">
|
||||||
<button
|
<button
|
||||||
className="btn btn-surface btn-sm"
|
className="btn btn-surface btn-sm"
|
||||||
onClick={() => setShowPlPicker(v => !v)}
|
onClick={() => {
|
||||||
|
setShowPlPicker(prev => {
|
||||||
|
if (!prev) pickerSongIdsRef.current = [...selectedIds];
|
||||||
|
return !prev;
|
||||||
|
});
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<ListPlus size={14} />
|
<ListPlus size={14} />
|
||||||
{t('common.bulkAddToPlaylist')}
|
{t('common.bulkAddToPlaylist')}
|
||||||
</button>
|
</button>
|
||||||
{showPlPicker && (
|
{showPlPicker && (
|
||||||
<AddToPlaylistSubmenu
|
<AddToPlaylistSubmenu
|
||||||
songIds={[...useSelectionStore.getState().selectedIds]}
|
songIds={pickerSongIdsRef.current}
|
||||||
|
resolveSongIds={() => pickerSongIdsRef.current}
|
||||||
onDone={() => { setShowPlPicker(false); useSelectionStore.getState().clearAll(); }}
|
onDone={() => { setShowPlPicker(false); useSelectionStore.getState().clearAll(); }}
|
||||||
dropDown
|
dropDown
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ export interface FavoritesSelectionResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function useFavoritesSelection(
|
export function useFavoritesSelection(
|
||||||
songs: SubsonicSong[],
|
visibleSongs: SubsonicSong[],
|
||||||
inSelectMode: boolean,
|
inSelectMode: boolean,
|
||||||
tracklistRef: React.RefObject<HTMLDivElement | null>,
|
tracklistRef: React.RefObject<HTMLDivElement | null>,
|
||||||
): FavoritesSelectionResult {
|
): FavoritesSelectionResult {
|
||||||
@@ -17,15 +17,18 @@ export function useFavoritesSelection(
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
useSelectionStore.getState().clearAll();
|
useSelectionStore.getState().clearAll();
|
||||||
lastSelectedIdxRef.current = null;
|
lastSelectedIdxRef.current = null;
|
||||||
}, [songs]);
|
}, [visibleSongs]);
|
||||||
|
|
||||||
// Clear selection on click outside tracklist
|
// Clear selection on click outside tracklist
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!inSelectMode) return;
|
if (!inSelectMode) return;
|
||||||
const handler = (e: MouseEvent) => {
|
const handler = (e: MouseEvent) => {
|
||||||
if (tracklistRef.current && !tracklistRef.current.contains(e.target as Node)) {
|
const target = e.target as HTMLElement;
|
||||||
useSelectionStore.getState().clearAll();
|
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);
|
document.addEventListener('mousedown', handler);
|
||||||
return () => document.removeEventListener('mousedown', handler);
|
return () => document.removeEventListener('mousedown', handler);
|
||||||
@@ -37,10 +40,8 @@ export function useFavoritesSelection(
|
|||||||
if (shift && lastSelectedIdxRef.current !== null) {
|
if (shift && lastSelectedIdxRef.current !== null) {
|
||||||
const from = Math.min(lastSelectedIdxRef.current, idx);
|
const from = Math.min(lastSelectedIdxRef.current, idx);
|
||||||
const to = Math.max(lastSelectedIdxRef.current, idx);
|
const to = Math.max(lastSelectedIdxRef.current, idx);
|
||||||
// we need visibleSongs here — read from latest closure via ref trick
|
|
||||||
// Instead, just toggle range based on idx into songs array
|
|
||||||
for (let j = from; j <= to; j++) {
|
for (let j = from; j <= to; j++) {
|
||||||
const sid = songs[j]?.id;
|
const sid = visibleSongs[j]?.id;
|
||||||
if (sid) next.add(sid);
|
if (sid) next.add(sid);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -49,7 +50,7 @@ export function useFavoritesSelection(
|
|||||||
}
|
}
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
}, [songs]);
|
}, [visibleSongs]);
|
||||||
|
|
||||||
return { toggleSelect };
|
return { toggleSelect };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,9 @@ export const favorites = {
|
|||||||
albums: 'Alben',
|
albums: 'Alben',
|
||||||
songs: 'Songs',
|
songs: 'Songs',
|
||||||
enqueueAll: 'Alle in die Warteschlange',
|
enqueueAll: 'Alle in die Warteschlange',
|
||||||
|
enqueueSelected: 'Auswahl in die Warteschlange',
|
||||||
playAll: 'Alle abspielen',
|
playAll: 'Alle abspielen',
|
||||||
|
playSelected: 'Auswahl abspielen',
|
||||||
removeSong: 'Aus Favoriten entfernen',
|
removeSong: 'Aus Favoriten entfernen',
|
||||||
stations: 'Radiosender',
|
stations: 'Radiosender',
|
||||||
showingFiltered: 'Zeige {{filtered}} von {{total}} ({{artist}})',
|
showingFiltered: 'Zeige {{filtered}} von {{total}} ({{artist}})',
|
||||||
|
|||||||
@@ -5,7 +5,9 @@ export const favorites = {
|
|||||||
albums: 'Albums',
|
albums: 'Albums',
|
||||||
songs: 'Songs',
|
songs: 'Songs',
|
||||||
enqueueAll: 'Add all to queue',
|
enqueueAll: 'Add all to queue',
|
||||||
|
enqueueSelected: 'Add selected to queue',
|
||||||
playAll: 'Play all',
|
playAll: 'Play all',
|
||||||
|
playSelected: 'Play selected',
|
||||||
removeSong: 'Remove from favorites',
|
removeSong: 'Remove from favorites',
|
||||||
stations: 'Radio Stations',
|
stations: 'Radio Stations',
|
||||||
showingFiltered: 'Showing {{filtered}} of {{total}} ({{artist}})',
|
showingFiltered: 'Showing {{filtered}} of {{total}} ({{artist}})',
|
||||||
|
|||||||
@@ -5,7 +5,9 @@ export const favorites = {
|
|||||||
albums: 'Álbumes',
|
albums: 'Álbumes',
|
||||||
songs: 'Canciones',
|
songs: 'Canciones',
|
||||||
enqueueAll: 'Agregar todo a la cola',
|
enqueueAll: 'Agregar todo a la cola',
|
||||||
|
enqueueSelected: 'Selección a la cola',
|
||||||
playAll: 'Reproducir todo',
|
playAll: 'Reproducir todo',
|
||||||
|
playSelected: 'Reproducir selección',
|
||||||
removeSong: 'Quitar de favoritos',
|
removeSong: 'Quitar de favoritos',
|
||||||
stations: 'Estaciones de Radio',
|
stations: 'Estaciones de Radio',
|
||||||
showingFiltered: 'Mostrando {{filtered}} de {{total}} ({{artist}})',
|
showingFiltered: 'Mostrando {{filtered}} de {{total}} ({{artist}})',
|
||||||
|
|||||||
@@ -5,7 +5,9 @@ export const favorites = {
|
|||||||
albums: 'Albums',
|
albums: 'Albums',
|
||||||
songs: 'Morceaux',
|
songs: 'Morceaux',
|
||||||
enqueueAll: 'Tout ajouter à la file',
|
enqueueAll: 'Tout ajouter à la file',
|
||||||
|
enqueueSelected: 'Sélection dans la file',
|
||||||
playAll: 'Tout lire',
|
playAll: 'Tout lire',
|
||||||
|
playSelected: 'Lire la sélection',
|
||||||
removeSong: 'Retirer des favoris',
|
removeSong: 'Retirer des favoris',
|
||||||
stations: 'Stations de radio',
|
stations: 'Stations de radio',
|
||||||
showingFiltered: 'Affichage de {{filtered}} sur {{total}} ({{artist}})',
|
showingFiltered: 'Affichage de {{filtered}} sur {{total}} ({{artist}})',
|
||||||
|
|||||||
@@ -5,7 +5,9 @@ export const favorites = {
|
|||||||
albums: 'アルバム',
|
albums: 'アルバム',
|
||||||
songs: '曲',
|
songs: '曲',
|
||||||
enqueueAll: 'すべてキューに追加',
|
enqueueAll: 'すべてキューに追加',
|
||||||
|
enqueueSelected: '選択をキューに追加',
|
||||||
playAll: 'すべて再生',
|
playAll: 'すべて再生',
|
||||||
|
playSelected: '選択を再生',
|
||||||
removeSong: 'お気に入りから削除',
|
removeSong: 'お気に入りから削除',
|
||||||
stations: 'ラジオ局',
|
stations: 'ラジオ局',
|
||||||
showingFiltered: '{{total}} 件中 {{filtered}} 件を表示 ({{artist}})',
|
showingFiltered: '{{total}} 件中 {{filtered}} 件を表示 ({{artist}})',
|
||||||
|
|||||||
@@ -5,7 +5,9 @@ export const favorites = {
|
|||||||
albums: 'Album',
|
albums: 'Album',
|
||||||
songs: 'Sanger',
|
songs: 'Sanger',
|
||||||
enqueueAll: 'Legg alle i kø',
|
enqueueAll: 'Legg alle i kø',
|
||||||
|
enqueueSelected: 'Valgte i kø',
|
||||||
playAll: 'Spill alle',
|
playAll: 'Spill alle',
|
||||||
|
playSelected: 'Spill valgte',
|
||||||
removeSong: 'Fjern fra favoritter',
|
removeSong: 'Fjern fra favoritter',
|
||||||
stations: 'Radiostasjoner',
|
stations: 'Radiostasjoner',
|
||||||
showingFiltered: 'Viser {{filtered}} av {{total}} ({{artist}})',
|
showingFiltered: 'Viser {{filtered}} av {{total}} ({{artist}})',
|
||||||
|
|||||||
@@ -5,7 +5,9 @@ export const favorites = {
|
|||||||
albums: 'Albums',
|
albums: 'Albums',
|
||||||
songs: 'Nummers',
|
songs: 'Nummers',
|
||||||
enqueueAll: 'Alles aan wachtrij toevoegen',
|
enqueueAll: 'Alles aan wachtrij toevoegen',
|
||||||
|
enqueueSelected: 'Selectie aan wachtrij',
|
||||||
playAll: 'Alles afspelen',
|
playAll: 'Alles afspelen',
|
||||||
|
playSelected: 'Selectie afspelen',
|
||||||
removeSong: 'Verwijderen uit favorieten',
|
removeSong: 'Verwijderen uit favorieten',
|
||||||
stations: 'Radiostations',
|
stations: 'Radiostations',
|
||||||
showingFiltered: 'Toont {{filtered}} van {{total}} ({{artist}})',
|
showingFiltered: 'Toont {{filtered}} van {{total}} ({{artist}})',
|
||||||
|
|||||||
@@ -5,7 +5,9 @@ export const favorites = {
|
|||||||
albums: 'Albume',
|
albums: 'Albume',
|
||||||
songs: 'Piese',
|
songs: 'Piese',
|
||||||
enqueueAll: 'Adaugă tot la coadă',
|
enqueueAll: 'Adaugă tot la coadă',
|
||||||
|
enqueueSelected: 'Selecția la coadă',
|
||||||
playAll: 'Redă tot',
|
playAll: 'Redă tot',
|
||||||
|
playSelected: 'Redă selecția',
|
||||||
removeSong: 'Șterge de la favorite',
|
removeSong: 'Șterge de la favorite',
|
||||||
stations: 'Stații Radio',
|
stations: 'Stații Radio',
|
||||||
showingFiltered: 'Se afișează {{filtered}} din {{total}} ({{artist}})',
|
showingFiltered: 'Se afișează {{filtered}} din {{total}} ({{artist}})',
|
||||||
|
|||||||
@@ -5,7 +5,9 @@ export const favorites = {
|
|||||||
albums: 'Альбомы',
|
albums: 'Альбомы',
|
||||||
songs: 'Треки',
|
songs: 'Треки',
|
||||||
enqueueAll: 'Всё в очередь',
|
enqueueAll: 'Всё в очередь',
|
||||||
|
enqueueSelected: 'Выбранное в очередь',
|
||||||
playAll: 'Воспроизвести всё',
|
playAll: 'Воспроизвести всё',
|
||||||
|
playSelected: 'Воспроизвести выбранное',
|
||||||
removeSong: 'Убрать из избранного',
|
removeSong: 'Убрать из избранного',
|
||||||
stations: 'Радиостанции',
|
stations: 'Радиостанции',
|
||||||
showingFiltered: 'Показано {{filtered}} из {{total}} ({{artist}})',
|
showingFiltered: 'Показано {{filtered}} из {{total}} ({{artist}})',
|
||||||
|
|||||||
@@ -5,7 +5,9 @@ export const favorites = {
|
|||||||
albums: '专辑',
|
albums: '专辑',
|
||||||
songs: '歌曲',
|
songs: '歌曲',
|
||||||
enqueueAll: '全部加入队列',
|
enqueueAll: '全部加入队列',
|
||||||
|
enqueueSelected: '所选加入队列',
|
||||||
playAll: '全部播放',
|
playAll: '全部播放',
|
||||||
|
playSelected: '播放所选',
|
||||||
removeSong: '从收藏中移除',
|
removeSong: '从收藏中移除',
|
||||||
stations: '广播电台',
|
stations: '广播电台',
|
||||||
showingFiltered: '显示 {{filtered}} / {{total}} ({{artist}})',
|
showingFiltered: '显示 {{filtered}} / {{total}} ({{artist}})',
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import FavoritesSongsTracklist from '../components/favorites/FavoritesSongsTrack
|
|||||||
import { useFavoritesData } from '../hooks/useFavoritesData';
|
import { useFavoritesData } from '../hooks/useFavoritesData';
|
||||||
import { useFavoritesSongFiltering } from '../hooks/useFavoritesSongFiltering';
|
import { useFavoritesSongFiltering } from '../hooks/useFavoritesSongFiltering';
|
||||||
import { useFavoritesSelection } from '../hooks/useFavoritesSelection';
|
import { useFavoritesSelection } from '../hooks/useFavoritesSelection';
|
||||||
|
import { useBulkPlPickerOutsideClick } from '../hooks/useBulkPlPickerOutsideClick';
|
||||||
import AlbumRow from '../components/AlbumRow';
|
import AlbumRow from '../components/AlbumRow';
|
||||||
import ArtistRow from '../components/ArtistRow';
|
import ArtistRow from '../components/ArtistRow';
|
||||||
import CachedImage from '../components/CachedImage';
|
import CachedImage from '../components/CachedImage';
|
||||||
@@ -120,7 +121,13 @@ export default function Favorites() {
|
|||||||
[selectedArtist, topFavoriteArtists],
|
[selectedArtist, topFavoriteArtists],
|
||||||
);
|
);
|
||||||
|
|
||||||
const { toggleSelect } = useFavoritesSelection(songs, inSelectMode, tracklistRef);
|
const { toggleSelect } = useFavoritesSelection(visibleSongs, inSelectMode, tracklistRef);
|
||||||
|
|
||||||
|
useBulkPlPickerOutsideClick(showPlPicker, setShowPlPicker);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!inSelectMode) setShowPlPicker(false);
|
||||||
|
}, [inSelectMode]);
|
||||||
|
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
@@ -198,6 +205,7 @@ export default function Favorites() {
|
|||||||
currentYear={CURRENT_YEAR}
|
currentYear={CURRENT_YEAR}
|
||||||
inSelectMode={inSelectMode}
|
inSelectMode={inSelectMode}
|
||||||
selectedCount={selectedCount}
|
selectedCount={selectedCount}
|
||||||
|
selectedIds={selectedIds}
|
||||||
showPlPicker={showPlPicker}
|
showPlPicker={showPlPicker}
|
||||||
setShowPlPicker={setShowPlPicker}
|
setShowPlPicker={setShowPlPicker}
|
||||||
/>
|
/>
|
||||||
|
|||||||
Reference in New Issue
Block a user