Highlight the playlist in the left panel on play (#2025)

* Fixed bad smart playlist field s

* first try to add playlist highlight

* Simplified calls

* Now works for grids too.

* Derive the playlist highlight from the currently-playing track's origin instead of a stale global field.

* addressed comments
This commit is contained in:
Norman
2026-06-23 20:18:02 -07:00
committed by GitHub
parent b6519e9839
commit 5ddbfcbfee
6 changed files with 89 additions and 10 deletions
@@ -64,6 +64,7 @@ export const useItemDragDropState = <TElement extends HTMLElement = HTMLDivEleme
return draggedItems; return draggedItems;
}, },
itemType, itemType,
metadata: { playlistId },
onDragStart: () => { onDragStart: () => {
if (!item || !isDataRow) { if (!item || !isDataRow) {
return; return;
@@ -248,10 +249,15 @@ export const useItemDragDropState = <TElement extends HTMLElement = HTMLDivEleme
case DragTarget.SONG: { case DragTarget.SONG: {
const sourceItems = (args.source.item || []) as Song[]; const sourceItems = (args.source.item || []) as Song[];
if (sourceItems.length > 0) { if (sourceItems.length > 0) {
playerContext.addToQueueByData(sourceItems, { const sourcePlaylistId = args.source.metadata?.playlistId as
edge: args.edge, | string
uniqueId: droppedOnUniqueId, | undefined;
}); playerContext.addToQueueByData(
sourceItems,
{ edge: args.edge, uniqueId: droppedOnUniqueId },
undefined,
sourcePlaylistId ?? null,
);
} }
break; break;
} }
@@ -39,7 +39,12 @@ import {
import { Play, PlayerRepeat, PlayerShuffle } from '/@/shared/types/types'; import { Play, PlayerRepeat, PlayerShuffle } from '/@/shared/types/types';
export interface PlayerContext { export interface PlayerContext {
addToQueueByData: (data: Song[], type: AddToQueueType, playSongId?: string) => void; addToQueueByData: (
data: Song[],
type: AddToQueueType,
playSongId?: string,
contextPlaylistId?: null | string,
) => void;
addToQueueByFetch: ( addToQueueByFetch: (
serverId: string, serverId: string,
id: string[], id: string[],
@@ -137,6 +142,23 @@ const getRootQueryKey = (itemType: LibraryItem, serverId: string) => {
} }
}; };
const isReplaceQueueType = (type: AddToQueueType): boolean => {
if (typeof type === 'object') return false;
return type === Play.NOW || type === Play.SHUFFLE;
};
// HashRouter puts the route in location.hash, not pathname.
const inferPlaylistContextFromUrl = (): null | string => {
const route = window.location.hash.replace(/^#/, '');
const match = route.match(/^\/playlists\/([^/]+)/);
return match ? match[1] : null;
};
// Stamps each song with the playlist it was queued from, so the sidebar highlight
// can be derived from whichever song is currently playing (see useCurrentPlaylistContextId).
const tagPlaylistContext = (songs: Song[], contextPlaylistId: string): Song[] =>
songs.map((song) => ({ ...song, _contextPlaylistId: contextPlaylistId }));
export const PlayerProvider = ({ children }: { children: React.ReactNode }) => { export const PlayerProvider = ({ children }: { children: React.ReactNode }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@@ -187,9 +209,20 @@ export const PlayerProvider = ({ children }: { children: React.ReactNode }) => {
}, [doNotShowAgain, setDoNotShowAgain, t]); }, [doNotShowAgain, setDoNotShowAgain, t]);
const addToQueueByData = useCallback( const addToQueueByData = useCallback(
(data: Song[], type: AddToQueueType, playSongId?: string) => { (
data: Song[],
type: AddToQueueType,
playSongId?: string,
contextPlaylistId?: null | string,
) => {
const filters = useSettingsStore.getState().playback.filters; const filters = useSettingsStore.getState().playback.filters;
const filteredData = filterSongsByPlayerFilters(data, filters); let filteredData = filterSongsByPlayerFilters(data, filters);
const resolvedContextId =
contextPlaylistId ??
(isReplaceQueueType(type) ? inferPlaylistContextFromUrl() : null);
if (resolvedContextId) {
filteredData = tagPlaylistContext(filteredData, resolvedContextId);
}
if (typeof type === 'object' && 'edge' in type && type.edge !== null) { if (typeof type === 'object' && 'edge' in type && type.edge !== null) {
const edge = type.edge === 'top' ? 'top' : 'bottom'; const edge = type.edge === 'top' ? 'top' : 'bottom';
@@ -279,7 +312,21 @@ export const PlayerProvider = ({ children }: { children: React.ReactNode }) => {
} }
const filters = useSettingsStore.getState().playback.filters; const filters = useSettingsStore.getState().playback.filters;
const filteredSongs = filterSongsByPlayerFilters(sortedSongs, filters); let filteredSongs = filterSongsByPlayerFilters(sortedSongs, filters);
// Songs from multiple playlists are merged together, so there is no single
// playlist to attribute them to: skip tagging (and URL inference) entirely.
const isMultiPlaylist = itemType === LibraryItem.PLAYLIST && id.length > 1;
const explicitId =
itemType === LibraryItem.PLAYLIST && id.length === 1 ? id[0] : null;
const resolvedContextId =
explicitId ??
(!isMultiPlaylist && isReplaceQueueType(type)
? inferPlaylistContextFromUrl()
: null);
if (resolvedContextId) {
filteredSongs = tagPlaylistContext(filteredSongs, resolvedContextId);
}
if (typeof type === 'object' && 'edge' in type && type.edge !== null) { if (typeof type === 'object' && 'edge' in type && type.edge !== null) {
const edge = type.edge === 'top' ? 'top' : 'bottom'; const edge = type.edge === 'top' ? 'top' : 'bottom';
@@ -136,6 +136,10 @@
white-space: nowrap; white-space: nowrap;
} }
.name-active {
color: var(--theme-colors-primary);
}
.image-container { .image-container {
flex-shrink: 0; flex-shrink: 0;
width: 3rem; width: 3rem;
@@ -28,6 +28,7 @@ import { useDragDrop } from '/@/renderer/hooks/use-drag-drop';
import { useDragMonitor } from '/@/renderer/hooks/use-drag-monitor'; import { useDragMonitor } from '/@/renderer/hooks/use-drag-monitor';
import { AppRoute } from '/@/renderer/router/routes'; import { AppRoute } from '/@/renderer/router/routes';
import { import {
useCurrentPlaylistContextId,
useCurrentServer, useCurrentServer,
useCurrentServerId, useCurrentServerId,
usePermissions, usePermissions,
@@ -116,6 +117,8 @@ export const PlaylistRowButton = memo(
const sidebarPlaylistSorting = useSidebarPlaylistSorting(); const sidebarPlaylistSorting = useSidebarPlaylistSorting();
const sidebarPlaylistMode = useSidebarPlaylistMode(); const sidebarPlaylistMode = useSidebarPlaylistMode();
const isCompact = sidebarPlaylistMode === 'compact'; const isCompact = sidebarPlaylistMode === 'compact';
const activePlaylistId = useCurrentPlaylistContextId();
const isActive = activePlaylistId === item.id;
const [isHovered, setIsHovered] = useState(false); const [isHovered, setIsHovered] = useState(false);
const isSmartPlaylist = Boolean(item.rules); const isSmartPlaylist = Boolean(item.rules);
@@ -292,7 +295,13 @@ export const PlaylistRowButton = memo(
> >
{isCompact ? ( {isCompact ? (
<> <>
<Text className={styles.compactName} fw={500} size="md"> <Text
className={clsx(styles.compactName, {
[styles.nameActive]: isActive,
})}
fw={500}
size="md"
>
{name} {name}
</Text> </Text>
{isHovered && ( {isHovered && (
@@ -307,7 +316,13 @@ export const PlaylistRowButton = memo(
<div className={styles.rowGroup}> <div className={styles.rowGroup}>
<Image containerClassName={styles.imageContainer} src={imageUrl} /> <Image containerClassName={styles.imageContainer} src={imageUrl} />
<div className={styles.metadata}> <div className={styles.metadata}>
<Text className={styles.name} fw={500} size="md"> <Text
className={clsx(styles.name, {
[styles.nameActive]: isActive,
})}
fw={500}
size="md"
>
{name} {name}
</Text> </Text>
<div className={styles.metadataGroup}> <div className={styles.metadataGroup}>
+6
View File
@@ -1640,6 +1640,7 @@ export const usePlayerStoreBase = createWithEqualityFn<PlayerState>()(
const excludedPlayerKeys = ['playerNum', 'seekToTimestamp', 'status']; const excludedPlayerKeys = ['playerNum', 'seekToTimestamp', 'status'];
// If we're not restoring the play queue, we don't need the index property // If we're not restoring the play queue, we don't need the index property
// (it is meaningless without the queue)
if (!shouldRestorePlayQueue) { if (!shouldRestorePlayQueue) {
excludedPlayerKeys.push('index'); excludedPlayerKeys.push('index');
} }
@@ -2076,6 +2077,7 @@ export const updateQueueSong = (songId: string, updatedSong: Song) => {
const uniqueId = song._uniqueId; const uniqueId = song._uniqueId;
state.queue.songs[song._uniqueId] = { state.queue.songs[song._uniqueId] = {
...updatedSong, ...updatedSong,
_contextPlaylistId: song._contextPlaylistId,
_uniqueId: uniqueId, _uniqueId: uniqueId,
}; };
} }
@@ -2083,6 +2085,10 @@ export const updateQueueSong = (songId: string, updatedSong: Song) => {
}); });
}; };
export const useCurrentPlaylistContextId = () => {
return usePlayerStoreBase((state) => state.getCurrentSong()?._contextPlaylistId ?? null);
};
export const usePlayerMuted = () => { export const usePlayerMuted = () => {
return usePlayerStoreBase((state) => state.player.muted); return usePlayerStoreBase((state) => state.player.muted);
}; };
+1
View File
@@ -73,6 +73,7 @@ export interface QueueData {
} }
export type QueueSong = Song & { export type QueueSong = Song & {
_contextPlaylistId?: null | string;
_uniqueId: string; _uniqueId: string;
}; };