diff --git a/packages/web/src/components/collection/desktop/edit-mode/tracks/TrackBulkActionsBar.tsx b/packages/web/src/components/collection/desktop/edit-mode/tracks/TrackBulkActionsBar.tsx
new file mode 100644
index 00000000000..687403bfd99
--- /dev/null
+++ b/packages/web/src/components/collection/desktop/edit-mode/tracks/TrackBulkActionsBar.tsx
@@ -0,0 +1,251 @@
+import { useCallback, useEffect, useMemo } from 'react'
+
+import { useCollection, useTracks } from '@audius/common/api'
+import { ID } from '@audius/common/models'
+import { cacheCollectionsActions, toastActions } from '@audius/common/store'
+import {
+ Button,
+ Flex,
+ IconCopy,
+ IconTrash,
+ Text,
+ useTheme
+} from '@audius/harmony'
+import { useDispatch } from 'react-redux'
+
+import { usePlaylistEditMode } from '../PlaylistEditModeContext'
+
+import { useTrackHistoryContext } from './TrackHistoryContext'
+import { useTrackSelection } from './TrackSelectionContext'
+
+const { removeTrackFromPlaylist, addTrackToPlaylist } = cacheCollectionsActions
+const { toast } = toastActions
+
+const messages = {
+ selected: (n: number) => `${n} selected`,
+ copy: 'Copy URLs',
+ remove: 'Remove',
+ clear: 'Clear',
+ selectAll: 'Select all',
+ undo: 'Undo',
+ redo: 'Redo',
+ copiedOne: 'Copied 1 track URL to clipboard',
+ copiedMany: (n: number) => `Copied ${n} track URLs to clipboard`,
+ copyFailed: 'Could not copy URLs to clipboard',
+ removed: (n: number) => (n === 1 ? 'Removed 1 track' : `Removed ${n} tracks`)
+}
+
+type Props = {
+ collectionId: ID
+ orderedTrackIds: ID[]
+}
+
+export const TrackBulkActionsBar = (props: Props) => {
+ const { collectionId, orderedTrackIds } = props
+ const dispatch = useDispatch()
+ const { color } = useTheme()
+ const editMode = usePlaylistEditMode()
+ const selection = useTrackSelection()
+ const history = useTrackHistoryContext()
+
+ const { data: collection } = useCollection(collectionId)
+ const selectedIds = useMemo(
+ () => orderedTrackIds.filter((id) => selection.isSelected(id)),
+ [orderedTrackIds, selection]
+ )
+ const { data: selectedTracks } = useTracks(selectedIds)
+
+ const copyUrls = useCallback(async () => {
+ if (!selectedTracks?.length) return
+ const origin =
+ typeof window !== 'undefined'
+ ? window.location.origin
+ : 'https://audius.co'
+ const urls = selectedTracks
+ .map((t) => (t?.permalink ? `${origin}${t.permalink}` : null))
+ .filter((u): u is string => !!u)
+ .join('\n')
+ try {
+ await navigator.clipboard.writeText(urls)
+ dispatch(
+ toast({
+ content:
+ urls.split('\n').length === 1
+ ? messages.copiedOne
+ : messages.copiedMany(urls.split('\n').length)
+ })
+ )
+ } catch {
+ dispatch(toast({ content: messages.copyFailed }))
+ }
+ }, [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))
+ })
+ dispatch(toast({ content: messages.removed(trackIds.length) }))
+ selection.clear()
+ }, [collection, collectionId, dispatch, history, selectedIds, selection])
+
+ const handleUndo = useCallback(() => {
+ history.undo((inverse) => {
+ if (inverse.type === 'add') {
+ dispatch(
+ addTrackToPlaylist(inverse.trackId, collectionId, { silent: true })
+ )
+ }
+ })
+ }, [collectionId, dispatch, history])
+
+ const handleRedo = useCallback(() => {
+ history.redo()
+ }, [history])
+
+ // Keyboard shortcuts: only active while in edit mode
+ useEffect(() => {
+ if (!editMode.isEditMode || editMode.collectionId !== collectionId) return
+ const onKey = (e: KeyboardEvent) => {
+ const target = e.target as HTMLElement | null
+ const isInputFocused =
+ target &&
+ ['INPUT', 'TEXTAREA'].includes(target.tagName) &&
+ !target.dataset.bulkActions
+ if (isInputFocused) return
+ const mod = e.metaKey || e.ctrlKey
+ if (mod && e.key.toLowerCase() === 'a') {
+ e.preventDefault()
+ selection.selectAll(orderedTrackIds)
+ return
+ }
+ if (mod && e.key.toLowerCase() === 'z' && !e.shiftKey) {
+ e.preventDefault()
+ handleUndo()
+ return
+ }
+ if (
+ (mod && e.key.toLowerCase() === 'z' && e.shiftKey) ||
+ (mod && e.key.toLowerCase() === 'y')
+ ) {
+ e.preventDefault()
+ handleRedo()
+ return
+ }
+ if (e.key === 'Escape') {
+ if (selection.count > 0) {
+ e.preventDefault()
+ selection.clear()
+ }
+ }
+ if (
+ (e.key === 'Delete' || e.key === 'Backspace') &&
+ selection.count > 0
+ ) {
+ e.preventDefault()
+ removeSelected()
+ }
+ }
+ window.addEventListener('keydown', onKey)
+ return () => window.removeEventListener('keydown', onKey)
+ }, [
+ collectionId,
+ editMode.collectionId,
+ editMode.isEditMode,
+ handleRedo,
+ handleUndo,
+ orderedTrackIds,
+ removeSelected,
+ selection
+ ])
+
+ if (!editMode.isEditMode || editMode.collectionId !== collectionId) {
+ return null
+ }
+ if (selection.count === 0 && !history.canUndo && !history.canRedo) {
+ return null
+ }
+
+ return (
+
+
+
+ {messages.selected(selection.count)}
+
+ {selection.count > 0 ? (
+
+ ) : null}
+
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/packages/web/src/components/collection/desktop/edit-mode/tracks/TrackHistoryContext.tsx b/packages/web/src/components/collection/desktop/edit-mode/tracks/TrackHistoryContext.tsx
new file mode 100644
index 00000000000..73e20d075a5
--- /dev/null
+++ b/packages/web/src/components/collection/desktop/edit-mode/tracks/TrackHistoryContext.tsx
@@ -0,0 +1,142 @@
+import {
+ createContext,
+ ReactNode,
+ useCallback,
+ useContext,
+ useMemo,
+ useRef,
+ useState
+} from 'react'
+
+import { ID } from '@audius/common/models'
+import { cacheCollectionsActions, toastActions } from '@audius/common/store'
+import { useDispatch } from 'react-redux'
+
+const { addTrackToPlaylist, removeTrackFromPlaylist } = cacheCollectionsActions
+const { toast } = toastActions
+
+export type TrackHistoryEntry =
+ | {
+ type: 'remove'
+ trackId: ID
+ // best-effort original position; not used to re-insert into the same
+ // slot because the existing addTrackToPlaylist saga always appends.
+ index: number
+ // timestamp captured at the time of removal for the saga call.
+ timestamp: number
+ }
+ | {
+ type: 'add'
+ trackId: ID
+ }
+
+type TrackHistoryContextValue = {
+ canUndo: boolean
+ canRedo: boolean
+ push: (entry: TrackHistoryEntry) => void
+ undo: (applyInverse: (entry: TrackHistoryEntry) => void) => void
+ redo: () => void
+}
+
+const TrackHistoryContext = createContext(null)
+
+type ProviderProps = {
+ collectionId?: ID
+ children: ReactNode
+}
+
+const messages = {
+ noMoreUndo: 'Nothing to undo',
+ noMoreRedo: 'Nothing to redo'
+}
+
+export const TrackHistoryProvider = ({
+ collectionId,
+ children
+}: ProviderProps) => {
+ const dispatch = useDispatch()
+ const undoStackRef = useRef([])
+ const redoStackRef = useRef([])
+ const [, force] = useState(0)
+ const bump = useCallback(() => force((v) => v + 1), [])
+
+ const push = useCallback(
+ (entry: TrackHistoryEntry) => {
+ undoStackRef.current.push(entry)
+ redoStackRef.current = []
+ bump()
+ },
+ [bump]
+ )
+
+ const undo = useCallback(
+ (applyInverse: (entry: TrackHistoryEntry) => void) => {
+ const entry = undoStackRef.current.pop()
+ if (!entry) {
+ dispatch(toast({ content: messages.noMoreUndo }))
+ return
+ }
+ redoStackRef.current.push(entry)
+ bump()
+ const inverse: TrackHistoryEntry =
+ entry.type === 'remove'
+ ? { type: 'add', trackId: entry.trackId }
+ : { type: 'remove', trackId: entry.trackId, index: -1, timestamp: 0 }
+ applyInverse(inverse)
+ },
+ [bump, dispatch]
+ )
+
+ const redo = useCallback(() => {
+ const entry = redoStackRef.current.pop()
+ if (!entry) {
+ dispatch(toast({ content: messages.noMoreRedo }))
+ return
+ }
+ undoStackRef.current.push(entry)
+ bump()
+ if (!collectionId) return
+ if (entry.type === 'remove') {
+ dispatch(
+ removeTrackFromPlaylist(entry.trackId, collectionId, entry.timestamp)
+ )
+ } else if (entry.type === 'add') {
+ dispatch(
+ addTrackToPlaylist(entry.trackId, collectionId, { silent: true })
+ )
+ }
+ }, [bump, collectionId, dispatch])
+
+ const value = useMemo(
+ () => ({
+ canUndo: undoStackRef.current.length > 0,
+ canRedo: redoStackRef.current.length > 0,
+ push,
+ undo,
+ redo
+ }),
+ // Intentionally include bump tick via undoStackRef.current.length read
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ [push, undo, redo, undoStackRef.current.length, redoStackRef.current.length]
+ )
+
+ return (
+
+ {children}
+
+ )
+}
+
+export const useTrackHistoryContext = (): TrackHistoryContextValue => {
+ const ctx = useContext(TrackHistoryContext)
+ if (!ctx) {
+ return {
+ canUndo: false,
+ canRedo: false,
+ push: () => {},
+ undo: () => {},
+ redo: () => {}
+ }
+ }
+ return ctx
+}
diff --git a/packages/web/src/components/collection/desktop/edit-mode/tracks/TrackSelectionContext.tsx b/packages/web/src/components/collection/desktop/edit-mode/tracks/TrackSelectionContext.tsx
new file mode 100644
index 00000000000..f2f34d214d8
--- /dev/null
+++ b/packages/web/src/components/collection/desktop/edit-mode/tracks/TrackSelectionContext.tsx
@@ -0,0 +1,109 @@
+import {
+ createContext,
+ ReactNode,
+ useCallback,
+ useContext,
+ useMemo,
+ useRef,
+ useState
+} from 'react'
+
+import { ID } from '@audius/common/models'
+
+type SelectionContextValue = {
+ selected: Set
+ count: number
+ isSelected: (id: ID) => boolean
+ toggle: (id: ID, index: number, opts?: { shift?: boolean }) => void
+ selectAll: (ids: ID[]) => void
+ clear: () => void
+ setSelected: (next: Set) => void
+}
+
+const TrackSelectionContext = createContext(null)
+
+type ProviderProps = {
+ orderedIds: ID[]
+ children: ReactNode
+}
+
+export const TrackSelectionProvider = ({
+ orderedIds,
+ children
+}: ProviderProps) => {
+ const [selected, setSelectedState] = useState>(new Set())
+ const lastIndexRef = useRef(null)
+
+ const setSelected = useCallback((next: Set) => {
+ setSelectedState(new Set(next))
+ }, [])
+
+ const isSelected = useCallback((id: ID) => selected.has(id), [selected])
+
+ const toggle = useCallback(
+ (id, index, opts) => {
+ setSelectedState((prev) => {
+ const next = new Set(prev)
+ if (opts?.shift && lastIndexRef.current !== null) {
+ const [a, b] = [lastIndexRef.current, index].sort((x, y) => x - y)
+ for (let i = a; i <= b; i += 1) {
+ const sliceId = orderedIds[i]
+ if (sliceId !== undefined) next.add(sliceId)
+ }
+ } else if (next.has(id)) {
+ next.delete(id)
+ } else {
+ next.add(id)
+ }
+ lastIndexRef.current = index
+ return next
+ })
+ },
+ [orderedIds]
+ )
+
+ const selectAll = useCallback((ids: ID[]) => {
+ setSelectedState(new Set(ids))
+ }, [])
+
+ const clear = useCallback(() => {
+ setSelectedState(new Set())
+ lastIndexRef.current = null
+ }, [])
+
+ const value = useMemo(
+ () => ({
+ selected,
+ count: selected.size,
+ isSelected,
+ toggle,
+ selectAll,
+ clear,
+ setSelected
+ }),
+ [selected, isSelected, toggle, selectAll, clear, setSelected]
+ )
+
+ return (
+
+ {children}
+
+ )
+}
+
+export const useTrackSelection = (): SelectionContextValue => {
+ const ctx = useContext(TrackSelectionContext)
+ if (!ctx) {
+ // Safe no-op fallback for usage outside the provider.
+ return {
+ selected: new Set(),
+ count: 0,
+ isSelected: () => false,
+ toggle: () => {},
+ selectAll: () => {},
+ clear: () => {},
+ setSelected: () => {}
+ }
+ }
+ return ctx
+}
diff --git a/packages/web/src/pages/collection-page/components/desktop/CollectionPage.tsx b/packages/web/src/pages/collection-page/components/desktop/CollectionPage.tsx
index aeaef0a1c3a..f4a99a34b57 100644
--- a/packages/web/src/pages/collection-page/components/desktop/CollectionPage.tsx
+++ b/packages/web/src/pages/collection-page/components/desktop/CollectionPage.tsx
@@ -24,6 +24,9 @@ import { CollectionDogEar } from 'components/collection'
import { CollectionHeader } from 'components/collection/desktop/CollectionHeader'
import { PlaylistEditModeBar } from 'components/collection/desktop/edit-mode/PlaylistEditModeBar'
import { PlaylistEditModeProvider } from 'components/collection/desktop/edit-mode/PlaylistEditModeContext'
+import { TrackBulkActionsBar } from 'components/collection/desktop/edit-mode/tracks/TrackBulkActionsBar'
+import { TrackHistoryProvider } from 'components/collection/desktop/edit-mode/tracks/TrackHistoryContext'
+import { TrackSelectionProvider } from 'components/collection/desktop/edit-mode/tracks/TrackSelectionContext'
import FilterInput from 'components/filter-input/FilterInput'
import Page from 'components/page/Page'
import { SuggestedTracks } from 'components/suggested-tracks'
@@ -300,92 +303,108 @@ const CollectionPage = ({ type }: CollectionPageProps) => {
) : null
const collectionMessages = getMessages(isAlbum ? 'album' : 'playlist')
+ const orderedTrackIds = dataSource
+ .map((t: CollectionTrack) => t.track_id)
+ .filter((id): id is number => typeof id === 'number')
return (
-
-
-
- {topSection}
- {!pageLoading && isEmpty ? (
-
- ) : !pageLoading &&
- tracks.status === Status.SUCCESS &&
- dataSource.length === 0 ? (
-
- ) : (
-
- )}
-
+ {topSection}
+ {playlistId != null ? (
+
+ ) : null}
+ {!pageLoading && isEmpty ? (
+
+ ) : !pageLoading &&
+ tracks.status === Status.SUCCESS &&
+ dataSource.length === 0 ? (
+
+ ) : (
+
+
+
+ )}
+
- {playlistId != null && isOwner && !isAlbum ? (
-
-
-
-
- ) : null}
-
-
+ {playlistId != null && isOwner && !isAlbum ? (
+
+
+
+
+ ) : null}
+
+
+
+
)
}