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 { 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 audioElRef = useRef(null); const [audioEl, setAudioEl] = useState(null); const [playing, setPlaying] = useState(false); const [gain, setGain] = useState(1); const [queuePlaylistId, setQueuePlaylistId] = useState(null); const [queueIndex, setQueueIndex] = useState(0); const queueRef = useRef<{ playlistId: PlaylistId; index: number } | null>(null); const queuePlaylist = useMemo( () => (queuePlaylistId ? state.playlists.find((p) => p.id === queuePlaylistId) ?? null : null), [queuePlaylistId, state.playlists], ); const currentTrack = queuePlaylist?.tracks[queueIndex] ?? null; const currentAudio = currentTrack ? audioById.get(currentTrack.audioId) ?? null : null; const stopPlayback = useCallback(() => { const el = audioElRef.current; if (el) { el.pause(); el.removeAttribute('src'); el.load(); } audioElRef.current = null; setAudioEl(null); setPlaying(false); queueRef.current = null; setQueuePlaylistId(null); setQueueIndex(0); }, []); useEffect(() => () => stopPlayback(), [stopPlayback]); 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 = audioElRef.current; if (!el) { el = new Audio(); el.preload = 'auto'; audioElRef.current = el; setAudioEl(el); } el.volume = gain; el.src = url; queueRef.current = { playlistId: playlist.id, index }; setQueuePlaylistId(playlist.id); setQueueIndex(index); try { await el.play(); setPlaying(true); } catch { setPlaying(false); } }, [api, gain], ); useEffect(() => { const el = audioElRef.current; if (!el) return; const onEnded = () => { const q = queueRef.current; if (!q) return; const pl = state.playlists.find((p) => p.id === q.playlistId); if (!pl || pl.tracks.length === 0) { stopPlayback(); return; } const next = (q.index + 1) % pl.tracks.length; void playAt(pl, next); }; const onPlay = () => setPlaying(true); const onPause = () => setPlaying(false); el.addEventListener('ended', onEnded); el.addEventListener('play', onPlay); el.addEventListener('pause', onPause); return () => { el.removeEventListener('ended', onEnded); el.removeEventListener('play', onPlay); el.removeEventListener('pause', onPause); }; }, [audioEl, playAt, state.playlists, stopPlayback]); const step = (delta: number) => { const q = queueRef.current; const pl = q ? state.playlists.find((p) => p.id === q.playlistId) : selected; if (!pl || pl.tracks.length === 0) return; const base = q?.playlistId === pl.id ? q.index : 0; const next = (base + delta + pl.tracks.length) % pl.tracks.length; void playAt(pl, 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); if (queueRef.current?.playlistId === id) stopPlayback(); 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 = queuePlaylistId === selected.id && queueIndex === idx && Boolean(currentTrack); 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}
)} { const el = audioElRef.current; if (!el) { if (selected && selected.tracks.length > 0) void playAt(selected, 0); return; } if (el.paused) void el.play().then(() => setPlaying(true)).catch(() => setPlaying(false)); else { el.pause(); setPlaying(false); } }} 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'), }; }