From 60c588ee86cb0d142ec9caef5dcb946b957c9fac Mon Sep 17 00:00:00 2001 From: Sportinger Date: Sat, 2 May 2026 14:23:46 +0200 Subject: [PATCH 1/2] Add browser recent projects menu --- docs/Features/Project-Persistence.md | 32 +- docs/Features/UI-Panels.md | 3 +- src/App.css | 54 +++- src/components/common/Toolbar.tsx | 120 ++++++- src/services/project/ProjectFileService.ts | 71 +++++ .../project/core/NativeProjectCoreService.ts | 7 + .../project/core/ProjectCoreService.ts | 9 + src/services/project/index.ts | 2 + src/services/project/recentProjects.ts | 295 ++++++++++++++++++ src/services/projectFileService.ts | 3 + 10 files changed, 586 insertions(+), 10 deletions(-) create mode 100644 src/services/project/recentProjects.ts diff --git a/docs/Features/Project-Persistence.md b/docs/Features/Project-Persistence.md index 7205f8bb0..1eaa84f7d 100644 --- a/docs/Features/Project-Persistence.md +++ b/docs/Features/Project-Persistence.md @@ -10,6 +10,7 @@ Local project folder storage with continuous save by default, optional interval - [Welcome Overlay](#welcome-overlay) - [Storage Backends](#storage-backends) +- [Recent Projects](#recent-projects) - [Project Folder Structure](#project-folder-structure) - [Auto-Save](#auto-save) - [Backup System](#backup-system) @@ -90,6 +91,24 @@ The `ProjectFileService` facade routes all calls to the active backend: --- +## Recent Projects + +Recent projects are tracked in browser storage and exposed through **File -> Open Recent**. + +- Opening, creating, or renaming a project updates the recent-project list. +- FSA projects store browser `FileSystemDirectoryHandle` references in IndexedDB and keep lightweight metadata in `localStorage`. +- Native Helper projects store normalized project paths in `localStorage`. +- Selecting an FSA recent project re-requests read/write permission if the browser has dropped it. +- Missing or unreadable recent entries are removed when opening them fails. +- The list is capped at 12 entries and can be cleared from the Open Recent flyout. + +Implementation: +- `src/services/project/recentProjects.ts` stores and normalizes recent metadata. +- `ProjectFileService.openRecentProject()` routes a selected entry to the FSA or Native backend. +- `Toolbar.tsx` renders the File menu flyout and listens for recent-project updates. + +--- + ## Project Folder Structure Projects are stored in a local folder you choose: @@ -396,6 +415,13 @@ Temporary camera `NO KF` live offsets are intentionally not saved. They only aff - Or File menu -> Open Project (`Ctrl+O`) - Select folder containing `project.json` +### Open Recent +- File menu -> Open Recent +- Shows projects remembered by the browser from previous create/open/rename actions +- FSA entries reuse stored IndexedDB handles and may ask for folder permission again +- Native Helper entries reopen by stored path +- The flyout includes "Clear Recent Projects" for clearing the browser-side list + ### Rename Project - Double-click the project name in the toolbar - Validates name (no special characters `<>:"/\|?*`) @@ -484,8 +510,8 @@ If IndexedDB storage becomes corrupted, an error dialog appears automatically: | Storage | Used For | Limits | |---------|----------|--------| | **Project Folder** | Project data, proxies, analysis, transcripts, cache, renders | Disk space | -| **IndexedDB** | File handles, media metadata, proxy frames (legacy), analysis cache, thumbnails | ~50MB | -| **localStorage** | App settings, autosave config, named/default dock layouts, dock layout fallback, Native Helper last project path | ~5MB | +| **IndexedDB** | File handles, recent FSA project handles, media metadata, proxy frames (legacy), analysis cache, thumbnails | ~50MB | +| **localStorage** | App settings, autosave config, named/default dock layouts, dock layout fallback, recent project metadata, Native Helper project paths | ~5MB | --- @@ -495,6 +521,7 @@ If IndexedDB storage becomes corrupted, an error dialog appears automatically: ``` src/services/project/ +-- ProjectFileService.ts # Facade -- routes to FSA or Native backend ++-- recentProjects.ts # Browser-side recent project registry +-- projectSave.ts # Store -> project format conversion + save +-- projectLoad.ts # Project format -> store conversion + load +-- projectLifecycle.ts # Create/open/close + auto-sync subscriptions @@ -523,6 +550,7 @@ src/services/project/ | Service | File | Purpose | |---------|------|---------| | ProjectDB | `src/services/projectDB.ts` | IndexedDB for handles, media, proxies, analysis, thumbnails | +| RecentProjects | `src/services/project/recentProjects.ts` | Recent project metadata plus FSA handle keys | | FileSystemService | `src/services/fileSystemService.ts` | File picker, handle cache, permission management | | NativeHelperClient | `src/services/nativeHelper/NativeHelperClient.ts` | WebSocket + HTTP client for Native Helper | diff --git a/docs/Features/UI-Panels.md b/docs/Features/UI-Panels.md index 4fdf5afd4..b5c3b17f1 100644 --- a/docs/Features/UI-Panels.md +++ b/docs/Features/UI-Panels.md @@ -29,7 +29,7 @@ Dockable desktop panel system with an After Effects-style menu bar, unified clip | Menu | Contents | |------|----------| -| **File** | New Project, Open Project, Save, Save As, Project Info, Autosave, Clear All Cache and Reload | +| **File** | New Project, Open Project, Open Recent, Save, Save As, Project Info, Autosave, Clear All Cache and Reload | | **Edit** | Copy, Paste, Settings | | **View** | Panels submenu, Layouts submenu | | **Output** | New Output Window, Open Output Manager, Active Outputs | @@ -54,6 +54,7 @@ Dockable desktop panel system with an After Effects-style menu bar, unified clip - **New Project** prompts for a project name and folder - **Open Project** opens an existing project folder +- **Open Recent** shows browser-remembered projects and can clear that recent list - **Save / Save As** follow the folder-based project model - **Autosave** still exposes enable/disable plus 1, 2, 5, and 10 minute intervals for interval-save mode - **Save Mode** itself lives in Settings -> General, and the default branch behavior is continuous save with a short debounce after changes diff --git a/src/App.css b/src/App.css index d46e38226..fe964845f 100644 --- a/src/App.css +++ b/src/App.css @@ -1114,6 +1114,45 @@ input, textarea { min-width: 240px; } +.menu-nested-submenu-recent { + min-width: 300px; + max-width: 420px; +} + +.menu-option-recent { + align-items: flex-start; + gap: 16px; +} + +.menu-recent-text { + display: flex; + min-width: 0; + flex: 1; + flex-direction: column; + gap: 2px; +} + +.menu-recent-name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.menu-recent-meta { + overflow: hidden; + color: var(--text-muted); + font-size: var(--font-xs); + text-overflow: ellipsis; + white-space: nowrap; +} + +.menu-recent-kind { + flex: 0 0 auto; + color: var(--text-muted); + font-size: var(--font-xs); + line-height: 18px; +} + .menu-layout-row { display: flex; align-items: stretch; @@ -6093,11 +6132,14 @@ input[type="checkbox"] { to { opacity: 1; transform: translateY(0); } } -.phase-exiting > .track-header, -.phase-exiting > .track-lane { +.phase-exiting > .track-header { animation: track-exit 200ms ease-in forwards; } +.phase-exiting > .track-lane { + animation: none; +} + .phase-entering > .track-header, .phase-entering > .track-lane { animation: track-enter 200ms ease-out; @@ -6596,15 +6638,15 @@ input[type="checkbox"] { } } -/* Clip exit animation - slides out to the right */ +/* Clip exit animation - flies out to the left without fading */ @keyframes clip-exit { from { opacity: 1; transform: translate3d(0, 0, 0); } to { - opacity: 0; - transform: translate3d(25px, 0, 0); + opacity: 1; + transform: translate3d(calc(-140vw - 100%), 0, 0); } } @@ -6613,7 +6655,7 @@ input[type="checkbox"] { } .timeline-clip.exit-animate { - animation: clip-exit 0.35s cubic-bezier(0.55, 0, 1, 0.45) forwards; + animation: clip-exit 0.35s cubic-bezier(0.7, 0, 0.84, 0) forwards; } .clip-loading-spinner { diff --git a/src/components/common/Toolbar.tsx b/src/components/common/Toolbar.tsx index 2f252a4d0..9aa7545f5 100644 --- a/src/components/common/Toolbar.tsx +++ b/src/components/common/Toolbar.tsx @@ -17,7 +17,11 @@ import { InfoDialog } from './InfoDialog'; import { LegalDialog } from './LegalDialog'; import type { LegalPage } from './LegalDialog'; import { NativeHelperStatus } from './NativeHelperStatus'; -import { projectFileService } from '../../services/projectFileService'; +import { + RECENT_PROJECTS_CHANGED_EVENT, + projectFileService, + type RecentProjectEntry, +} from '../../services/projectFileService'; import { getShortcutRegistry } from '../../services/shortcutRegistry'; import { useMediaStore } from '../../stores/mediaStore'; import { @@ -48,6 +52,19 @@ interface ToolbarProps { onOpenSplash?: () => void; } +function formatRecentProjectDate(timestamp: number): string { + if (!Number.isFinite(timestamp)) { + return ''; + } + + return new Date(timestamp).toLocaleDateString(undefined, { + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }); +} + export function Toolbar({ onOpenChangelog, onOpenSplash }: ToolbarProps) { const { isEngineReady, createOutputWindow } = useEngine(); const targets = useRenderTargetStore((s) => s.targets); @@ -112,6 +129,7 @@ export function Toolbar({ onOpenChangelog, onOpenSplash }: ToolbarProps) { const [showInfoDialog, setShowInfoDialog] = useState(false); const [showLegalDialog, setShowLegalDialog] = useState(null); const [renameError, setRenameError] = useState(null); + const [recentProjects, setRecentProjects] = useState([]); const menuBarRef = useRef(null); const autosaveTimerRef = useRef(null); const isRenamingRef = useRef(false); @@ -138,6 +156,20 @@ export function Toolbar({ onOpenChangelog, onOpenSplash }: ToolbarProps) { return () => clearInterval(interval); }, []); + useEffect(() => { + const refreshRecentProjects = () => { + setRecentProjects(projectFileService.getRecentProjects()); + }; + + refreshRecentProjects(); + window.addEventListener(RECENT_PROJECTS_CHANGED_EVENT, refreshRecentProjects); + window.addEventListener('storage', refreshRecentProjects); + return () => { + window.removeEventListener(RECENT_PROJECTS_CHANGED_EVENT, refreshRecentProjects); + window.removeEventListener('storage', refreshRecentProjects); + }; + }, []); + // Try to restore last project on mount useEffect(() => { const restoreProject = async () => { @@ -285,6 +317,54 @@ export function Toolbar({ onOpenChangelog, onOpenSplash }: ToolbarProps) { setOpenMenu(null); }, []); + const handleOpenRecent = useCallback(async (projectId: string) => { + if (projectFileService.hasUnsavedChanges()) { + if (!confirm('You have unsaved changes. Open a different project?')) { + return; + } + } + + setIsLoading(true); + setProjectLoadProgress({ + phase: 'opening', + percent: 3, + message: 'Opening recent project', + blocking: true, + }); + + try { + const success = await projectFileService.openRecentProject(projectId); + if (!success) { + setProjectLoadProgress(null); + window.alert('Could not open that recent project. It may have moved, or the browser may need permission again.'); + return; + } + + await loadProjectToStores(); + const data = projectFileService.getProjectData(); + if (data) { + setProjectName(data.name); + setIsProjectOpen(true); + setNeedsPermission(false); + setPendingProjectName(null); + } + } catch (error) { + log.error('Failed to open recent project', error); + setProjectLoadProgress(null); + window.alert('Could not open that recent project.'); + } finally { + setRecentProjects(projectFileService.getRecentProjects()); + setIsLoading(false); + setOpenMenu(null); + } + }, []); + + const handleClearRecentProjects = useCallback(async () => { + await projectFileService.clearRecentProjects(); + setRecentProjects([]); + setOpenMenu(null); + }, []); + const handleNameSubmit = useCallback(async () => { // Prevent double-call from Enter + blur if (isRenamingRef.current) return; @@ -559,6 +639,44 @@ export function Toolbar({ onOpenChangelog, onOpenSplash }: ToolbarProps) { Open Project... {shortcutLabels.open} +
+ +
+ {recentProjects.length === 0 ? ( + No recent projects + ) : ( + <> + {recentProjects.map((project) => { + const meta = formatRecentProjectDate(project.lastOpenedAt); + const title = project.path || project.name; + return ( + + ); + })} +
+ + + )} +
+
)} - {tracks.map((track) => { + {timelineViewTracks.map((track) => { const isDimmed = - (track.type === 'video' && anyVideoSolo && !track.solo) || - (track.type === 'audio' && anyAudioSolo && !track.solo); - const isExpanded = isTrackExpanded(track.id); - const dynamicHeight = getExpandedTrackHeight(track.id, track.height); + (track.type === 'video' && anyViewVideoSolo && !track.solo) || + (track.type === 'audio' && anyViewAudioSolo && !track.solo); + const isExpanded = !isCompositionTrackMorphing && isTrackExpanded(track.id); + const dynamicHeight = isExpanded ? getExpandedTrackHeight(track.id, track.height) : track.height; return ( toggleTrackExpanded(track.id)} onToggleSolo={() => @@ -1180,18 +1271,18 @@ export function Timeline() {
)} - {tracks.map((track) => { + {timelineViewTracks.map((track) => { const isDimmed = - (track.type === 'video' && anyVideoSolo && !track.solo) || - (track.type === 'audio' && anyAudioSolo && !track.solo); - const isExpanded = isTrackExpanded(track.id); - const dynamicHeight = getExpandedTrackHeight(track.id, track.height); + (track.type === 'video' && anyViewVideoSolo && !track.solo) || + (track.type === 'audio' && anyViewAudioSolo && !track.solo); + const isExpanded = !isCompositionTrackMorphing && isTrackExpanded(track.id); + const dynamicHeight = isExpanded ? getExpandedTrackHeight(track.id, track.height) : track.height; return ( + {isCompositionTrackMorphing && ( +
+ {tracks.map((track) => { + const isExpanded = isTrackExpanded(track.id); + const dynamicHeight = isExpanded ? getExpandedTrackHeight(track.id, track.height) : track.height; + const trackClips = clips.filter((clip) => clip.trackId === track.id); - + return ( +
+
+ {trackClips.map((clip) => renderClip(clip, track.id))} +
+
+ ); + })} +
+ )} + + {!isCompositionTrackMorphing && ( + + )} + + {!isCompositionTrackMorphing && ( + + )} {/* New video track preview for linked audio-to-video */} {/* When hovering audio track, show linked video preview as new track */} @@ -1330,6 +1447,7 @@ export function Timeline() { duration={duration} markerDrag={markerDrag} onMarkerMouseDown={handleMarkerMouseDown} + switchMotionClass={timelineSwitchMotionClass} clipDrag={clipDrag} isRamPreviewing={effectiveIsRamPreviewing} ramPreviewProgress={effectiveRamPreviewProgress} @@ -1354,15 +1472,17 @@ export function Timeline() { /> )} - + {!isCompositionTrackMorphing && ( + + )} {/* track-lanes-scroll */} @@ -1373,7 +1493,7 @@ export function Timeline() { {/* Playhead - spans from ruler through all tracks */} {showPlayhead && (
(
s.clipAnimationPhase); + const compositionSwitchDirection = useTimelineStore(s => s.compositionSwitchDirection); const clipEntranceKey = useTimelineStore(s => s.clipEntranceAnimationKey); const [mountEntranceKey] = useState(clipEntranceKey); @@ -224,10 +225,16 @@ function TimelineClipComponent({ // - 'entering' + new clips: apply entrance animation (only during composition switch) // - Otherwise: no animation const isNewClip = mountEntranceKey === clipEntranceKey && clipEntranceKey > 0; + const exitAnimationClass = compositionSwitchDirection === 'backward' + ? 'exit-animate exit-animate-left' + : 'exit-animate exit-animate-right'; + const entranceAnimationClass = compositionSwitchDirection === 'backward' + ? 'entrance-animate entrance-animate-right' + : 'entrance-animate entrance-animate-left'; const animationClass = clipAnimationPhase === 'exiting' - ? 'exit-animate' + ? exitAnimationClass : (clipAnimationPhase === 'entering' && isNewClip) - ? 'entrance-animate' + ? entranceAnimationClass : ''; // AI move animation (FLIP technique) diff --git a/src/components/timeline/components/TimelineOverlays.tsx b/src/components/timeline/components/TimelineOverlays.tsx index 329b24cd0..3618413d2 100644 --- a/src/components/timeline/components/TimelineOverlays.tsx +++ b/src/components/timeline/components/TimelineOverlays.tsx @@ -14,6 +14,7 @@ interface TimelineOverlaysProps { duration: number; markerDrag: { type: 'in' | 'out' } | null; onMarkerMouseDown: (e: React.MouseEvent, type: 'in' | 'out') => void; + switchMotionClass?: string; // Clip drag clipDrag: ClipDragState | null; @@ -41,6 +42,7 @@ export function TimelineOverlays({ duration, markerDrag, onMarkerMouseDown, + switchMotionClass = '', clipDrag, isRamPreviewing, ramPreviewProgress, @@ -157,7 +159,7 @@ export function TimelineOverlays({ {/* In marker */} {inPoint !== null && (
@@ -174,7 +176,7 @@ export function TimelineOverlays({ {/* Out marker */} {outPoint !== null && (
diff --git a/src/stores/mediaStore/slices/compositionSlice.ts b/src/stores/mediaStore/slices/compositionSlice.ts index c76533b75..5257a55fa 100644 --- a/src/stores/mediaStore/slices/compositionSlice.ts +++ b/src/stores/mediaStore/slices/compositionSlice.ts @@ -715,6 +715,21 @@ function calculateSyncedPlayhead( return null; } +function getCompositionSwitchDirection( + currentActiveId: string | null, + newId: string | null, + openCompositionIds: string[] +): 'forward' | 'backward' { + const currentIndex = currentActiveId ? openCompositionIds.indexOf(currentActiveId) : -1; + const nextIndex = newId ? openCompositionIds.indexOf(newId) : -1; + + if (currentIndex !== -1 && nextIndex !== -1 && nextIndex > currentIndex) { + return 'backward'; + } + + return 'forward'; +} + /** * Internal helper to set active composition (avoids calling get().setActiveComposition). * Handles exit/enter animations for smooth transitions. @@ -756,22 +771,31 @@ function doSetActiveComposition( if (skipAnimation) { // Skip exit/enter animations entirely + timelineStore.setCompositionSwitchTargetTracks(null); finishCompositionSwitch(set, get, newId, savedCompId, syncedPlayhead, options); return; } + timelineStore.setCompositionSwitchDirection( + getCompositionSwitchDirection(currentActiveId, newId, get().openCompositionIds) + ); + // Trigger exit animation for current clips const hasExistingClips = timelineStore.clips.length > 0; if (hasExistingClips && newId !== currentActiveId) { + const targetComp = newId ? compositions.find((c) => c.id === newId) : null; + timelineStore.setCompositionSwitchTargetTracks(targetComp?.timelineData?.tracks ?? null); + // Set exit animation phase timelineStore.setClipAnimationPhase('exiting'); // Wait for exit animation, then load new composition setTimeout(async () => { await finishCompositionSwitch(set, get, newId, savedCompId, syncedPlayhead, options); - }, 350); // Exit animation duration + }, 175); // Exit animation duration } else { // No existing clips or same comp, load immediately + timelineStore.setCompositionSwitchTargetTracks(null); finishCompositionSwitch(set, get, newId, savedCompId, syncedPlayhead, options); } } @@ -825,10 +849,12 @@ async function finishCompositionSwitch( // Reset to idle after entrance animation completes setTimeout(() => { timelineStore.setClipAnimationPhase('idle'); - }, 700); // Entrance animation duration (0.6s + buffer) + timelineStore.setCompositionSwitchTargetTracks(null); + }, 350); // Entrance animation duration (0.3s + buffer) } } else { timelineStore.clearTimeline(); + timelineStore.setCompositionSwitchTargetTracks(null); timelineStore.setClipAnimationPhase('idle'); } } diff --git a/src/stores/timeline/index.ts b/src/stores/timeline/index.ts index 330f3de6d..344d318b6 100644 --- a/src/stores/timeline/index.ts +++ b/src/stores/timeline/index.ts @@ -206,6 +206,12 @@ export const useTimelineStore = create()( // Clip animation phase for enter/exit transitions clipAnimationPhase: 'idle' as const, + // Composition switch direction derived from tab positions + compositionSwitchDirection: 'forward' as const, + + // Target track layout shown during composition switch exit + compositionSwitchTargetTracks: null, + // Slot grid view progress (0 = full timeline, 1 = full grid view) slotGridProgress: 0, diff --git a/src/stores/timeline/playbackSlice.ts b/src/stores/timeline/playbackSlice.ts index 7f2e28949..8866e9f8a 100644 --- a/src/stores/timeline/playbackSlice.ts +++ b/src/stores/timeline/playbackSlice.ts @@ -320,6 +320,14 @@ export const createPlaybackSlice: SliceCreator = (set, get) => set({ clipAnimationPhase: phase }); }, + setCompositionSwitchDirection: (direction) => { + set({ compositionSwitchDirection: direction }); + }, + + setCompositionSwitchTargetTracks: (tracks) => { + set({ compositionSwitchTargetTracks: tracks ? tracks.map((track) => ({ ...track })) : null }); + }, + // Slot grid view progress setSlotGridProgress: (progress: number) => { set({ slotGridProgress: Math.max(0, Math.min(1, progress)) }); diff --git a/src/stores/timeline/types.ts b/src/stores/timeline/types.ts index 52f66022d..426bbd8da 100644 --- a/src/stores/timeline/types.ts +++ b/src/stores/timeline/types.ts @@ -173,6 +173,12 @@ export interface TimelineState { // Clip animation phase for enter/exit transitions clipAnimationPhase: 'idle' | 'exiting' | 'entering'; + // Direction derived from composition tab positions + compositionSwitchDirection: 'forward' | 'backward'; + + // Target track layout shown while old clips exit during a composition switch + compositionSwitchTargetTracks: TimelineTrack[] | null; + // Slot grid view progress (0 = full timeline, 1 = full grid view) slotGridProgress: number; @@ -329,6 +335,8 @@ export interface PlaybackActions { toggleCutTool: () => void; // Clip animation phase for composition transitions setClipAnimationPhase: (phase: 'idle' | 'exiting' | 'entering') => void; + setCompositionSwitchDirection: (direction: 'forward' | 'backward') => void; + setCompositionSwitchTargetTracks: (tracks: TimelineTrack[] | null) => void; // Slot grid view setSlotGridProgress: (progress: number) => void; // Performance toggles diff --git a/src/version.ts b/src/version.ts index 92cd2536e..a0b714386 100644 --- a/src/version.ts +++ b/src/version.ts @@ -1,6 +1,6 @@ // App version // Format: MAJOR.MINOR.PATCH -export const APP_VERSION = '1.7.2'; +export const APP_VERSION = '1.7.3'; export interface ChangelogNotice { type: 'info' | 'warning' | 'success' | 'danger'; @@ -36,15 +36,15 @@ export const FEATURED_VIDEO: { // Build/Platform notice shown at top of changelog (set to null to hide) export const BUILD_NOTICE: ChangelogNotice | null = { type: 'success', - title: 'Lottie state keyframes are live', - message: 'Lottie clips now support bounce playback, render resolution overrides, and timeline-native state machine keyframes.', + title: 'Composition tab switching upgraded', + message: 'Timeline comps now switch with directional clip motion, matching playhead and marker motion, and animated layer-height changes.', animated: true, }; export const WIP_NOTICE: ChangelogNotice | null = { type: 'info', - title: 'Vector animation upgraded', - message: 'State changes show as blue stepped keyframes, while boolean and numeric state-machine inputs use the normal keyframe lanes.', + title: 'Recent projects are easier to reopen', + message: 'The File menu now shows recent browser and native projects for quicker access.', animated: true, };