feat: add Player/Clipper mode switching with auto-hide UI
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
337
src/App.svelte
337
src/App.svelte
@@ -10,9 +10,11 @@
|
|||||||
import ExportDialog from '$lib/components/ExportDialog.svelte';
|
import ExportDialog from '$lib/components/ExportDialog.svelte';
|
||||||
import AboutDialog from '$lib/components/AboutDialog.svelte';
|
import AboutDialog from '$lib/components/AboutDialog.svelte';
|
||||||
import ProcessingModal from '$lib/components/ProcessingModal.svelte';
|
import ProcessingModal from '$lib/components/ProcessingModal.svelte';
|
||||||
import { loadPreferences, preferences, setClipListPosition, setClipListWidth } from '$lib/stores/preferences.svelte';
|
import PlayerControls from '$lib/components/PlayerControls.svelte';
|
||||||
|
import PlayerTimeline from '$lib/components/PlayerTimeline.svelte';
|
||||||
|
import { loadPreferences, preferences, setClipListPosition, setClipListWidth, setAppMode } from '$lib/stores/preferences.svelte';
|
||||||
import { getSelectedClipId, removeClip } from '$lib/stores/clips.svelte';
|
import { getSelectedClipId, removeClip } from '$lib/stores/clips.svelte';
|
||||||
import { adjustShuttle, resetShuttleRate } from '$lib/transport/playback';
|
import { adjustShuttle, resetShuttleRate, toggleFullscreen } from '$lib/transport/playback';
|
||||||
import { session } from '$lib/stores/videoSession.svelte';
|
import { session } from '$lib/stores/videoSession.svelte';
|
||||||
import { listen } from '@tauri-apps/api/event';
|
import { listen } from '@tauri-apps/api/event';
|
||||||
import type { TransportKeyAction } from '$lib/transport/playback';
|
import type { TransportKeyAction } from '$lib/transport/playback';
|
||||||
@@ -36,6 +38,22 @@
|
|||||||
let sidebarResizeStartWidth = $state(0);
|
let sidebarResizeStartWidth = $state(0);
|
||||||
let windowWidth = $state(window.innerWidth);
|
let windowWidth = $state(window.innerWidth);
|
||||||
|
|
||||||
|
let isPlayerMode = $derived(preferences.appMode === 'player');
|
||||||
|
|
||||||
|
// Auto-hide state for Player mode
|
||||||
|
let showPlayerControls = $state(true);
|
||||||
|
let showToolbar = $state(true);
|
||||||
|
let showPlayerTimeline = $state(false);
|
||||||
|
let controlsHideTimer = $state<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
let toolbarHideTimer = $state<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
// Caption state for PlayerControls
|
||||||
|
let captionsEnabled = $state(true);
|
||||||
|
|
||||||
|
const CONTROLS_HIDE_DELAY = 2500;
|
||||||
|
const TOOLBAR_PROXIMITY = 50;
|
||||||
|
const TIMELINE_PROXIMITY = 80;
|
||||||
|
|
||||||
let isLeft = $derived(preferences.clipListPosition === 'left');
|
let isLeft = $derived(preferences.clipListPosition === 'left');
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
@@ -57,6 +75,20 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (!isPlayerMode) return;
|
||||||
|
if (!session.isPlaying) {
|
||||||
|
// Video paused — show everything
|
||||||
|
showPlayerControls = true;
|
||||||
|
showToolbar = true;
|
||||||
|
if (controlsHideTimer) clearTimeout(controlsHideTimer);
|
||||||
|
if (toolbarHideTimer) clearTimeout(toolbarHideTimer);
|
||||||
|
} else {
|
||||||
|
// Video playing — start hide timers
|
||||||
|
resetControlsTimer();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
function dispatchTransport(action: TransportKeyAction) {
|
function dispatchTransport(action: TransportKeyAction) {
|
||||||
transportControls?.handleKeyAction(action);
|
transportControls?.handleKeyAction(action);
|
||||||
}
|
}
|
||||||
@@ -107,6 +139,57 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resetControlsTimer() {
|
||||||
|
if (controlsHideTimer) clearTimeout(controlsHideTimer);
|
||||||
|
showPlayerControls = true;
|
||||||
|
if (session.isPlaying) {
|
||||||
|
controlsHideTimer = setTimeout(() => {
|
||||||
|
showPlayerControls = false;
|
||||||
|
}, CONTROLS_HIDE_DELAY);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetToolbarTimer() {
|
||||||
|
if (toolbarHideTimer) clearTimeout(toolbarHideTimer);
|
||||||
|
showToolbar = true;
|
||||||
|
if (session.isPlaying) {
|
||||||
|
toolbarHideTimer = setTimeout(() => {
|
||||||
|
showToolbar = false;
|
||||||
|
}, CONTROLS_HIDE_DELAY);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePlayerMouseMove(e: MouseEvent) {
|
||||||
|
if (!isPlayerMode) return;
|
||||||
|
|
||||||
|
// Controls: show on any mouse movement over the video area
|
||||||
|
resetControlsTimer();
|
||||||
|
|
||||||
|
// Toolbar: show when near top edge
|
||||||
|
if (e.clientY < TOOLBAR_PROXIMITY) {
|
||||||
|
resetToolbarTimer();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Timeline: show when near bottom edge
|
||||||
|
const windowH = window.innerHeight;
|
||||||
|
showPlayerTimeline = e.clientY > windowH - TIMELINE_PROXIMITY;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleMode() {
|
||||||
|
const next = preferences.appMode === 'clipper' ? 'player' : 'clipper';
|
||||||
|
setAppMode(next);
|
||||||
|
// When entering Player mode, show controls initially
|
||||||
|
if (next === 'player') {
|
||||||
|
showPlayerControls = true;
|
||||||
|
showToolbar = true;
|
||||||
|
resetControlsTimer();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleToggleCaptions() {
|
||||||
|
captionsEnabled = !captionsEnabled;
|
||||||
|
}
|
||||||
|
|
||||||
function handleGlobalKeydown(e: KeyboardEvent) {
|
function handleGlobalKeydown(e: KeyboardEvent) {
|
||||||
const target = e.target as HTMLElement;
|
const target = e.target as HTMLElement;
|
||||||
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA') return;
|
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA') return;
|
||||||
@@ -132,15 +215,41 @@
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
dispatchTransport(e.shiftKey ? 'seek-forward-1' : 'seek-forward-5');
|
dispatchTransport(e.shiftKey ? 'seek-forward-1' : 'seek-forward-5');
|
||||||
break;
|
break;
|
||||||
|
case 'p':
|
||||||
|
case 'P':
|
||||||
|
e.preventDefault();
|
||||||
|
setAppMode('player');
|
||||||
|
if (preferences.appMode !== 'player') {
|
||||||
|
showPlayerControls = true;
|
||||||
|
showToolbar = true;
|
||||||
|
resetControlsTimer();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'c':
|
||||||
|
case 'C':
|
||||||
|
e.preventDefault();
|
||||||
|
setAppMode('clipper');
|
||||||
|
break;
|
||||||
|
case 'f':
|
||||||
|
case 'F':
|
||||||
|
if (isPlayerMode) {
|
||||||
|
e.preventDefault();
|
||||||
|
toggleFullscreen();
|
||||||
|
}
|
||||||
|
break;
|
||||||
case 'i':
|
case 'i':
|
||||||
case 'I':
|
case 'I':
|
||||||
e.preventDefault();
|
if (!isPlayerMode) {
|
||||||
dispatchTransport('mark-in');
|
e.preventDefault();
|
||||||
|
dispatchTransport('mark-in');
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
case 'o':
|
case 'o':
|
||||||
case 'O':
|
case 'O':
|
||||||
e.preventDefault();
|
if (!isPlayerMode) {
|
||||||
dispatchTransport('mark-out');
|
e.preventDefault();
|
||||||
|
dispatchTransport('mark-out');
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
case 'j':
|
case 'j':
|
||||||
case 'J':
|
case 'J':
|
||||||
@@ -159,9 +268,11 @@
|
|||||||
break;
|
break;
|
||||||
case 'Delete':
|
case 'Delete':
|
||||||
case 'Backspace': {
|
case 'Backspace': {
|
||||||
e.preventDefault();
|
if (!isPlayerMode) {
|
||||||
const selectedId = getSelectedClipId();
|
e.preventDefault();
|
||||||
if (selectedId) removeClip(selectedId);
|
const selectedId = getSelectedClipId();
|
||||||
|
if (selectedId) removeClip(selectedId);
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'a':
|
case 'a':
|
||||||
@@ -170,7 +281,7 @@
|
|||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case 'e':
|
case 'e':
|
||||||
if (e.metaKey) {
|
if (e.metaKey && !isPlayerMode) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
showExportDialog = true;
|
showExportDialog = true;
|
||||||
}
|
}
|
||||||
@@ -192,6 +303,7 @@
|
|||||||
onmousemove={(e) => {
|
onmousemove={(e) => {
|
||||||
handleResizeMove(e);
|
handleResizeMove(e);
|
||||||
handleSidebarResizeMove(e);
|
handleSidebarResizeMove(e);
|
||||||
|
handlePlayerMouseMove(e);
|
||||||
}}
|
}}
|
||||||
onmouseup={() => {
|
onmouseup={() => {
|
||||||
handleResizeEnd();
|
handleResizeEnd();
|
||||||
@@ -222,94 +334,118 @@
|
|||||||
<ProcessingModal />
|
<ProcessingModal />
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<div class="app-shell">
|
<div class="app-shell" class:player-mode={isPlayerMode}>
|
||||||
<header class="toolbar">
|
<header class="toolbar" class:auto-hide={isPlayerMode} class:toolbar-visible={isPlayerMode && showToolbar} class:toolbar-hidden={isPlayerMode && !showToolbar}>
|
||||||
<UrlInput />
|
<UrlInput />
|
||||||
<button
|
<button
|
||||||
class="layout-btn"
|
class="mode-btn"
|
||||||
onclick={toggleClipListPosition}
|
onclick={toggleMode}
|
||||||
title={preferences.clipListPosition === 'bottom'
|
title={isPlayerMode ? 'Switch to Clipper (C)' : 'Switch to Player (P)'}
|
||||||
? 'Move clip list to sidebar'
|
|
||||||
: 'Move clip list to bottom'}
|
|
||||||
>
|
>
|
||||||
{preferences.clipListPosition === 'bottom' ? '⬒' : '⬓'}
|
{isPlayerMode ? 'Player' : 'Clipper'}
|
||||||
</button>
|
</button>
|
||||||
|
{#if !isPlayerMode}
|
||||||
|
<button
|
||||||
|
class="layout-btn"
|
||||||
|
onclick={toggleClipListPosition}
|
||||||
|
title={preferences.clipListPosition === 'bottom'
|
||||||
|
? 'Move clip list to sidebar'
|
||||||
|
: 'Move clip list to bottom'}
|
||||||
|
>
|
||||||
|
{preferences.clipListPosition === 'bottom' ? '⬒' : '⬓'}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
<button class="about-btn" onclick={() => (showAboutDialog = true)} title="About">ℹ</button>
|
<button class="about-btn" onclick={() => (showAboutDialog = true)} title="About">ℹ</button>
|
||||||
<button class="prefs-btn" onclick={() => (showPreferences = true)} title="Preferences">⚙</button>
|
<button class="prefs-btn" onclick={() => (showPreferences = true)} title="Preferences">⚙</button>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main class="content" class:layout-left={isLeft} style:--sidebar-width="{preferences.clipListWidth}px">
|
<main class="content" class:player-mode={isPlayerMode} class:layout-left={!isPlayerMode && isLeft} style:--sidebar-width="{preferences.clipListWidth}px">
|
||||||
<!-- Sidebar clip list: visible only in left mode -->
|
{#if !isPlayerMode}
|
||||||
<div class="sidebar-area" class:hidden={!isLeft}>
|
<!-- Sidebar clip list: visible only in left mode -->
|
||||||
<ClipList onExport={() => (showExportDialog = true)} position="left" />
|
<div class="sidebar-area" class:hidden={!isLeft}>
|
||||||
</div>
|
<ClipList onExport={() => (showExportDialog = true)} position="left" />
|
||||||
<div
|
</div>
|
||||||
class="sidebar-handle"
|
|
||||||
class:hidden={!isLeft}
|
|
||||||
class:active={isSidebarResizing}
|
|
||||||
role="slider"
|
|
||||||
aria-orientation="vertical"
|
|
||||||
aria-label="Resize clip list sidebar"
|
|
||||||
aria-valuenow={preferences.clipListWidth}
|
|
||||||
aria-valuemin={150}
|
|
||||||
aria-valuemax={Math.round(windowWidth * 0.4)}
|
|
||||||
tabindex="0"
|
|
||||||
onmousedown={handleSidebarResizeStart}
|
|
||||||
onkeydown={(e) => {
|
|
||||||
const maxWidth = Math.round(windowWidth * 0.4);
|
|
||||||
const minWidth = Math.min(150, maxWidth);
|
|
||||||
if (e.key === 'ArrowRight') {
|
|
||||||
e.preventDefault();
|
|
||||||
setClipListWidth(
|
|
||||||
Math.max(minWidth, Math.min(maxWidth, preferences.clipListWidth + 10)),
|
|
||||||
);
|
|
||||||
} else if (e.key === 'ArrowLeft') {
|
|
||||||
e.preventDefault();
|
|
||||||
setClipListWidth(
|
|
||||||
Math.max(minWidth, Math.min(maxWidth, preferences.clipListWidth - 10)),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
></div>
|
|
||||||
|
|
||||||
<!-- Video + Transport: always rendered, never destroyed -->
|
|
||||||
<div class="video-area">
|
|
||||||
<VideoPlayer />
|
|
||||||
<TransportControls bind:this={transportControls} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Timeline section: always full width -->
|
|
||||||
<div class="lower-section">
|
|
||||||
<div
|
<div
|
||||||
class="resize-handle"
|
class="sidebar-handle"
|
||||||
class:active={isResizing}
|
class:hidden={!isLeft}
|
||||||
|
class:active={isSidebarResizing}
|
||||||
role="slider"
|
role="slider"
|
||||||
aria-orientation="horizontal"
|
aria-orientation="vertical"
|
||||||
aria-label="Resize timeline"
|
aria-label="Resize clip list sidebar"
|
||||||
aria-valuenow={timelineHeight}
|
aria-valuenow={preferences.clipListWidth}
|
||||||
aria-valuemin={40}
|
aria-valuemin={150}
|
||||||
aria-valuemax={500}
|
aria-valuemax={Math.round(windowWidth * 0.4)}
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
onmousedown={handleResizeStart}
|
onmousedown={handleSidebarResizeStart}
|
||||||
onkeydown={(e) => {
|
onkeydown={(e) => {
|
||||||
if (e.key === 'ArrowDown') {
|
const maxWidth = Math.round(windowWidth * 0.4);
|
||||||
|
const minWidth = Math.min(150, maxWidth);
|
||||||
|
if (e.key === 'ArrowRight') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
timelineHeight = Math.min(500, timelineHeight + 10);
|
setClipListWidth(
|
||||||
} else if (e.key === 'ArrowUp') {
|
Math.max(minWidth, Math.min(maxWidth, preferences.clipListWidth + 10)),
|
||||||
|
);
|
||||||
|
} else if (e.key === 'ArrowLeft') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
timelineHeight = Math.max(40, timelineHeight - 10);
|
setClipListWidth(
|
||||||
|
Math.max(minWidth, Math.min(maxWidth, preferences.clipListWidth - 10)),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
></div>
|
></div>
|
||||||
<div class="timeline-pane" style="height: {timelineHeight}px">
|
{/if}
|
||||||
<Timeline />
|
|
||||||
</div>
|
<!-- Video area: always mounted, never destroyed -->
|
||||||
|
<div class="video-area" class:player-video={isPlayerMode}>
|
||||||
|
<VideoPlayer />
|
||||||
|
{#if isPlayerMode}
|
||||||
|
<PlayerControls
|
||||||
|
visible={showPlayerControls}
|
||||||
|
{captionsEnabled}
|
||||||
|
hasCaptions={session.hasCaptions || false}
|
||||||
|
onToggleCaptions={handleToggleCaptions}
|
||||||
|
onOpenCaptionSettings={() => {}}
|
||||||
|
/>
|
||||||
|
<PlayerTimeline visible={showPlayerTimeline || !session.isPlaying} />
|
||||||
|
{:else}
|
||||||
|
<TransportControls bind:this={transportControls} />
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Bottom clip list: visible only in bottom mode -->
|
{#if !isPlayerMode}
|
||||||
<div class="bottom-cliplist" class:hidden={isLeft}>
|
<!-- Timeline section: always full width -->
|
||||||
<ClipList onExport={() => (showExportDialog = true)} />
|
<div class="lower-section">
|
||||||
</div>
|
<div
|
||||||
|
class="resize-handle"
|
||||||
|
class:active={isResizing}
|
||||||
|
role="slider"
|
||||||
|
aria-orientation="horizontal"
|
||||||
|
aria-label="Resize timeline"
|
||||||
|
aria-valuenow={timelineHeight}
|
||||||
|
aria-valuemin={40}
|
||||||
|
aria-valuemax={500}
|
||||||
|
tabindex="0"
|
||||||
|
onmousedown={handleResizeStart}
|
||||||
|
onkeydown={(e) => {
|
||||||
|
if (e.key === 'ArrowDown') {
|
||||||
|
e.preventDefault();
|
||||||
|
timelineHeight = Math.min(500, timelineHeight + 10);
|
||||||
|
} else if (e.key === 'ArrowUp') {
|
||||||
|
e.preventDefault();
|
||||||
|
timelineHeight = Math.max(40, timelineHeight - 10);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
></div>
|
||||||
|
<div class="timeline-pane" style="height: {timelineHeight}px">
|
||||||
|
<Timeline />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Bottom clip list: visible only in bottom mode -->
|
||||||
|
<div class="bottom-cliplist" class:hidden={isLeft}>
|
||||||
|
<ClipList onExport={() => (showExportDialog = true)} />
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<StatusBar onOpenAbout={() => (showAboutDialog = true)} />
|
<StatusBar onOpenAbout={() => (showAboutDialog = true)} />
|
||||||
@@ -422,6 +558,49 @@
|
|||||||
background: var(--accent, #89b4fa);
|
background: var(--accent, #89b4fa);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* --- Mode toggle button --- */
|
||||||
|
.mode-btn {
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Player mode layout --- */
|
||||||
|
.content.player-mode {
|
||||||
|
grid-template-areas: "video";
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
grid-template-rows: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-video {
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Toolbar auto-hide --- */
|
||||||
|
.toolbar.auto-hide {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
z-index: 30;
|
||||||
|
transition: opacity 0.2s ease, transform 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar.toolbar-hidden {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-100%);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar.toolbar-visible {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
.layout-btn {
|
.layout-btn {
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
padding: 4px 8px;
|
padding: 4px 8px;
|
||||||
|
|||||||
@@ -37,6 +37,11 @@ body {
|
|||||||
height: 100vh;
|
height: 100vh;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Player mode: app shell becomes relative for overlay positioning */
|
||||||
|
.app-shell.player-mode {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
button {
|
button {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
border: none;
|
border: none;
|
||||||
|
|||||||
Reference in New Issue
Block a user