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 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':
if (!isPlayerMode) {
e.preventDefault(); e.preventDefault();
dispatchTransport('mark-in'); dispatchTransport('mark-in');
}
break; break;
case 'o': case 'o':
case 'O': case 'O':
if (!isPlayerMode) {
e.preventDefault(); e.preventDefault();
dispatchTransport('mark-out'); 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': {
if (!isPlayerMode) {
e.preventDefault(); e.preventDefault();
const selectedId = getSelectedClipId(); const selectedId = getSelectedClipId();
if (selectedId) removeClip(selectedId); 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,9 +334,17 @@
<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
class="mode-btn"
onclick={toggleMode}
title={isPlayerMode ? 'Switch to Clipper (C)' : 'Switch to Player (P)'}
>
{isPlayerMode ? 'Player' : 'Clipper'}
</button>
{#if !isPlayerMode}
<button <button
class="layout-btn" class="layout-btn"
onclick={toggleClipListPosition} onclick={toggleClipListPosition}
@@ -234,11 +354,13 @@
> >
{preferences.clipListPosition === 'bottom' ? '⬒' : '⬓'} {preferences.clipListPosition === 'bottom' ? '⬒' : '⬓'}
</button> </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">
{#if !isPlayerMode}
<!-- Sidebar clip list: visible only in left mode --> <!-- Sidebar clip list: visible only in left mode -->
<div class="sidebar-area" class:hidden={!isLeft}> <div class="sidebar-area" class:hidden={!isLeft}>
<ClipList onExport={() => (showExportDialog = true)} position="left" /> <ClipList onExport={() => (showExportDialog = true)} position="left" />
@@ -271,13 +393,26 @@
} }
}} }}
></div> ></div>
{/if}
<!-- Video + Transport: always rendered, never destroyed --> <!-- Video area: always mounted, never destroyed -->
<div class="video-area"> <div class="video-area" class:player-video={isPlayerMode}>
<VideoPlayer /> <VideoPlayer />
{#if isPlayerMode}
<PlayerControls
visible={showPlayerControls}
{captionsEnabled}
hasCaptions={session.hasCaptions || false}
onToggleCaptions={handleToggleCaptions}
onOpenCaptionSettings={() => {}}
/>
<PlayerTimeline visible={showPlayerTimeline || !session.isPlaying} />
{:else}
<TransportControls bind:this={transportControls} /> <TransportControls bind:this={transportControls} />
{/if}
</div> </div>
{#if !isPlayerMode}
<!-- Timeline section: always full width --> <!-- Timeline section: always full width -->
<div class="lower-section"> <div class="lower-section">
<div <div
@@ -310,6 +445,7 @@
<div class="bottom-cliplist" class:hidden={isLeft}> <div class="bottom-cliplist" class:hidden={isLeft}>
<ClipList onExport={() => (showExportDialog = true)} /> <ClipList onExport={() => (showExportDialog = true)} />
</div> </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;

View File

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