feat: add Player/Clipper mode switching with auto-hide UI

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-22 19:08:25 -04:00
parent 56686a36e6
commit d36138c550
2 changed files with 263 additions and 79 deletions

View File

@@ -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;

View File

@@ -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;