mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-04-05 12:12:05 -07:00
Add playlist browser overlay modal (#37)
This commit is contained in:
407
src/renderer/modals/playlist-browser.ts
Normal file
407
src/renderer/modals/playlist-browser.ts
Normal file
@@ -0,0 +1,407 @@
|
||||
import type {
|
||||
PlaylistBrowserDirectoryItem,
|
||||
PlaylistBrowserMutationResult,
|
||||
PlaylistBrowserQueueItem,
|
||||
PlaylistBrowserSnapshot,
|
||||
} from '../../types';
|
||||
import type { ModalStateReader, RendererContext } from '../context';
|
||||
import {
|
||||
renderPlaylistBrowserDirectoryRow,
|
||||
renderPlaylistBrowserPlaylistRow,
|
||||
} from './playlist-browser-renderer.js';
|
||||
|
||||
function clampIndex(index: number, length: number): number {
|
||||
if (length <= 0) return 0;
|
||||
return Math.min(Math.max(index, 0), length - 1);
|
||||
}
|
||||
|
||||
function buildDefaultStatus(snapshot: PlaylistBrowserSnapshot): string {
|
||||
const directoryCount = snapshot.directoryItems.length;
|
||||
const playlistCount = snapshot.playlistItems.length;
|
||||
if (!snapshot.directoryAvailable) {
|
||||
return `${snapshot.directoryStatus} ${playlistCount > 0 ? `· ${playlistCount} queued` : ''}`.trim();
|
||||
}
|
||||
return `${directoryCount} sibling videos · ${playlistCount} queued`;
|
||||
}
|
||||
|
||||
function getDefaultDirectorySelectionIndex(snapshot: PlaylistBrowserSnapshot): number {
|
||||
const directoryIndex = snapshot.directoryItems.findIndex((item) => item.isCurrentFile);
|
||||
return clampIndex(directoryIndex >= 0 ? directoryIndex : 0, snapshot.directoryItems.length);
|
||||
}
|
||||
|
||||
function getDefaultPlaylistSelectionIndex(snapshot: PlaylistBrowserSnapshot): number {
|
||||
const playlistIndex =
|
||||
snapshot.playingIndex ?? snapshot.playlistItems.findIndex((item) => item.current || item.playing);
|
||||
return clampIndex(playlistIndex >= 0 ? playlistIndex : 0, snapshot.playlistItems.length);
|
||||
}
|
||||
|
||||
function resolvePreservedIndex<T>(
|
||||
previousIndex: number,
|
||||
previousItems: T[],
|
||||
nextItems: T[],
|
||||
matchIndex: (previousItem: T) => number,
|
||||
): number {
|
||||
if (nextItems.length <= 0) return 0;
|
||||
if (previousItems.length <= 0) return clampIndex(previousIndex, nextItems.length);
|
||||
|
||||
const normalizedPreviousIndex = clampIndex(previousIndex, previousItems.length);
|
||||
const previousItem = previousItems[normalizedPreviousIndex];
|
||||
const matchedIndex = previousItem ? matchIndex(previousItem) : -1;
|
||||
return clampIndex(matchedIndex >= 0 ? matchedIndex : normalizedPreviousIndex, nextItems.length);
|
||||
}
|
||||
|
||||
function resolveDirectorySelectionIndex(
|
||||
snapshot: PlaylistBrowserSnapshot,
|
||||
previousSnapshot: PlaylistBrowserSnapshot,
|
||||
previousIndex: number,
|
||||
): number {
|
||||
return resolvePreservedIndex(
|
||||
previousIndex,
|
||||
previousSnapshot.directoryItems,
|
||||
snapshot.directoryItems,
|
||||
(previousItem: PlaylistBrowserDirectoryItem) =>
|
||||
snapshot.directoryItems.findIndex((item) => item.path === previousItem.path),
|
||||
);
|
||||
}
|
||||
|
||||
function resolvePlaylistSelectionIndex(
|
||||
snapshot: PlaylistBrowserSnapshot,
|
||||
previousSnapshot: PlaylistBrowserSnapshot,
|
||||
previousIndex: number,
|
||||
): number {
|
||||
return resolvePreservedIndex(
|
||||
previousIndex,
|
||||
previousSnapshot.playlistItems,
|
||||
snapshot.playlistItems,
|
||||
(previousItem: PlaylistBrowserQueueItem) => {
|
||||
if (previousItem.id !== null) {
|
||||
const byId = snapshot.playlistItems.findIndex((item) => item.id === previousItem.id);
|
||||
if (byId >= 0) return byId;
|
||||
}
|
||||
if (previousItem.path) {
|
||||
return snapshot.playlistItems.findIndex((item) => item.path === previousItem.path);
|
||||
}
|
||||
return -1;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function createPlaylistBrowserModal(
|
||||
ctx: RendererContext,
|
||||
options: {
|
||||
modalStateReader: Pick<ModalStateReader, 'isAnyModalOpen'>;
|
||||
syncSettingsModalSubtitleSuppression: () => void;
|
||||
},
|
||||
) {
|
||||
function setStatus(message: string, isError = false): void {
|
||||
ctx.state.playlistBrowserStatus = message;
|
||||
ctx.dom.playlistBrowserStatus.textContent = message;
|
||||
ctx.dom.playlistBrowserStatus.classList.toggle('error', isError);
|
||||
}
|
||||
|
||||
function getSnapshot(): PlaylistBrowserSnapshot | null {
|
||||
return ctx.state.playlistBrowserSnapshot;
|
||||
}
|
||||
|
||||
function resetSnapshotUi(): void {
|
||||
ctx.state.playlistBrowserSnapshot = null;
|
||||
ctx.state.playlistBrowserStatus = '';
|
||||
ctx.state.playlistBrowserSelectedDirectoryIndex = 0;
|
||||
ctx.state.playlistBrowserSelectedPlaylistIndex = 0;
|
||||
ctx.dom.playlistBrowserTitle.textContent = 'Playlist Browser';
|
||||
ctx.dom.playlistBrowserDirectoryList.replaceChildren();
|
||||
ctx.dom.playlistBrowserPlaylistList.replaceChildren();
|
||||
ctx.dom.playlistBrowserStatus.textContent = '';
|
||||
ctx.dom.playlistBrowserStatus.classList.remove('error');
|
||||
}
|
||||
|
||||
function syncSelection(
|
||||
snapshot: PlaylistBrowserSnapshot,
|
||||
previousSnapshot: PlaylistBrowserSnapshot | null,
|
||||
): void {
|
||||
if (!previousSnapshot) {
|
||||
ctx.state.playlistBrowserSelectedDirectoryIndex = getDefaultDirectorySelectionIndex(snapshot);
|
||||
ctx.state.playlistBrowserSelectedPlaylistIndex = getDefaultPlaylistSelectionIndex(snapshot);
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.state.playlistBrowserSelectedDirectoryIndex = resolveDirectorySelectionIndex(
|
||||
snapshot,
|
||||
previousSnapshot,
|
||||
ctx.state.playlistBrowserSelectedDirectoryIndex,
|
||||
);
|
||||
ctx.state.playlistBrowserSelectedPlaylistIndex = resolvePlaylistSelectionIndex(
|
||||
snapshot,
|
||||
previousSnapshot,
|
||||
ctx.state.playlistBrowserSelectedPlaylistIndex,
|
||||
);
|
||||
}
|
||||
|
||||
function render(): void {
|
||||
const snapshot = getSnapshot();
|
||||
if (!snapshot) {
|
||||
ctx.dom.playlistBrowserDirectoryList.replaceChildren();
|
||||
ctx.dom.playlistBrowserPlaylistList.replaceChildren();
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.dom.playlistBrowserTitle.textContent = snapshot.directoryPath ?? 'Playlist Browser';
|
||||
ctx.dom.playlistBrowserStatus.textContent =
|
||||
ctx.state.playlistBrowserStatus || buildDefaultStatus(snapshot);
|
||||
ctx.dom.playlistBrowserDirectoryList.replaceChildren(
|
||||
...snapshot.directoryItems.map((item, index) =>
|
||||
renderPlaylistBrowserDirectoryRow(ctx, item, index, {
|
||||
appendDirectoryItem,
|
||||
movePlaylistItem,
|
||||
playPlaylistItem,
|
||||
removePlaylistItem,
|
||||
render,
|
||||
}),
|
||||
),
|
||||
);
|
||||
ctx.dom.playlistBrowserPlaylistList.replaceChildren(
|
||||
...snapshot.playlistItems.map((item, index) =>
|
||||
renderPlaylistBrowserPlaylistRow(ctx, item, index, {
|
||||
appendDirectoryItem,
|
||||
movePlaylistItem,
|
||||
playPlaylistItem,
|
||||
removePlaylistItem,
|
||||
render,
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function applySnapshot(snapshot: PlaylistBrowserSnapshot): void {
|
||||
const previousSnapshot = ctx.state.playlistBrowserSnapshot;
|
||||
ctx.state.playlistBrowserSnapshot = snapshot;
|
||||
syncSelection(snapshot, previousSnapshot);
|
||||
render();
|
||||
}
|
||||
|
||||
async function refreshSnapshot(): Promise<void> {
|
||||
try {
|
||||
const snapshot = await window.electronAPI.getPlaylistBrowserSnapshot();
|
||||
ctx.state.playlistBrowserStatus = '';
|
||||
applySnapshot(snapshot);
|
||||
setStatus(
|
||||
buildDefaultStatus(snapshot),
|
||||
!snapshot.directoryAvailable && snapshot.directoryStatus.length > 0,
|
||||
);
|
||||
} catch (error) {
|
||||
resetSnapshotUi();
|
||||
setStatus(error instanceof Error ? error.message : String(error), true);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleMutation(
|
||||
action: Promise<PlaylistBrowserMutationResult>,
|
||||
fallbackMessage: string,
|
||||
): Promise<void> {
|
||||
const result = await action;
|
||||
if (!result.ok) {
|
||||
setStatus(result.message, true);
|
||||
return;
|
||||
}
|
||||
setStatus(result.message || fallbackMessage, false);
|
||||
if (result.snapshot) {
|
||||
applySnapshot(result.snapshot);
|
||||
return;
|
||||
}
|
||||
await refreshSnapshot();
|
||||
}
|
||||
|
||||
async function appendDirectoryItem(filePath: string): Promise<void> {
|
||||
await handleMutation(window.electronAPI.appendPlaylistBrowserFile(filePath), 'Queued file');
|
||||
}
|
||||
|
||||
async function playPlaylistItem(index: number): Promise<void> {
|
||||
const result = await window.electronAPI.playPlaylistBrowserIndex(index);
|
||||
if (!result.ok) {
|
||||
setStatus(result.message, true);
|
||||
return;
|
||||
}
|
||||
closePlaylistBrowserModal();
|
||||
}
|
||||
|
||||
async function removePlaylistItem(index: number): Promise<void> {
|
||||
await handleMutation(window.electronAPI.removePlaylistBrowserIndex(index), 'Removed queue item');
|
||||
}
|
||||
|
||||
async function movePlaylistItem(index: number, direction: 1 | -1): Promise<void> {
|
||||
await handleMutation(
|
||||
window.electronAPI.movePlaylistBrowserIndex(index, direction),
|
||||
'Moved queue item',
|
||||
);
|
||||
}
|
||||
|
||||
async function openPlaylistBrowserModal(): Promise<void> {
|
||||
if (ctx.state.playlistBrowserModalOpen) {
|
||||
await refreshSnapshot();
|
||||
return;
|
||||
}
|
||||
if (options.modalStateReader.isAnyModalOpen()) {
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.state.playlistBrowserModalOpen = true;
|
||||
ctx.state.playlistBrowserActivePane = 'playlist';
|
||||
options.syncSettingsModalSubtitleSuppression();
|
||||
ctx.dom.overlay.classList.add('interactive');
|
||||
ctx.dom.playlistBrowserModal.classList.remove('hidden');
|
||||
ctx.dom.playlistBrowserModal.setAttribute('aria-hidden', 'false');
|
||||
window.electronAPI.notifyOverlayModalOpened('playlist-browser');
|
||||
await refreshSnapshot();
|
||||
}
|
||||
|
||||
function closePlaylistBrowserModal(): void {
|
||||
if (!ctx.state.playlistBrowserModalOpen) return;
|
||||
ctx.state.playlistBrowserModalOpen = false;
|
||||
resetSnapshotUi();
|
||||
ctx.dom.playlistBrowserModal.classList.add('hidden');
|
||||
ctx.dom.playlistBrowserModal.setAttribute('aria-hidden', 'true');
|
||||
window.electronAPI.notifyOverlayModalClosed('playlist-browser');
|
||||
options.syncSettingsModalSubtitleSuppression();
|
||||
if (!ctx.state.isOverSubtitle && !options.modalStateReader.isAnyModalOpen()) {
|
||||
ctx.dom.overlay.classList.remove('interactive');
|
||||
}
|
||||
}
|
||||
|
||||
function moveSelection(delta: number): void {
|
||||
const snapshot = getSnapshot();
|
||||
if (!snapshot) return;
|
||||
if (ctx.state.playlistBrowserActivePane === 'directory') {
|
||||
ctx.state.playlistBrowserSelectedDirectoryIndex = clampIndex(
|
||||
ctx.state.playlistBrowserSelectedDirectoryIndex + delta,
|
||||
snapshot.directoryItems.length,
|
||||
);
|
||||
} else {
|
||||
ctx.state.playlistBrowserSelectedPlaylistIndex = clampIndex(
|
||||
ctx.state.playlistBrowserSelectedPlaylistIndex + delta,
|
||||
snapshot.playlistItems.length,
|
||||
);
|
||||
}
|
||||
render();
|
||||
}
|
||||
|
||||
function jumpSelection(target: 'start' | 'end'): void {
|
||||
const snapshot = getSnapshot();
|
||||
if (!snapshot) return;
|
||||
const length =
|
||||
ctx.state.playlistBrowserActivePane === 'directory'
|
||||
? snapshot.directoryItems.length
|
||||
: snapshot.playlistItems.length;
|
||||
const nextIndex = target === 'start' ? 0 : Math.max(0, length - 1);
|
||||
if (ctx.state.playlistBrowserActivePane === 'directory') {
|
||||
ctx.state.playlistBrowserSelectedDirectoryIndex = nextIndex;
|
||||
} else {
|
||||
ctx.state.playlistBrowserSelectedPlaylistIndex = nextIndex;
|
||||
}
|
||||
render();
|
||||
}
|
||||
|
||||
function activateSelection(): void {
|
||||
const snapshot = getSnapshot();
|
||||
if (!snapshot) return;
|
||||
if (ctx.state.playlistBrowserActivePane === 'directory') {
|
||||
const item = snapshot.directoryItems[ctx.state.playlistBrowserSelectedDirectoryIndex];
|
||||
if (item) {
|
||||
void appendDirectoryItem(item.path);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const item = snapshot.playlistItems[ctx.state.playlistBrowserSelectedPlaylistIndex];
|
||||
if (item) {
|
||||
void playPlaylistItem(item.index);
|
||||
}
|
||||
}
|
||||
|
||||
function handlePlaylistBrowserKeydown(event: KeyboardEvent): boolean {
|
||||
if (!ctx.state.playlistBrowserModalOpen) return false;
|
||||
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
closePlaylistBrowserModal();
|
||||
return true;
|
||||
}
|
||||
if (event.key === 'Tab') {
|
||||
event.preventDefault();
|
||||
ctx.state.playlistBrowserActivePane =
|
||||
ctx.state.playlistBrowserActivePane === 'directory' ? 'playlist' : 'directory';
|
||||
render();
|
||||
return true;
|
||||
}
|
||||
if (event.key === 'Home') {
|
||||
event.preventDefault();
|
||||
jumpSelection('start');
|
||||
return true;
|
||||
}
|
||||
if (event.key === 'End') {
|
||||
event.preventDefault();
|
||||
jumpSelection('end');
|
||||
return true;
|
||||
}
|
||||
if (event.key === 'ArrowUp' && (event.ctrlKey || event.metaKey)) {
|
||||
if (ctx.state.playlistBrowserActivePane === 'playlist') {
|
||||
event.preventDefault();
|
||||
const item = getSnapshot()?.playlistItems[ctx.state.playlistBrowserSelectedPlaylistIndex];
|
||||
if (item) {
|
||||
void movePlaylistItem(item.index, -1);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if (event.key === 'ArrowDown' && (event.ctrlKey || event.metaKey)) {
|
||||
if (ctx.state.playlistBrowserActivePane === 'playlist') {
|
||||
event.preventDefault();
|
||||
const item = getSnapshot()?.playlistItems[ctx.state.playlistBrowserSelectedPlaylistIndex];
|
||||
if (item) {
|
||||
void movePlaylistItem(item.index, 1);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if (event.key === 'ArrowUp') {
|
||||
event.preventDefault();
|
||||
moveSelection(-1);
|
||||
return true;
|
||||
}
|
||||
if (event.key === 'ArrowDown') {
|
||||
event.preventDefault();
|
||||
moveSelection(1);
|
||||
return true;
|
||||
}
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
activateSelection();
|
||||
return true;
|
||||
}
|
||||
if (event.key === 'Delete' || event.key === 'Backspace') {
|
||||
if (ctx.state.playlistBrowserActivePane === 'playlist') {
|
||||
event.preventDefault();
|
||||
const item = getSnapshot()?.playlistItems[ctx.state.playlistBrowserSelectedPlaylistIndex];
|
||||
if (item) {
|
||||
void removePlaylistItem(item.index);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function wireDomEvents(): void {
|
||||
ctx.dom.playlistBrowserClose.addEventListener('click', () => {
|
||||
closePlaylistBrowserModal();
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
openPlaylistBrowserModal,
|
||||
closePlaylistBrowserModal,
|
||||
handlePlaylistBrowserKeydown,
|
||||
refreshSnapshot,
|
||||
wireDomEvents,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user