diff --git a/packages/web/src/components/collection/desktop/edit-mode/PlaylistEditModeContext.tsx b/packages/web/src/components/collection/desktop/edit-mode/PlaylistEditModeContext.tsx index 1c4608fa2e4..eb8faf9032c 100644 --- a/packages/web/src/components/collection/desktop/edit-mode/PlaylistEditModeContext.tsx +++ b/packages/web/src/components/collection/desktop/edit-mode/PlaylistEditModeContext.tsx @@ -4,6 +4,7 @@ import { useCallback, useContext, useMemo, + useRef, useState } from 'react' @@ -40,13 +41,19 @@ type PlaylistEditModeContextValue = { isEditMode: boolean status: Status draft: PlaylistMetadataDraft + removedTrackIds: Set hasChanges: boolean + canUndoRemoval: boolean + canRedoRemoval: boolean enterEditMode: () => void exitEditMode: () => void setField: ( field: K, value: PlaylistMetadataDraft[K] ) => void + stageRemoval: (ids: ID[]) => void + undoRemoval: () => void + redoRemoval: () => void apply: () => void discard: () => void resolveConflict: () => void @@ -61,14 +68,21 @@ const compareValues = (a: unknown, b: unknown) => const messages = { saved: ({ savedDetails, - savedArtwork + savedArtwork, + savedTracks }: { savedDetails: boolean savedArtwork: boolean + savedTracks: boolean }) => { - if (savedDetails && savedArtwork) return 'Saved details and artwork' - if (savedArtwork) return 'Saved artwork' - return 'Saved details' + const parts: string[] = [] + if (savedDetails) parts.push('details') + if (savedArtwork) parts.push('artwork') + if (savedTracks) parts.push('tracks') + if (parts.length === 0) return 'Saved changes' + if (parts.length === 1) return `Saved ${parts[0]}` + const last = parts.pop() + return `Saved ${parts.join(', ')} and ${last}` }, conflict: 'Heads up — someone else changed this playlist while you were editing. Reload and try again.', @@ -92,22 +106,40 @@ export const PlaylistEditModeProvider = ({ const [isEditMode, setIsEditMode] = useState(false) const [draft, setDraft] = useState({}) + const [removedTrackIds, setRemovedTrackIds] = useState>(new Set()) const [status, setStatus] = useState('idle') const [editModeLoadedAt, setEditModeLoadedAt] = useState(null) + // Undo/redo stacks for staged track removals. Each entry is one user action + // (a batch of track ids removed together). These live alongside the draft so + // they reset atomically with it on enter/exit/discard/apply. + const undoStackRef = useRef([]) + const redoStackRef = useRef([]) + const [, setHistoryTick] = useState(0) + const bumpHistory = useCallback(() => setHistoryTick((t) => t + 1), []) + + const resetRemovals = useCallback(() => { + setRemovedTrackIds(new Set()) + undoStackRef.current = [] + redoStackRef.current = [] + bumpHistory() + }, [bumpHistory]) + const enterEditMode = useCallback(() => { setIsEditMode(true) setDraft({}) + resetRemovals() setStatus('idle') setEditModeLoadedAt(Date.now()) - }, []) + }, [resetRemovals]) const exitEditMode = useCallback(() => { setIsEditMode(false) setDraft({}) + resetRemovals() setStatus('idle') setEditModeLoadedAt(null) - }, []) + }, [resetRemovals]) const setField = useCallback( (field, value) => { @@ -116,19 +148,61 @@ export const PlaylistEditModeProvider = ({ [] ) + const stageRemoval = useCallback( + (ids: ID[]) => { + if (ids.length === 0) return + setRemovedTrackIds((prev) => { + const next = new Set(prev) + ids.forEach((id) => next.add(id)) + return next + }) + undoStackRef.current.push(ids) + redoStackRef.current = [] + bumpHistory() + }, + [bumpHistory] + ) + + const undoRemoval = useCallback(() => { + const ids = undoStackRef.current.pop() + if (!ids) return + redoStackRef.current.push(ids) + setRemovedTrackIds((prev) => { + const next = new Set(prev) + ids.forEach((id) => next.delete(id)) + return next + }) + bumpHistory() + }, [bumpHistory]) + + const redoRemoval = useCallback(() => { + const ids = redoStackRef.current.pop() + if (!ids) return + undoStackRef.current.push(ids) + setRemovedTrackIds((prev) => { + const next = new Set(prev) + ids.forEach((id) => next.add(id)) + return next + }) + bumpHistory() + }, [bumpHistory]) + const discard = useCallback(() => { setDraft({}) - }, []) + resetRemovals() + }, [resetRemovals]) const resolveConflict = useCallback(() => { setStatus('idle') setDraft({}) + resetRemovals() setIsEditMode(false) setEditModeLoadedAt(null) - }, []) + }, [resetRemovals]) const hasChanges = useMemo(() => { if (!collection) return false + if (removedTrackIds.size > 0) return true const fields = ['playlist_name', 'description', 'is_private'] as const for (const f of fields) { if ( @@ -140,7 +214,7 @@ export const PlaylistEditModeProvider = ({ } if (draft.artwork !== undefined && draft.artwork !== null) return true return false - }, [collection, draft]) + }, [collection, draft, removedTrackIds]) const apply = useCallback(() => { if (!collection || !collection.playlist_id) return @@ -165,10 +239,19 @@ export const PlaylistEditModeProvider = ({ setStatus('saving') + const playlistContents = { + ...collection.playlist_contents, + track_ids: collection.playlist_contents.track_ids.filter( + (t) => !removedTrackIds.has(t.track) + ) + } + const merged: EditCollectionValues = { ...(collection as unknown as EditCollectionValues), - playlist_contents: collection.playlist_contents, - tracks: tracks ?? [], + playlist_contents: playlistContents, + tracks: (tracks ?? []).filter( + (t) => t.track_id == null || !removedTrackIds.has(t.track_id) + ), playlist_name: draft.playlist_name ?? collection.playlist_name, description: draft.description !== undefined @@ -186,16 +269,22 @@ export const PlaylistEditModeProvider = ({ draft.description !== undefined || draft.is_private !== undefined const savedArtwork = draft.artwork != null + const savedTracks = removedTrackIds.size > 0 dispatch( editPlaylist(collection.playlist_id, merged, (success) => { if (success) { dispatch( toast({ - content: messages.saved({ savedDetails, savedArtwork }) + content: messages.saved({ + savedDetails, + savedArtwork, + savedTracks + }) }) ) setDraft({}) + resetRemovals() setIsEditMode(false) setStatus('idle') setEditModeLoadedAt(null) @@ -212,9 +301,14 @@ export const PlaylistEditModeProvider = ({ editModeLoadedAt, exitEditMode, hasChanges, + removedTrackIds, + resetRemovals, tracks ]) + const canUndoRemoval = undoStackRef.current.length > 0 + const canRedoRemoval = redoStackRef.current.length > 0 + const value = useMemo( () => ({ collectionId, @@ -222,16 +316,24 @@ export const PlaylistEditModeProvider = ({ isEditMode, status, draft, + removedTrackIds, hasChanges, + canUndoRemoval, + canRedoRemoval, enterEditMode, exitEditMode, setField, + stageRemoval, + undoRemoval, + redoRemoval, apply, discard, resolveConflict }), [ apply, + canRedoRemoval, + canUndoRemoval, collectionId, discard, draft, @@ -240,9 +342,13 @@ export const PlaylistEditModeProvider = ({ hasChanges, isEditMode, isOwner, + redoRemoval, + removedTrackIds, resolveConflict, setField, - status + stageRemoval, + status, + undoRemoval ] ) @@ -265,10 +371,16 @@ export const usePlaylistEditMode = (): PlaylistEditModeContextValue => { isEditMode: false, status: 'idle', draft: {}, + removedTrackIds: new Set(), hasChanges: false, + canUndoRemoval: false, + canRedoRemoval: false, enterEditMode: () => {}, exitEditMode: () => {}, setField: () => {}, + stageRemoval: () => {}, + undoRemoval: () => {}, + redoRemoval: () => {}, apply: () => {}, discard: () => {}, resolveConflict: () => {} diff --git a/packages/web/src/components/collection/desktop/edit-mode/tracks/EditAwareTracksTable.tsx b/packages/web/src/components/collection/desktop/edit-mode/tracks/EditAwareTracksTable.tsx index dea1ced9f98..1cd51ee0f7f 100644 --- a/packages/web/src/components/collection/desktop/edit-mode/tracks/EditAwareTracksTable.tsx +++ b/packages/web/src/components/collection/desktop/edit-mode/tracks/EditAwareTracksTable.tsx @@ -36,14 +36,25 @@ export const EditAwareTracksTable = (props: EditAwareTracksTableProps) => { const isEditingThis = editMode.isEditMode && editMode.collectionId === collectionId + // While editing, tracks staged for removal disappear from the table + // immediately; they are only persisted (or restored) when Apply/Discard runs. + const visibleData = useMemo(() => { + if (!isEditingThis || editMode.removedTrackIds.size === 0) return data + return data.filter( + (t) => + typeof t.track_id !== 'number' || + !editMode.removedTrackIds.has(t.track_id) + ) + }, [data, editMode.removedTrackIds, isEditingThis]) + const selectableTrackIds = useMemo(() => { if (!isEditingThis) return [] as ID[] const ids: ID[] = [] - for (const t of data) { + for (const t of visibleData) { if (typeof t.track_id === 'number') ids.push(t.track_id) } return ids - }, [data, isEditingThis]) + }, [visibleData, isEditingThis]) const selectableCount = selectableTrackIds.length const selectedCount = selection.count @@ -162,7 +173,7 @@ export const EditAwareTracksTable = (props: EditAwareTracksTableProps) => { return ( { const { color } = useTheme() const editMode = usePlaylistEditMode() const selection = useTrackSelection() - const history = useTrackHistoryContext() - const { data: collection } = useCollection(collectionId) + // Tracks already staged for removal are hidden from the table and should not + // be re-selected (e.g. by Select all). + const selectableTrackIds = useMemo( + () => orderedTrackIds.filter((id) => !editMode.removedTrackIds.has(id)), + [orderedTrackIds, editMode] + ) const selectedIds = useMemo( - () => orderedTrackIds.filter((id) => selection.isSelected(id)), - [orderedTrackIds, selection] + () => selectableTrackIds.filter((id) => selection.isSelected(id)), + [selectableTrackIds, selection] ) const { data: selectedTracks } = useTracks(selectedIds) @@ -81,39 +83,21 @@ export const TrackBulkActionsBar = (props: Props) => { }, [dispatch, selectedTracks]) const removeSelected = useCallback(() => { - if (!collection) return const trackIds = selectedIds if (trackIds.length === 0) return - // Record each removal in history so it can be undone. - trackIds.forEach((trackId) => { - const entry = collection.playlist_contents.track_ids.find( - (t) => t.track === trackId - ) - if (!entry) return - const index = collection.playlist_contents.track_ids.findIndex( - (t) => t.track === trackId && t.time === entry.time - ) - const timestamp = entry.metadata_time ?? entry.time - history.push({ type: 'remove', trackId, index, timestamp }) - dispatch(removeTrackFromPlaylist(trackId, collectionId, timestamp)) - }) + // Stage the removal — it isn't persisted until Apply is pressed. + editMode.stageRemoval(trackIds) dispatch(toast({ content: messages.removed(trackIds.length) })) selection.clear() - }, [collection, collectionId, dispatch, history, selectedIds, selection]) + }, [dispatch, editMode, selectedIds, selection]) const handleUndo = useCallback(() => { - history.undo((inverse) => { - if (inverse.type === 'add') { - dispatch( - addTrackToPlaylist(inverse.trackId, collectionId, { silent: true }) - ) - } - }) - }, [collectionId, dispatch, history]) + editMode.undoRemoval() + }, [editMode]) const handleRedo = useCallback(() => { - history.redo() - }, [history]) + editMode.redoRemoval() + }, [editMode]) // Keyboard shortcuts: only active while in edit mode useEffect(() => { @@ -128,7 +112,7 @@ export const TrackBulkActionsBar = (props: Props) => { const mod = e.metaKey || e.ctrlKey if (mod && e.key.toLowerCase() === 'a') { e.preventDefault() - selection.selectAll(orderedTrackIds) + selection.selectAll(selectableTrackIds) return } if (mod && e.key.toLowerCase() === 'z' && !e.shiftKey) { @@ -166,7 +150,7 @@ export const TrackBulkActionsBar = (props: Props) => { editMode.isEditMode, handleRedo, handleUndo, - orderedTrackIds, + selectableTrackIds, removeSelected, selection ]) @@ -174,7 +158,11 @@ export const TrackBulkActionsBar = (props: Props) => { if (!editMode.isEditMode || editMode.collectionId !== collectionId) { return null } - if (selection.count === 0 && !history.canUndo && !history.canRedo) { + if ( + selection.count === 0 && + !editMode.canUndoRemoval && + !editMode.canRedoRemoval + ) { return null } @@ -205,7 +193,7 @@ export const TrackBulkActionsBar = (props: Props) => { @@ -232,7 +220,7 @@ export const TrackBulkActionsBar = (props: Props) => {