feat(playlists): библиотека плейлистов приложения
CRUD в редакторе, read-only окно на пульте, отдельный аудиоканал, экспорт/импорт и маппинг из Foundry. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,67 @@
|
||||
.bar {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--stroke);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--color-overlay-dark-2);
|
||||
}
|
||||
|
||||
.meta {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-weight: 800;
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
margin-top: 2px;
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text2);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.transport {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.scrubRow {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr auto;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.scrub {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.time {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text2);
|
||||
font-variant-numeric: tabular-nums;
|
||||
min-width: 2.5em;
|
||||
}
|
||||
|
||||
.volume {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.volumeLabel {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text2);
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { Button } from '../ui/controls';
|
||||
|
||||
import styles from './PlaylistPlayerBar.module.css';
|
||||
|
||||
function formatTime(sec: number): string {
|
||||
if (!Number.isFinite(sec) || sec < 0) return '0:00';
|
||||
const s = Math.floor(sec);
|
||||
const m = Math.floor(s / 60);
|
||||
const r = s % 60;
|
||||
return `${String(m)}:${String(r).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function clampGain(v: number): number {
|
||||
if (!Number.isFinite(v)) return 1;
|
||||
return Math.max(0, Math.min(1, v));
|
||||
}
|
||||
|
||||
export type PlaylistPlayerBarProps = {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
audioEl: HTMLAudioElement | null;
|
||||
playing: boolean;
|
||||
gain: number;
|
||||
onGainChange: (gain: number) => void;
|
||||
onTogglePlay: () => void;
|
||||
onPrev: () => void;
|
||||
onNext: () => void;
|
||||
playLabel: string;
|
||||
pauseLabel: string;
|
||||
prevLabel: string;
|
||||
nextLabel: string;
|
||||
volumeLabel: string;
|
||||
scrubLabel: string;
|
||||
emptyLabel: string;
|
||||
};
|
||||
|
||||
export function PlaylistPlayerBar({
|
||||
title,
|
||||
subtitle,
|
||||
audioEl,
|
||||
playing,
|
||||
gain,
|
||||
onGainChange,
|
||||
onTogglePlay,
|
||||
onPrev,
|
||||
onNext,
|
||||
playLabel,
|
||||
pauseLabel,
|
||||
prevLabel,
|
||||
nextLabel,
|
||||
volumeLabel,
|
||||
scrubLabel,
|
||||
emptyLabel,
|
||||
}: PlaylistPlayerBarProps) {
|
||||
const [current, setCurrent] = useState(0);
|
||||
const [duration, setDuration] = useState(0);
|
||||
const scrubbing = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!audioEl) {
|
||||
setCurrent(0);
|
||||
setDuration(0);
|
||||
return;
|
||||
}
|
||||
const sync = () => {
|
||||
if (!scrubbing.current) setCurrent(audioEl.currentTime);
|
||||
setDuration(Number.isFinite(audioEl.duration) ? audioEl.duration : 0);
|
||||
};
|
||||
sync();
|
||||
audioEl.addEventListener('timeupdate', sync);
|
||||
audioEl.addEventListener('loadedmetadata', sync);
|
||||
audioEl.addEventListener('durationchange', sync);
|
||||
return () => {
|
||||
audioEl.removeEventListener('timeupdate', sync);
|
||||
audioEl.removeEventListener('loadedmetadata', sync);
|
||||
audioEl.removeEventListener('durationchange', sync);
|
||||
};
|
||||
}, [audioEl]);
|
||||
|
||||
useEffect(() => {
|
||||
if (audioEl) audioEl.volume = clampGain(gain);
|
||||
}, [audioEl, gain]);
|
||||
|
||||
const hasTrack = Boolean(audioEl);
|
||||
|
||||
return (
|
||||
<div className={styles.bar} data-testid="playlist-player-bar">
|
||||
<div className={styles.meta}>
|
||||
<div className={styles.title}>{hasTrack ? title : emptyLabel}</div>
|
||||
{hasTrack && subtitle ? <div className={styles.subtitle}>{subtitle}</div> : null}
|
||||
</div>
|
||||
<div className={styles.transport}>
|
||||
<Button variant="ghost" iconOnly title={prevLabel} ariaLabel={prevLabel} disabled={!hasTrack} onClick={onPrev}>
|
||||
⏮
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
iconOnly
|
||||
title={playing ? pauseLabel : playLabel}
|
||||
ariaLabel={playing ? pauseLabel : playLabel}
|
||||
disabled={!hasTrack}
|
||||
onClick={onTogglePlay}
|
||||
data-testid="playlist-play-pause"
|
||||
>
|
||||
{playing ? '⏸' : '▶'}
|
||||
</Button>
|
||||
<Button variant="ghost" iconOnly title={nextLabel} ariaLabel={nextLabel} disabled={!hasTrack} onClick={onNext}>
|
||||
⏭
|
||||
</Button>
|
||||
</div>
|
||||
<div className={styles.scrubRow}>
|
||||
<span className={styles.time}>{formatTime(current)}</span>
|
||||
<input
|
||||
type="range"
|
||||
className={styles.scrub}
|
||||
min={0}
|
||||
max={Math.max(duration, 0.001)}
|
||||
step={0.01}
|
||||
value={Math.min(current, duration || 0)}
|
||||
disabled={!hasTrack || !duration}
|
||||
aria-label={scrubLabel}
|
||||
onMouseDown={() => {
|
||||
scrubbing.current = true;
|
||||
}}
|
||||
onMouseUp={(e) => {
|
||||
scrubbing.current = false;
|
||||
if (audioEl) audioEl.currentTime = Number(e.currentTarget.value);
|
||||
}}
|
||||
onChange={(e) => {
|
||||
const v = Number(e.currentTarget.value);
|
||||
setCurrent(v);
|
||||
if (audioEl && !scrubbing.current) audioEl.currentTime = v;
|
||||
}}
|
||||
/>
|
||||
<span className={styles.time}>{formatTime(duration)}</span>
|
||||
</div>
|
||||
<label className={styles.volume}>
|
||||
<span className={styles.volumeLabel}>{volumeLabel}</span>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.01}
|
||||
value={clampGain(gain)}
|
||||
aria-label={volumeLabel}
|
||||
onChange={(e) => onGainChange(clampGain(Number(e.currentTarget.value)))}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
.root {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(200px, 260px) 1fr;
|
||||
gap: 12px;
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.sidebar,
|
||||
.main {
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.mainDropActive {
|
||||
outline: 2px dashed var(--accent2);
|
||||
outline-offset: -2px;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.dropHintOverlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 5;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
pointer-events: none;
|
||||
background: var(--color-overlay-dark-3);
|
||||
color: var(--text);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 700;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.dropHintMuted {
|
||||
margin-top: 8px;
|
||||
color: var(--text2);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.list,
|
||||
.tracks {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
border: 1px solid var(--stroke);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-overlay-dark-3);
|
||||
padding: 6px;
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
align-content: start;
|
||||
}
|
||||
|
||||
.playlistRow,
|
||||
.trackRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px;
|
||||
border-radius: 6px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.playlistRow:hover,
|
||||
.trackRow:hover {
|
||||
background: var(--accent-fill-soft);
|
||||
}
|
||||
|
||||
.playlistRowSelected,
|
||||
.trackRowCurrent {
|
||||
background: var(--scene-list-selected-bg);
|
||||
border: 1px solid var(--scene-list-selected-border);
|
||||
}
|
||||
|
||||
.trackRowMatch {
|
||||
outline: 1px dashed var(--accent2);
|
||||
}
|
||||
|
||||
.playlistName,
|
||||
.trackTitle,
|
||||
.playlistNameReadonly,
|
||||
.trackTitleReadonly {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.trackTitle {
|
||||
background: transparent;
|
||||
border: 0;
|
||||
color: inherit;
|
||||
text-align: left;
|
||||
cursor: text;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.playlistNameReadonly {
|
||||
font-weight: 700;
|
||||
font-size: var(--text-md, 1rem);
|
||||
}
|
||||
|
||||
.trackTitleReadonly {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.playlistCount {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text2);
|
||||
}
|
||||
|
||||
.mainHeader {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.mainActions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.trackPlay {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--text2);
|
||||
cursor: pointer;
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
.empty {
|
||||
padding: 12px;
|
||||
color: var(--text2);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
@@ -0,0 +1,564 @@
|
||||
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<PlaylistId | null>(null);
|
||||
const [editingName, setEditingName] = useState('');
|
||||
const [editingAudioId, setEditingAudioId] = useState<PlaylistAudioId | null>(null);
|
||||
const [editingAudioTitle, setEditingAudioTitle] = useState('');
|
||||
const [pendingDelete, setPendingDelete] = useState<AppPlaylist | null>(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<HTMLAudioElement | null>(null);
|
||||
const [audioEl, setAudioEl] = useState<HTMLAudioElement | null>(null);
|
||||
const [playing, setPlaying] = useState(false);
|
||||
const [gain, setGain] = useState(1);
|
||||
const [queuePlaylistId, setQueuePlaylistId] = useState<PlaylistId | null>(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 (
|
||||
<div className={[styles.root, className].filter(Boolean).join(' ')} data-testid="playlists-panel">
|
||||
<div className={styles.sidebar}>
|
||||
<div className={styles.toolbar}>
|
||||
<Input
|
||||
value={query}
|
||||
onChange={setQuery}
|
||||
placeholder={labels.searchPlaceholder}
|
||||
data-testid="playlists-search"
|
||||
/>
|
||||
{!readOnly ? (
|
||||
<Button variant="primary" onClick={() => void createPlaylist()} data-testid="playlists-create">
|
||||
{labels.create}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
<div className={styles.list}>
|
||||
{filtered.playlists.length === 0 ? (
|
||||
<div className={styles.empty}>{labels.emptyPlaylists}</div>
|
||||
) : (
|
||||
filtered.playlists.map((pl) => (
|
||||
<button
|
||||
key={pl.id}
|
||||
type="button"
|
||||
className={[styles.playlistRow, selected?.id === pl.id ? styles.playlistRowSelected : '']
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
data-testid={`playlist-row-${pl.id}`}
|
||||
draggable={!readOnly}
|
||||
onDragStart={
|
||||
readOnly
|
||||
? undefined
|
||||
: (e) => {
|
||||
e.dataTransfer.setData(PLAYLIST_DND, pl.id);
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
}
|
||||
}
|
||||
onDragOver={
|
||||
readOnly
|
||||
? undefined
|
||||
: (e) => {
|
||||
if (e.dataTransfer.types.includes(PLAYLIST_DND)) e.preventDefault();
|
||||
}
|
||||
}
|
||||
onDrop={
|
||||
readOnly
|
||||
? undefined
|
||||
: (e) => {
|
||||
e.preventDefault();
|
||||
const dragged = e.dataTransfer.getData(PLAYLIST_DND) as PlaylistId;
|
||||
if (!dragged || dragged === pl.id) return;
|
||||
const ids = state.playlists.map((p) => p.id);
|
||||
const from = ids.indexOf(dragged);
|
||||
const to = ids.indexOf(pl.id);
|
||||
if (from < 0 || to < 0) return;
|
||||
const next = [...ids];
|
||||
next.splice(from, 1);
|
||||
next.splice(to, 0, dragged);
|
||||
void api.invoke(ipcChannels.playlists.setOrder, { playlistIds: next });
|
||||
}
|
||||
}
|
||||
onClick={() => setSelectedId(pl.id)}
|
||||
>
|
||||
<span className={styles.playlistName}>{pl.name || labels.untitled}</span>
|
||||
<span className={styles.playlistCount}>{pl.tracks.length}</span>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={[
|
||||
styles.main,
|
||||
!readOnly && selected && audioFileDrop.dragOver ? styles.mainDropActive : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
onDragEnter={readOnly ? undefined : audioFileDrop.onDragEnter}
|
||||
onDragLeave={readOnly ? undefined : audioFileDrop.onDragLeave}
|
||||
onDragOver={readOnly ? undefined : audioFileDrop.onDragOver}
|
||||
onDrop={readOnly ? undefined : audioFileDrop.onDrop}
|
||||
>
|
||||
{!readOnly && selected && audioFileDrop.dragOver ? (
|
||||
<div className={styles.dropHintOverlay}>{labels.dropAudioHint}</div>
|
||||
) : null}
|
||||
{selected ? (
|
||||
<>
|
||||
<div className={styles.mainHeader}>
|
||||
{readOnly ? (
|
||||
<div className={styles.playlistNameReadonly}>{selected.name || labels.untitled}</div>
|
||||
) : (
|
||||
<Input value={editingName} onChange={setEditingName} onBlur={() => void saveRename()} />
|
||||
)}
|
||||
<div className={styles.mainActions}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => void playAt(selected, 0)}
|
||||
disabled={selected.tracks.length === 0}
|
||||
>
|
||||
{labels.playPlaylist}
|
||||
</Button>
|
||||
{!readOnly ? (
|
||||
<>
|
||||
<Button variant="ghost" onClick={() => void addAudio()}>
|
||||
{labels.addAudio}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => void api.invoke(ipcChannels.playlists.duplicate, { id: selected.id })}
|
||||
>
|
||||
{labels.duplicate}
|
||||
</Button>
|
||||
<Button variant="ghost" onClick={() => void deleteSelected()}>
|
||||
{labels.delete}
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.tracks}>
|
||||
{selected.tracks.length === 0 ? (
|
||||
<div className={styles.empty}>
|
||||
{labels.emptyTracks}
|
||||
{!readOnly ? <div className={styles.dropHintMuted}>{labels.dropAudioHint}</div> : null}
|
||||
</div>
|
||||
) : (
|
||||
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 (
|
||||
<div
|
||||
key={tr.id}
|
||||
className={[
|
||||
styles.trackRow,
|
||||
isCurrent ? styles.trackRowCurrent : '',
|
||||
highlighted ? styles.trackRowMatch : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
draggable={!readOnly}
|
||||
onDragStart={
|
||||
readOnly
|
||||
? undefined
|
||||
: (e) => {
|
||||
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)}
|
||||
>
|
||||
<button type="button" className={styles.trackPlay} onClick={() => void playAt(selected, idx)}>
|
||||
▶
|
||||
</button>
|
||||
{readOnly ? (
|
||||
<span className={styles.trackTitleReadonly}>{title}</span>
|
||||
) : editingAudioId === tr.audioId ? (
|
||||
<Input
|
||||
value={editingAudioTitle}
|
||||
onChange={setEditingAudioTitle}
|
||||
onBlur={() => {
|
||||
void api.invoke(ipcChannels.playlists.renameAudio, {
|
||||
audioId: tr.audioId,
|
||||
title: editingAudioTitle,
|
||||
});
|
||||
setEditingAudioId(null);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.trackTitle}
|
||||
onClick={() => {
|
||||
setEditingAudioId(tr.audioId);
|
||||
setEditingAudioTitle(title);
|
||||
}}
|
||||
title={labels.renameAudio}
|
||||
>
|
||||
{title}
|
||||
</button>
|
||||
)}
|
||||
{!readOnly ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
void api.invoke(ipcChannels.playlists.removeTrack, {
|
||||
playlistId: selected.id,
|
||||
trackId: tr.id,
|
||||
})
|
||||
}
|
||||
>
|
||||
{labels.removeTrack}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className={styles.empty}>{labels.emptyPlaylists}</div>
|
||||
)}
|
||||
|
||||
<PlaylistPlayerBar
|
||||
title={currentAudio?.title ?? ''}
|
||||
subtitle={queuePlaylist?.name}
|
||||
audioEl={audioEl}
|
||||
playing={playing}
|
||||
gain={gain}
|
||||
onGainChange={setGain}
|
||||
onTogglePlay={() => {
|
||||
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}
|
||||
/>
|
||||
</div>
|
||||
{!readOnly && pendingDelete
|
||||
? createPortal(
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('common.close')}
|
||||
className={editorStyles.modalBackdrop}
|
||||
onClick={() => setPendingDelete(null)}
|
||||
/>
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
data-testid="playlists-delete-confirm"
|
||||
className={editorStyles.modalDialog}
|
||||
>
|
||||
<div className={editorStyles.modalHeader}>
|
||||
<div className={editorStyles.modalTitle}>{t('playlists.deleteTitle')}</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('common.close')}
|
||||
className={editorStyles.modalClose}
|
||||
onClick={() => setPendingDelete(null)}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<div className={editorStyles.muted}>
|
||||
{t('playlists.deleteConfirm', { name: pendingDelete.name })}
|
||||
</div>
|
||||
<div className={editorStyles.modalFooter}>
|
||||
<Button onClick={() => setPendingDelete(null)}>{t('common.cancel')}</Button>
|
||||
<Button variant="primary" onClick={() => void confirmDelete()}>
|
||||
{t('common.delete')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>,
|
||||
document.body,
|
||||
)
|
||||
: null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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'),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { ipcChannels } from '../../../shared/ipc/contracts';
|
||||
import type { PlaylistsState } from '../../../shared/types/appPlaylists';
|
||||
import { getDndApi } from '../dndApi';
|
||||
|
||||
const EMPTY: PlaylistsState = { playlists: [], audios: [] };
|
||||
|
||||
export function useAppPlaylists(): PlaylistsState {
|
||||
const api = getDndApi();
|
||||
const [state, setState] = useState<PlaylistsState>(EMPTY);
|
||||
|
||||
useEffect(() => {
|
||||
void api.invoke(ipcChannels.playlists.getState, {}).then(setState);
|
||||
return api.on(ipcChannels.playlists.stateChanged, (next) => setState(next));
|
||||
}, [api]);
|
||||
|
||||
return state;
|
||||
}
|
||||
Reference in New Issue
Block a user