import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; import { ipcChannels } from '../../../shared/ipc/contracts'; import type { AppPlaylist, PlaylistAudioId, PlaylistId, PlaylistTrackId, } from '../../../shared/types/appPlaylists'; import { filterPlaylistsByQuery } from '../../../shared/types/appPlaylists'; import { filterAudioFilePaths, useFileDropZone } from '../../editor/fileDrop'; import editorStyles from '../../editor/EditorApp.module.css'; import { useEditorI18n } from '../../editor/i18n/EditorI18nContext'; import { getDndApi } from '../dndApi'; import { registerEditorAudioStopper } from '../audio/editorAudioStop'; import { Button, Input } from '../ui/controls'; import { PlaylistPlayerBar } from './PlaylistPlayerBar'; import styles from './PlaylistsPanel.module.css'; import { useAppPlaylists } from './useAppPlaylists'; const TRACK_DND = 'application/x-dnd-playlist-track'; const PLAYLIST_DND = 'application/x-dnd-playlist-id'; export type PlaylistsPanelLabels = { searchPlaceholder: string; create: string; rename: string; duplicate: string; delete: string; addAudio: string; emptyPlaylists: string; emptyTracks: string; untitled: string; play: string; pause: string; prev: string; next: string; volume: string; scrub: string; emptyPlayer: string; playPlaylist: string; removeTrack: string; renameAudio: string; dropAudioHint: string; }; export function PlaylistsPanel({ labels, className, readOnly = false, }: { labels: PlaylistsPanelLabels; className?: string; /** Electron player window: browse + play only (no CRUD / DnD). */ readOnly?: boolean; }) { const { t } = useEditorI18n(); const api = getDndApi(); const state = useAppPlaylists(); const [query, setQuery] = useState(''); const [selectedId, setSelectedId] = useState(null); const [editingName, setEditingName] = useState(''); const [editingAudioId, setEditingAudioId] = useState(null); const [editingAudioTitle, setEditingAudioTitle] = useState(''); const [pendingDelete, setPendingDelete] = useState(null); const audioById = useMemo(() => new Map(state.audios.map((a) => [a.id, a])), [state.audios]); const filtered = useMemo(() => filterPlaylistsByQuery(state, query), [state, query]); const selected = useMemo( () => state.playlists.find((p) => p.id === selectedId) ?? filtered.playlists[0] ?? null, [filtered.playlists, selectedId, state.playlists], ); useEffect(() => { if (selected) { setSelectedId(selected.id); setEditingName(selected.name); } else { setEditingName(''); } }, [selected]); const elsRef = useRef>(new Map()); const queuesRef = useRef>(new Map()); const [playerUi, setPlayerUi] = useState< Record >({}); const playerUiRef = useRef(playerUi); playerUiRef.current = playerUi; const playAtRef = useRef<(playlist: AppPlaylist, index: number) => Promise>(async () => undefined); const statePlaylistsRef = useRef(state.playlists); statePlaylistsRef.current = state.playlists; const bumpPlayer = useCallback((playlistId: PlaylistId, patch: Partial<{ index: number; playing: boolean; gain: number }>) => { setPlayerUi((prev) => { const cur = prev[playlistId] ?? { index: 0, playing: false, gain: 1 }; return { ...prev, [playlistId]: { ...cur, ...patch } }; }); }, []); const destroyPlayer = useCallback((playlistId: PlaylistId) => { const el = elsRef.current.get(playlistId); if (el) { el.pause(); el.onended = null; el.onplay = null; el.onpause = null; el.removeAttribute('src'); el.load(); elsRef.current.delete(playlistId); } queuesRef.current.delete(playlistId); setPlayerUi((prev) => { if (!(playlistId in prev)) return prev; const next = { ...prev }; delete next[playlistId]; return next; }); }, []); const stopAllPlayback = useCallback(() => { for (const id of [...elsRef.current.keys()]) destroyPlayer(id); }, [destroyPlayer]); useEffect(() => () => stopAllPlayback(), [stopAllPlayback]); useEffect(() => { if (readOnly) return undefined; return registerEditorAudioStopper(() => stopAllPlayback()); }, [readOnly, stopAllPlayback]); const playAt = useCallback( async (playlist: AppPlaylist, index: number) => { const track = playlist.tracks[index]; if (!track) return; const { url } = await api.invoke(ipcChannels.playlists.audioUrl, { id: track.audioId }); if (!url) return; let el = elsRef.current.get(playlist.id); if (!el) { el = new Audio(); el.preload = 'auto'; elsRef.current.set(playlist.id, el); el.onended = () => { const qIndex = queuesRef.current.get(playlist.id) ?? 0; const pl = statePlaylistsRef.current.find((p) => p.id === playlist.id); if (!pl || pl.tracks.length === 0) { destroyPlayer(playlist.id); return; } const next = (qIndex + 1) % pl.tracks.length; void playAtRef.current(pl, next); }; el.onplay = () => bumpPlayer(playlist.id, { playing: true }); el.onpause = () => bumpPlayer(playlist.id, { playing: false }); } const gain = playerUiRef.current[playlist.id]?.gain ?? 1; el.volume = gain; el.src = url; queuesRef.current.set(playlist.id, index); bumpPlayer(playlist.id, { index, playing: false, gain }); try { await el.play(); bumpPlayer(playlist.id, { index, playing: true }); } catch { bumpPlayer(playlist.id, { index, playing: false }); } }, [api, bumpPlayer, destroyPlayer], ); playAtRef.current = playAt; const selectedPlayer = selected ? playerUi[selected.id] : undefined; const selectedEl = selected ? elsRef.current.get(selected.id) ?? null : null; const selectedQueueIndex = selectedPlayer?.index ?? 0; const selectedPlaying = Boolean(selectedPlayer?.playing); const selectedGain = selectedPlayer?.gain ?? 1; const selectedCurrentTrack = selected?.tracks[selectedQueueIndex] ?? null; const selectedCurrentAudio = selectedCurrentTrack ? audioById.get(selectedCurrentTrack.audioId) ?? null : null; const step = (delta: number) => { if (!selected || selected.tracks.length === 0) return; const base = playerUi[selected.id]?.index ?? 0; const next = (base + delta + selected.tracks.length) % selected.tracks.length; void playAt(selected, next); }; const createPlaylist = async () => { const { playlist } = await api.invoke(ipcChannels.playlists.create, {}); setSelectedId(playlist.id); }; const saveRename = async () => { if (!selected) return; if (editingName.trim() === selected.name) return; await api.invoke(ipcChannels.playlists.rename, { id: selected.id, name: editingName }); }; const addAudio = async () => { if (!selected) return; const { filePaths } = await api.invoke(ipcChannels.playlists.pickAudioFiles, {}); if (filePaths.length === 0) return; await api.invoke(ipcChannels.playlists.addAudioFiles, { playlistId: selected.id, filePaths, }); }; const addAudioFromPaths = useCallback( (paths: string[]) => { if (!selected || paths.length === 0) return; void api.invoke(ipcChannels.playlists.addAudioFiles, { playlistId: selected.id, filePaths: paths, }); }, [api, selected], ); const audioFileDrop = useFileDropZone({ disabled: readOnly || !selected, filterPaths: filterAudioFilePaths, onDropPaths: addAudioFromPaths, }); const deleteSelected = () => { if (readOnly || !selected) return; setPendingDelete(selected); }; const confirmDelete = async () => { if (!pendingDelete) return; const id = pendingDelete.id; setPendingDelete(null); destroyPlayer(id); await api.invoke(ipcChannels.playlists.delete, { id }); setSelectedId((cur) => (cur === id ? null : cur)); }; return (
{!readOnly ? ( ) : null}
{filtered.playlists.length === 0 ? (
{labels.emptyPlaylists}
) : ( filtered.playlists.map((pl) => ( )) )}
{!readOnly && selected && audioFileDrop.dragOver ? (
{labels.dropAudioHint}
) : null} {selected ? ( <>
{readOnly ? (
{selected.name || labels.untitled}
) : ( void saveRename()} /> )}
{!readOnly ? ( <> ) : null}
{selected.tracks.length === 0 ? (
{labels.emptyTracks} {!readOnly ?
{labels.dropAudioHint}
: null}
) : ( selected.tracks.map((tr, idx) => { const audio = audioById.get(tr.audioId); const title = audio?.title ?? labels.untitled; const highlighted = query.trim() && filtered.matchedAudioIds.has(tr.audioId); const isCurrent = selectedPlayer?.playing === true && selectedQueueIndex === idx && Boolean(selectedCurrentTrack); return (
{ e.dataTransfer.setData(TRACK_DND, tr.id); e.dataTransfer.effectAllowed = 'move'; } } onDragOver={ readOnly ? undefined : (e) => { if (e.dataTransfer.types.includes(TRACK_DND)) e.preventDefault(); } } onDrop={ readOnly ? undefined : (e) => { if (!e.dataTransfer.types.includes(TRACK_DND)) return; e.preventDefault(); e.stopPropagation(); const dragged = e.dataTransfer.getData(TRACK_DND) as PlaylistTrackId; if (!dragged || dragged === tr.id) return; const ids = selected.tracks.map((t) => t.id); const from = ids.indexOf(dragged); const to = ids.indexOf(tr.id); if (from < 0 || to < 0) return; const next = [...ids]; next.splice(from, 1); next.splice(to, 0, dragged); void api.invoke(ipcChannels.playlists.setTracksOrder, { playlistId: selected.id, trackIds: next, }); } } onDoubleClick={() => void playAt(selected, idx)} > {readOnly ? ( {title} ) : editingAudioId === tr.audioId ? ( { void api.invoke(ipcChannels.playlists.renameAudio, { audioId: tr.audioId, title: editingAudioTitle, }); setEditingAudioId(null); }} /> ) : ( )} {!readOnly ? ( ) : null}
); }) )}
) : (
{labels.emptyPlaylists}
)} { if (!selected) return; const el = elsRef.current.get(selected.id); if (el) el.volume = g; bumpPlayer(selected.id, { gain: g }); }} onTogglePlay={() => { if (!selected) return; const el = elsRef.current.get(selected.id); if (!el) { if (selected.tracks.length > 0) void playAt(selected, 0); return; } if (el.paused) void el.play().catch(() => bumpPlayer(selected.id, { playing: false })); else el.pause(); }} onPrev={() => step(-1)} onNext={() => step(1)} playLabel={labels.play} pauseLabel={labels.pause} prevLabel={labels.prev} nextLabel={labels.next} volumeLabel={labels.volume} scrubLabel={labels.scrub} emptyLabel={labels.emptyPlayer} />
{!readOnly && pendingDelete ? createPortal( <>
{t('playlists.deleteConfirm', { name: pendingDelete.name })}
, document.body, ) : null} ); } export function playlistsPanelLabelsFromT(t: (key: string) => string): PlaylistsPanelLabels { return { searchPlaceholder: t('playlists.search'), create: t('playlists.create'), rename: t('playlists.rename'), duplicate: t('playlists.duplicate'), delete: t('playlists.delete'), addAudio: t('playlists.addAudio'), emptyPlaylists: t('playlists.empty'), emptyTracks: t('playlists.emptyTracks'), untitled: t('playlists.untitled'), play: t('playlists.play'), pause: t('playlists.pause'), prev: t('playlists.prev'), next: t('playlists.next'), volume: t('playlists.volume'), scrub: t('playlists.scrub'), emptyPlayer: t('playlists.emptyPlayer'), playPlaylist: t('playlists.playPlaylist'), removeTrack: t('playlists.removeTrack'), renameAudio: t('playlists.renameAudio'), dropAudioHint: t('playlists.dropAudioHint'), }; }