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 AboutDialog from '$lib/components/AboutDialog.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 { adjustShuttle, resetShuttleRate } from '$lib/transport/playback';
|
||||
import { adjustShuttle, resetShuttleRate, toggleFullscreen } from '$lib/transport/playback';
|
||||
import { session } from '$lib/stores/videoSession.svelte';
|
||||
import { listen } from '@tauri-apps/api/event';
|
||||
import type { TransportKeyAction } from '$lib/transport/playback';
|
||||
@@ -36,6 +38,22 @@
|
||||
let sidebarResizeStartWidth = $state(0);
|
||||
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');
|
||||
|
||||
$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) {
|
||||
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) {
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA') return;
|
||||
@@ -132,15 +215,41 @@
|
||||
e.preventDefault();
|
||||
dispatchTransport(e.shiftKey ? 'seek-forward-1' : 'seek-forward-5');
|
||||
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':
|
||||
e.preventDefault();
|
||||
dispatchTransport('mark-in');
|
||||
if (!isPlayerMode) {
|
||||
e.preventDefault();
|
||||
dispatchTransport('mark-in');
|
||||
}
|
||||
break;
|
||||
case 'o':
|
||||
case 'O':
|
||||
e.preventDefault();
|
||||
dispatchTransport('mark-out');
|
||||
if (!isPlayerMode) {
|
||||
e.preventDefault();
|
||||
dispatchTransport('mark-out');
|
||||
}
|
||||
break;
|
||||
case 'j':
|
||||
case 'J':
|
||||
@@ -159,9 +268,11 @@
|
||||
break;
|
||||
case 'Delete':
|
||||
case 'Backspace': {
|
||||
e.preventDefault();
|
||||
const selectedId = getSelectedClipId();
|
||||
if (selectedId) removeClip(selectedId);
|
||||
if (!isPlayerMode) {
|
||||
e.preventDefault();
|
||||
const selectedId = getSelectedClipId();
|
||||
if (selectedId) removeClip(selectedId);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'a':
|
||||
@@ -170,7 +281,7 @@
|
||||
}
|
||||
break;
|
||||
case 'e':
|
||||
if (e.metaKey) {
|
||||
if (e.metaKey && !isPlayerMode) {
|
||||
e.preventDefault();
|
||||
showExportDialog = true;
|
||||
}
|
||||
@@ -192,6 +303,7 @@
|
||||
onmousemove={(e) => {
|
||||
handleResizeMove(e);
|
||||
handleSidebarResizeMove(e);
|
||||
handlePlayerMouseMove(e);
|
||||
}}
|
||||
onmouseup={() => {
|
||||
handleResizeEnd();
|
||||
@@ -222,94 +334,118 @@
|
||||
<ProcessingModal />
|
||||
{/if}
|
||||
|
||||
<div class="app-shell">
|
||||
<header class="toolbar">
|
||||
<div class="app-shell" class:player-mode={isPlayerMode}>
|
||||
<header class="toolbar" class:auto-hide={isPlayerMode} class:toolbar-visible={isPlayerMode && showToolbar} class:toolbar-hidden={isPlayerMode && !showToolbar}>
|
||||
<UrlInput />
|
||||
<button
|
||||
class="layout-btn"
|
||||
onclick={toggleClipListPosition}
|
||||
title={preferences.clipListPosition === 'bottom'
|
||||
? 'Move clip list to sidebar'
|
||||
: 'Move clip list to bottom'}
|
||||
class="mode-btn"
|
||||
onclick={toggleMode}
|
||||
title={isPlayerMode ? 'Switch to Clipper (C)' : 'Switch to Player (P)'}
|
||||
>
|
||||
{preferences.clipListPosition === 'bottom' ? '⬒' : '⬓'}
|
||||
{isPlayerMode ? 'Player' : 'Clipper'}
|
||||
</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="prefs-btn" onclick={() => (showPreferences = true)} title="Preferences">⚙</button>
|
||||
</header>
|
||||
|
||||
<main class="content" class:layout-left={isLeft} style:--sidebar-width="{preferences.clipListWidth}px">
|
||||
<!-- Sidebar clip list: visible only in left mode -->
|
||||
<div class="sidebar-area" class:hidden={!isLeft}>
|
||||
<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">
|
||||
<main class="content" class:player-mode={isPlayerMode} class:layout-left={!isPlayerMode && isLeft} style:--sidebar-width="{preferences.clipListWidth}px">
|
||||
{#if !isPlayerMode}
|
||||
<!-- Sidebar clip list: visible only in left mode -->
|
||||
<div class="sidebar-area" class:hidden={!isLeft}>
|
||||
<ClipList onExport={() => (showExportDialog = true)} position="left" />
|
||||
</div>
|
||||
<div
|
||||
class="resize-handle"
|
||||
class:active={isResizing}
|
||||
class="sidebar-handle"
|
||||
class:hidden={!isLeft}
|
||||
class:active={isSidebarResizing}
|
||||
role="slider"
|
||||
aria-orientation="horizontal"
|
||||
aria-label="Resize timeline"
|
||||
aria-valuenow={timelineHeight}
|
||||
aria-valuemin={40}
|
||||
aria-valuemax={500}
|
||||
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={handleResizeStart}
|
||||
onmousedown={handleSidebarResizeStart}
|
||||
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();
|
||||
timelineHeight = Math.min(500, timelineHeight + 10);
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
setClipListWidth(
|
||||
Math.max(minWidth, Math.min(maxWidth, preferences.clipListWidth + 10)),
|
||||
);
|
||||
} else if (e.key === 'ArrowLeft') {
|
||||
e.preventDefault();
|
||||
timelineHeight = Math.max(40, timelineHeight - 10);
|
||||
setClipListWidth(
|
||||
Math.max(minWidth, Math.min(maxWidth, preferences.clipListWidth - 10)),
|
||||
);
|
||||
}
|
||||
}}
|
||||
></div>
|
||||
<div class="timeline-pane" style="height: {timelineHeight}px">
|
||||
<Timeline />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- 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>
|
||||
|
||||
<!-- Bottom clip list: visible only in bottom mode -->
|
||||
<div class="bottom-cliplist" class:hidden={isLeft}>
|
||||
<ClipList onExport={() => (showExportDialog = true)} />
|
||||
</div>
|
||||
{#if !isPlayerMode}
|
||||
<!-- Timeline section: always full width -->
|
||||
<div class="lower-section">
|
||||
<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>
|
||||
|
||||
<StatusBar onOpenAbout={() => (showAboutDialog = true)} />
|
||||
@@ -422,6 +558,49 @@
|
||||
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 {
|
||||
font-size: 16px;
|
||||
padding: 4px 8px;
|
||||
|
||||
@@ -37,6 +37,11 @@ body {
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
/* Player mode: app shell becomes relative for overlay positioning */
|
||||
.app-shell.player-mode {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
button {
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
|
||||
Reference in New Issue
Block a user