diff --git a/src-tauri/src/commands/playlist.rs b/src-tauri/src/commands/playlist.rs index a4773e8..04171e5 100644 --- a/src-tauri/src/commands/playlist.rs +++ b/src-tauri/src/commands/playlist.rs @@ -416,6 +416,31 @@ pub async fn list_playlist_tracks( Ok(tracks) } +/// Return the IDs of every user playlist that currently contains `track_id`. +/// Smart playlists are excluded — their membership is computed on the fly +/// from rules and would be misleading to expose as a toggle target. +/// +/// Used by the `+` popover to render a checkmark on rows the track is +/// already in (and to flip the click handler from "add" to "remove"). +#[tauri::command] +pub async fn list_playlists_containing_track( + state: tauri::State<'_, AppState>, + track_id: i64, +) -> AppResult> { + let pool = state.require_profile_pool().await?; + let rows: Vec<(i64,)> = sqlx::query_as( + "SELECT pt.playlist_id + FROM playlist_track pt + JOIN playlist p ON p.id = pt.playlist_id + WHERE pt.track_id = ? + AND p.is_smart = 0", + ) + .bind(track_id) + .fetch_all(&pool) + .await?; + Ok(rows.into_iter().map(|(id,)| id).collect()) +} + /// Append a single track to the end of a playlist. Idempotent — if the track /// is already in the playlist the existing row is preserved and `updated_at` /// is still bumped so the UI reflects the user's intent. diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 69919d2..9579122 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -468,6 +468,7 @@ pub fn run() { commands::playlist::update_playlist, commands::playlist::delete_playlist, commands::playlist::list_playlist_tracks, + commands::playlist::list_playlists_containing_track, commands::playlist::add_track_to_playlist, commands::playlist::add_tracks_to_playlist, commands::playlist::remove_track_from_playlist, diff --git a/src/components/views/LibraryView.tsx b/src/components/views/LibraryView.tsx index 1ab63f0..33f3357 100644 --- a/src/components/views/LibraryView.tsx +++ b/src/components/views/LibraryView.tsx @@ -55,7 +55,10 @@ import { resolvePlaylistColor } from "../../lib/playlistVisuals"; import { resolveArtwork } from "../../lib/tauri/artwork"; import { FadeInImage } from "../common/FadeInImage"; import { PlaylistIcon } from "../../lib/PlaylistIcon"; -import type { Playlist } from "../../lib/tauri/playlist"; +import { + listPlaylistsContainingTrack, + type Playlist, +} from "../../lib/tauri/playlist"; import { pickFolder } from "../../lib/tauri/dialog"; import { removeFolderFromLibrary, @@ -137,6 +140,7 @@ export function LibraryView({ const { playlists, addTracksToPlaylist, + removeTrackFromPlaylist, addSourceToPlaylist, createPlaylist, } = usePlaylist(); @@ -541,6 +545,16 @@ export function LibraryView({ console.error("[LibraryView] add to playlist failed", err); } }} + onRemoveFromPlaylist={async (playlistId, trackId) => { + try { + await removeTrackFromPlaylist(playlistId, trackId); + } catch (err) { + console.error( + "[LibraryView] remove from playlist failed", + err, + ); + } + }} onCreatePlaylist={(trackId) => { setPendingSourceForCreate({ kind: "tracks", ids: [trackId] }); setIsCreatePlaylistModalOpen(true); @@ -945,7 +959,11 @@ interface TrackTableProps { likedIds: Set; onToggleLike: (trackId: number) => void; playlists: Playlist[]; - onAddToPlaylist: (playlistId: number, trackId: number) => void; + onAddToPlaylist: (playlistId: number, trackId: number) => Promise | void; + onRemoveFromPlaylist: ( + playlistId: number, + trackId: number, + ) => Promise | void; onCreatePlaylist: (trackId: number) => void; onNavigateToAlbum: (albumId: number) => void; onNavigateToArtist: (artistId: number) => void; @@ -966,6 +984,7 @@ function TrackTable({ onToggleLike, playlists, onAddToPlaylist, + onRemoveFromPlaylist, onCreatePlaylist, onNavigateToAlbum, onNavigateToArtist, @@ -976,6 +995,13 @@ function TrackTable({ "use no memo"; const unknown = t("library.table.unknown"); const [openMenuTrackId, setOpenMenuTrackId] = useState(null); + // Per-track playlist membership snapshot, fetched the first time the + // user opens the `+` popover for a given track. Entry stays cached for + // the lifetime of the table so reopening the menu is instant. Optimistic + // updates flip the set on toggle. + const [trackMembership, setTrackMembership] = useState< + Map> + >(new Map()); const [ratingOverrides, setRatingOverrides] = useState< Map >(new Map()); @@ -1208,7 +1234,27 @@ function TrackTable({ data-add-to-playlist-trigger onClick={(e) => { e.stopPropagation(); - setOpenMenuTrackId(isMenuOpen ? null : track.id); + const opening = !isMenuOpen; + setOpenMenuTrackId(opening ? track.id : null); + // Lazy-fetch membership the first time this track's + // popover is opened. Subsequent opens reuse the cached + // set (kept in sync via optimistic updates on toggle). + if (opening && !trackMembership.has(track.id)) { + listPlaylistsContainingTrack(track.id) + .then((ids) => { + setTrackMembership((prev) => { + const next = new Map(prev); + next.set(track.id, new Set(ids)); + return next; + }); + }) + .catch((err) => { + console.error( + "[LibraryView] load membership failed", + err, + ); + }); + } }} aria-label={t("trackActions.addToPlaylist")} aria-haspopup="menu" @@ -1225,8 +1271,28 @@ function TrackTable({ { - onAddToPlaylist(playlistId, track.id); + const members = trackMembership.get(track.id); + const isMember = members?.has(playlistId) ?? false; + // Optimistic membership flip — the underlying mutations + // are idempotent on the backend, so a failed RPC just + // means the visual state will drift until the next + // popover open, which is the worst-case loss for a + // single click. + setTrackMembership((prev) => { + const next = new Map(prev); + const set = new Set(next.get(track.id) ?? []); + if (isMember) set.delete(playlistId); + else set.add(playlistId); + next.set(track.id, set); + return next; + }); + if (isMember) { + void onRemoveFromPlaylist(playlistId, track.id); + } else { + void onAddToPlaylist(playlistId, track.id); + } setOpenMenuTrackId(null); }} onCreate={() => { @@ -1251,6 +1317,14 @@ interface AddToPlaylistPopoverProps { onPick: (playlistId: number) => void; onCreate: () => void; t: Translator; + /** + * Optional set of playlist IDs the target is already in. Only meaningful + * for the track popover — when provided, matching rows render a green + * checkmark and the caller is expected to toggle (remove) rather than + * add on click. Albums/artists/folders skip this prop because their + * "+ to playlist" action is a bulk add with no symmetric remove. + */ + memberPlaylistIds?: ReadonlySet; } /** @@ -1266,6 +1340,7 @@ function AddToPlaylistPopover({ onPick, onCreate, t, + memberPlaylistIds, }: AddToPlaylistPopoverProps) { return (
{ const color = resolvePlaylistColor(pl.color_id); + const isMember = memberPlaylistIds?.has(pl.id) ?? false; return (
- + {pl.name} + {isMember && ( +