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..9e7a4be5d 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; @@ -6068,11 +6107,11 @@ input[type="checkbox"] { overflow: hidden; height: 30px; border-bottom: 1px solid var(--border-color); - transition: opacity 200ms ease; + transition: none; } .time-ruler-wrapper.comp-switching { - opacity: 0; + opacity: 1; } .timeline-scroll-wrapper { @@ -6081,40 +6120,15 @@ input[type="checkbox"] { position: relative; } -/* Composition switch: track exit animation (slide up + fade out) */ -@keyframes track-exit { - from { opacity: 1; transform: translateY(0); } - to { opacity: 0; transform: translateY(-12px); } -} - -/* Composition switch: track enter animation (slide down + fade in) */ -@keyframes track-enter { - from { opacity: 0; transform: translateY(-12px); } - to { opacity: 1; transform: translateY(0); } -} - +/* Composition switch: track rows stay visible while their labels/heights update. */ .phase-exiting > .track-header, -.phase-exiting > .track-lane { - animation: track-exit 200ms ease-in forwards; -} - +.phase-exiting > .track-lane, .phase-entering > .track-header, .phase-entering > .track-lane { - animation: track-enter 200ms ease-out; + animation: none; + transition-duration: var(--composition-switch-layout-duration, 475ms), 160ms, 160ms; } -/* Stagger tracks by index */ -.phase-exiting > :nth-child(1), -.phase-entering > :nth-child(1) { animation-delay: 0ms; } -.phase-exiting > :nth-child(2), -.phase-entering > :nth-child(2) { animation-delay: 30ms; } -.phase-exiting > :nth-child(3), -.phase-entering > :nth-child(3) { animation-delay: 60ms; } -.phase-exiting > :nth-child(4), -.phase-entering > :nth-child(4) { animation-delay: 90ms; } -.phase-exiting > :nth-child(5), -.phase-entering > :nth-child(5) { animation-delay: 120ms; } - /* Content row - track headers and lanes side by side */ .timeline-content-row { display: flex; @@ -6150,6 +6164,8 @@ input[type="checkbox"] { border-bottom: 1px solid var(--border-color); background: var(--bg-tertiary); position: relative; + overflow: hidden; + transition: height 220ms cubic-bezier(0.22, 1, 0.36, 1), background 160ms ease, border-color 160ms ease; } .track-header.video { @@ -6414,6 +6430,7 @@ input[type="checkbox"] { background: var(--bg-secondary); min-width: 100%; overflow: hidden; + transition: height 220ms cubic-bezier(0.22, 1, 0.36, 1), background 160ms ease, border-color 160ms ease; } .track-lane.video { @@ -6436,6 +6453,22 @@ input[type="checkbox"] { ); } +.composition-exit-clips-overlay { + position: absolute; + top: 0; + left: 0; + min-width: inherit; + width: 100%; + pointer-events: none; + z-index: 12; +} + +.composition-exit-track-row { + position: relative; + width: 100%; + overflow: visible; +} + /* Timeline clips */ .timeline-clip { position: absolute; @@ -6523,11 +6556,14 @@ input[type="checkbox"] { background: rgba(40, 60, 90, 0.85) !important; border: 2px dashed var(--accent) !important; box-shadow: none !important; - /* Override global .loading class which sets transform/display for centered spinners */ - transform: none !important; display: block; } +.timeline-clip.loading:not(.entrance-animate):not(.exit-animate) { + /* Override global .loading class which sets transform for centered spinners. */ + transform: none !important; +} + .timeline-clip.loading.audio { display: none; } @@ -6585,10 +6621,10 @@ input[type="checkbox"] { } /* Clip entrance animation on composition switch - GPU accelerated */ -@keyframes clip-entrance { +@keyframes clip-entrance-from-left { from { - opacity: 0; - transform: translate3d(-25px, 0, 0); + opacity: 1; + transform: translate3d(-72px, 0, 0); } to { opacity: 1; @@ -6596,24 +6632,54 @@ input[type="checkbox"] { } } -/* Clip exit animation - slides out to the right */ -@keyframes clip-exit { +@keyframes clip-entrance-from-right { + from { + opacity: 1; + transform: translate3d(72px, 0, 0); + } + to { + opacity: 1; + transform: translate3d(0, 0, 0); + } +} + +/* Clip exit animation - flies out without fading */ +@keyframes clip-exit-right { 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); + } +} + +@keyframes clip-exit-left { + from { + opacity: 1; + transform: translate3d(0, 0, 0); + } + to { + opacity: 1; + transform: translate3d(calc(-140vw - 100%), 0, 0); } } -.timeline-clip.entrance-animate { - animation: clip-entrance 0.6s cubic-bezier(0.22, 1, 0.36, 1) forwards; +.timeline-clip.entrance-animate-left { + animation: clip-entrance-from-left 0.3s cubic-bezier(0.22, 1, 0.36, 1) forwards; } -.timeline-clip.exit-animate { - animation: clip-exit 0.35s cubic-bezier(0.55, 0, 1, 0.45) forwards; +.timeline-clip.entrance-animate-right { + animation: clip-entrance-from-right 0.3s cubic-bezier(0.22, 1, 0.36, 1) forwards; +} + +.timeline-clip.exit-animate-right { + animation: clip-exit-right 0.175s cubic-bezier(0.7, 0, 0.84, 0) forwards; +} + +.timeline-clip.exit-animate-left { + animation: clip-exit-left 0.175s cubic-bezier(0.7, 0, 0.84, 0) forwards; } .clip-loading-spinner { @@ -8142,6 +8208,7 @@ input[type="checkbox"] { z-index: 30; pointer-events: none; transform: translateX(-1px); + --timeline-switch-base-x: -1px; } .playhead-head { @@ -8394,6 +8461,7 @@ input[type="checkbox"] { bottom: 0; z-index: 95; pointer-events: none; + --timeline-switch-base-x: 0px; } .in-out-marker .marker-flag { @@ -8465,6 +8533,7 @@ input[type="checkbox"] { z-index: 29; pointer-events: none; transform: translateX(-1px); + --timeline-switch-base-x: -1px; --marker-color: var(--accent-timeline); } @@ -8531,6 +8600,50 @@ input[type="checkbox"] { box-shadow: 0 0 4px var(--marker-color); } +@keyframes timeline-switch-enter-left { + from { transform: translate3d(calc(var(--timeline-switch-base-x, 0px) - 72px), 0, 0); } + to { transform: translate3d(var(--timeline-switch-base-x, 0px), 0, 0); } +} + +@keyframes timeline-switch-enter-right { + from { transform: translate3d(calc(var(--timeline-switch-base-x, 0px) + 72px), 0, 0); } + to { transform: translate3d(var(--timeline-switch-base-x, 0px), 0, 0); } +} + +@keyframes timeline-switch-exit-right { + from { transform: translate3d(var(--timeline-switch-base-x, 0px), 0, 0); } + to { transform: translate3d(calc(var(--timeline-switch-base-x, 0px) + 140vw + 100%), 0, 0); } +} + +@keyframes timeline-switch-exit-left { + from { transform: translate3d(var(--timeline-switch-base-x, 0px), 0, 0); } + to { transform: translate3d(calc(var(--timeline-switch-base-x, 0px) - 140vw - 100%), 0, 0); } +} + +.playhead.timeline-switch-enter-left, +.timeline-marker.timeline-switch-enter-left, +.in-out-marker.timeline-switch-enter-left { + animation: timeline-switch-enter-left 0.3s cubic-bezier(0.22, 1, 0.36, 1) forwards; +} + +.playhead.timeline-switch-enter-right, +.timeline-marker.timeline-switch-enter-right, +.in-out-marker.timeline-switch-enter-right { + animation: timeline-switch-enter-right 0.3s cubic-bezier(0.22, 1, 0.36, 1) forwards; +} + +.playhead.timeline-switch-exit-right, +.timeline-marker.timeline-switch-exit-right, +.in-out-marker.timeline-switch-exit-right { + animation: timeline-switch-exit-right 0.175s cubic-bezier(0.7, 0, 0.84, 0) forwards; +} + +.playhead.timeline-switch-exit-left, +.timeline-marker.timeline-switch-exit-left, +.in-out-marker.timeline-switch-exit-left { + animation: timeline-switch-exit-left 0.175s cubic-bezier(0.7, 0, 0.84, 0) forwards; +} + /* Ghost marker for drag-to-create */ .timeline-marker.ghost { opacity: 0.7; @@ -8888,7 +9001,7 @@ input[type="checkbox"] { /* Track header resize cursor on shift hover */ .track-header { cursor: default; - transition: background 0.15s; + transition: height 220ms cubic-bezier(0.22, 1, 0.36, 1), background 160ms ease, border-color 160ms ease; } .track-header:hover { diff --git a/src/changelog-data.json b/src/changelog-data.json index a9c43c482..98153f42c 100644 --- a/src/changelog-data.json +++ b/src/changelog-data.json @@ -1,4 +1,25 @@ [ + { + "date": "2026-05-02", + "type": "improve", + "title": "Directional Composition Tab Switching", + "description": "Timeline composition switches now follow tab order with directional clip, playhead, marker, and in/out marker motion, faster enter/exit timing, and non-fading transitions.", + "section": "Timeline / Composition Tabs" + }, + { + "date": "2026-05-02", + "type": "improve", + "title": "Layer Layout Morphs During Composition Switches", + "description": "Track rows start morphing to the destination composition's layer count and heights while outgoing clips leave, keeping loading placeholders and incoming clips on the same motion path.", + "section": "Timeline / Layers" + }, + { + "date": "2026-05-02", + "type": "new", + "title": "Recent Projects Menu", + "description": "The File menu now lists recent browser and native projects for quicker reopening.", + "section": "Project Persistence" + }, { "date": "2026-05-02", "type": "new", 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/services/project/ProjectFileService.ts b/src/services/project/ProjectFileService.ts index 1932e6b6a..f06eaeee3 100644 --- a/src/services/project/ProjectFileService.ts +++ b/src/services/project/ProjectFileService.ts @@ -3,6 +3,7 @@ // Supports two backends: FSA (Chrome) and Native Helper (Firefox) import { Logger } from '../logger'; +import { projectDB } from '../projectDB'; import { FileStorageService, fileStorageService } from './core/FileStorageService'; import { NativeFileStorageService, nativeFileStorageService } from './core/NativeFileStorageService'; import { NativeProjectCoreService } from './core/NativeProjectCoreService'; @@ -22,6 +23,13 @@ import { getRawRelativePath, parseRawRelativePath, } from './core/rawPath'; +import { + clearRecentProjects, + getRecentProject, + getRecentProjects, + removeRecentProject, + type RecentProjectEntry, +} from './recentProjects'; import type { ProjectFile, ProjectMediaFile, ProjectComposition, ProjectFolder } from './types'; type IterableDirectoryHandle = FileSystemDirectoryHandle & { @@ -464,6 +472,69 @@ class ProjectFileService { return nativeCore.loadProject(projectPath); } + getRecentProjects(): RecentProjectEntry[] { + return getRecentProjects(); + } + + async removeRecentProject(id: string): Promise { + await removeRecentProject(id); + } + + async clearRecentProjects(): Promise { + await clearRecentProjects(); + } + + async openRecentProject(id: string): Promise { + const recentProject = getRecentProject(id); + if (!recentProject) { + return false; + } + + if (recentProject.backend === 'native') { + if (!recentProject.path) { + await removeRecentProject(id); + return false; + } + + const nativeCore = await this.ensureNativeBackendReady(); + return nativeCore ? nativeCore.loadProject(recentProject.path) : false; + } + + if (!this.isFsaAvailable || !recentProject.handleKey) { + return false; + } + + let storedHandle: FileSystemHandle | null = null; + try { + storedHandle = await projectDB.getStoredHandle(recentProject.handleKey); + } catch (error) { + log.warn('Failed to read recent project handle', error); + return false; + } + + if (!storedHandle || storedHandle.kind !== 'directory') { + await removeRecentProject(id); + return false; + } + + const projectHandle = storedHandle as FileSystemDirectoryHandle; + let permission = await projectHandle.queryPermission({ mode: 'readwrite' }); + if (permission !== 'granted') { + permission = await projectHandle.requestPermission({ mode: 'readwrite' }); + } + + if (permission !== 'granted') { + return false; + } + + this.activateFsaBackend(); + const loaded = await this.coreService.loadProject(projectHandle); + if (!loaded) { + await removeRecentProject(id); + } + return loaded; + } + async loadProject(handleOrPath: FileSystemDirectoryHandle | string): Promise { if (typeof handleOrPath === 'string') { const nativeCore = await this.ensureNativeBackendReady(); diff --git a/src/services/project/core/NativeProjectCoreService.ts b/src/services/project/core/NativeProjectCoreService.ts index e9123a85e..2f8c62ece 100644 --- a/src/services/project/core/NativeProjectCoreService.ts +++ b/src/services/project/core/NativeProjectCoreService.ts @@ -7,6 +7,7 @@ import { apiKeyManager } from '../../apiKeyManager'; import { NativeHelperClient } from '../../nativeHelper/NativeHelperClient'; import { PROJECT_FOLDER_PATHS, MAX_BACKUPS } from './constants'; import { shouldPreferAutosave, shouldSkipEmptyProjectSave } from './autosaveRecovery'; +import { addRecentNativeProject, removeRecentNativeProject } from '../recentProjects'; import type { ProjectFile, ProjectMediaFile, ProjectComposition, ProjectFolder } from '../types'; const log = Logger.create('NativeProjectCore'); @@ -185,6 +186,7 @@ export class NativeProjectCoreService { this.isDirty = false; this.storeLastProject(projectPath); + await addRecentNativeProject(projectPath, initialProject); this.startAutoSave(); // Save any existing API keys @@ -223,6 +225,7 @@ export class NativeProjectCoreService { this.isDirty = false; this.storeLastProject(projectPath); + await addRecentNativeProject(projectPath, projectData); this.startAutoSave(); // Try to restore API keys from file if IndexedDB keys are empty @@ -383,6 +386,7 @@ export class NativeProjectCoreService { try { // Get parent directory + const oldPath = this.projectPath; const parts = this.projectPath.replace(/\\/g, '/').split('/'); parts.pop(); // Remove current folder name const parentPath = parts.join('/'); @@ -408,6 +412,7 @@ export class NativeProjectCoreService { this.projectData.name = trimmedName; this.projectData.updatedAt = new Date().toISOString(); await this.saveProject(); + await addRecentNativeProject(this.projectPath, this.projectData); return true; } @@ -420,6 +425,8 @@ export class NativeProjectCoreService { await this.client.writeFile(jsonPath, JSON.stringify(this.projectData, null, 2)); this.storeLastProject(newPath); + await removeRecentNativeProject(oldPath); + await addRecentNativeProject(newPath, this.projectData); this.isDirty = false; log.info(`Project renamed to "${trimmedName}"`); diff --git a/src/services/project/core/ProjectCoreService.ts b/src/services/project/core/ProjectCoreService.ts index 987fce661..5967e9401 100644 --- a/src/services/project/core/ProjectCoreService.ts +++ b/src/services/project/core/ProjectCoreService.ts @@ -5,6 +5,7 @@ import { Logger } from '../../logger'; import { projectDB } from '../../projectDB'; import { apiKeyManager } from '../../apiKeyManager'; import { shouldPreferAutosave, shouldSkipEmptyProjectSave } from './autosaveRecovery'; +import { addRecentFsaProject, removeRecentFsaProject } from '../recentProjects'; const log = Logger.create('ProjectCore'); import { FileStorageService } from './FileStorageService'; @@ -209,6 +210,7 @@ export class ProjectCoreService { this.isDirty = false; await this.storeLastProject(projectFolder); + await addRecentFsaProject(projectFolder, initialProject); this.startAutoSave(); // Save any existing API keys to the new project @@ -258,6 +260,7 @@ export class ProjectCoreService { this.isDirty = false; await this.storeLastProject(handle); + await addRecentFsaProject(handle, projectData); this.startAutoSave(); // Try to restore API keys from file if IndexedDB keys are empty @@ -425,6 +428,7 @@ export class ProjectCoreService { this.projectData.name = trimmedName; this.projectData.updatedAt = new Date().toISOString(); await this.writeProjectFile(this.projectHandle, PROJECT_FILE_NAME, this.projectData); + await addRecentFsaProject(this.projectHandle, this.projectData); this.isDirty = false; return true; } @@ -439,17 +443,20 @@ export class ProjectCoreService { this.projectData.name = trimmedName; this.projectData.updatedAt = new Date().toISOString(); await this.writeProjectFile(this.projectHandle, PROJECT_FILE_NAME, this.projectData); + await addRecentFsaProject(this.projectHandle, this.projectData); this.isDirty = false; return true; } const oldName = this.projectHandle.name; + const oldProjectHandle = this.projectHandle; // If the folder name already matches the new name, just update project data if (trimmedName === oldName) { this.projectData.name = trimmedName; this.projectData.updatedAt = new Date().toISOString(); await this.writeProjectFile(this.projectHandle, PROJECT_FILE_NAME, this.projectData); + await addRecentFsaProject(this.projectHandle, this.projectData); this.isDirty = false; log.info(`Project display name updated to "${trimmedName}"`); return true; @@ -498,6 +505,8 @@ export class ProjectCoreService { this.projectHandle = newFolder; await projectDB.storeHandle('lastProject', newFolder); + await removeRecentFsaProject(oldProjectHandle); + await addRecentFsaProject(newFolder, this.projectData); try { await parentDir.removeEntry(oldName, { recursive: true }); diff --git a/src/services/project/index.ts b/src/services/project/index.ts index 1a351040b..666cf5608 100644 --- a/src/services/project/index.ts +++ b/src/services/project/index.ts @@ -28,6 +28,8 @@ export { FileStorageService, fileStorageService } from './core/FileStorageServic export { ProjectCoreService } from './core/ProjectCoreService'; export { PROJECT_FOLDERS, MAX_BACKUPS, PROJECT_FOLDER_PATHS } from './core/constants'; export type { ProjectFolderKey } from './core/constants'; +export { RECENT_PROJECTS_CHANGED_EVENT } from './recentProjects'; +export type { RecentProjectEntry, RecentProjectBackend } from './recentProjects'; // Domain services (for advanced usage) export { AnalysisService } from './domains/AnalysisService'; diff --git a/src/services/project/recentProjects.ts b/src/services/project/recentProjects.ts new file mode 100644 index 000000000..2f29f1ac9 --- /dev/null +++ b/src/services/project/recentProjects.ts @@ -0,0 +1,295 @@ +import { Logger } from '../logger'; +import { projectDB } from '../projectDB'; +import type { ProjectFile } from './types'; + +const log = Logger.create('RecentProjects'); + +const RECENT_PROJECTS_KEY = 'ms-recent-projects'; +const RECENT_PROJECT_HANDLE_PREFIX = 'recentProject:'; +const MAX_RECENT_PROJECTS = 12; + +export const RECENT_PROJECTS_CHANGED_EVENT = 'masterselects-recent-projects-changed'; + +export type RecentProjectBackend = 'fsa' | 'native'; + +export interface RecentProjectEntry { + id: string; + name: string; + backend: RecentProjectBackend; + lastOpenedAt: number; + updatedAt?: string; + handleKey?: string; + path?: string; +} + +function getStorage(): Storage | null { + if (typeof window === 'undefined') { + return null; + } + + try { + return window.localStorage; + } catch { + return null; + } +} + +function normalizeNativePath(path: string): string { + return path.trim().replace(/\\/g, '/').replace(/\/+$/, ''); +} + +function getNameFromPath(path: string): string { + const parts = normalizeNativePath(path).split('/').filter(Boolean); + return parts.at(-1) ?? 'Project'; +} + +function createRecentId(): string { + return `recent-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; +} + +function isRecentProjectEntry(value: unknown): value is RecentProjectEntry { + if (!value || typeof value !== 'object') { + return false; + } + + const entry = value as Partial; + if (typeof entry.id !== 'string' || typeof entry.name !== 'string') { + return false; + } + + if (entry.backend !== 'fsa' && entry.backend !== 'native') { + return false; + } + + if (typeof entry.lastOpenedAt !== 'number' || !Number.isFinite(entry.lastOpenedAt)) { + return false; + } + + if (entry.backend === 'fsa') { + return typeof entry.handleKey === 'string'; + } + + return typeof entry.path === 'string' && entry.path.length > 0; +} + +function dispatchRecentProjectsChanged(): void { + if (typeof window === 'undefined') { + return; + } + + window.dispatchEvent(new CustomEvent(RECENT_PROJECTS_CHANGED_EVENT)); +} + +function readRecentProjects(): RecentProjectEntry[] { + const storage = getStorage(); + if (!storage) { + return []; + } + + try { + const raw = storage.getItem(RECENT_PROJECTS_KEY); + if (!raw) { + return []; + } + + const parsed = JSON.parse(raw) as unknown; + if (!Array.isArray(parsed)) { + return []; + } + + return parsed + .filter(isRecentProjectEntry) + .toSorted((left, right) => right.lastOpenedAt - left.lastOpenedAt) + .slice(0, MAX_RECENT_PROJECTS); + } catch (error) { + log.warn('Failed to read recent projects', error); + return []; + } +} + +function writeRecentProjects(entries: RecentProjectEntry[]): void { + const storage = getStorage(); + if (!storage) { + return; + } + + const normalized = entries + .toSorted((left, right) => right.lastOpenedAt - left.lastOpenedAt) + .slice(0, MAX_RECENT_PROJECTS); + + try { + storage.setItem(RECENT_PROJECTS_KEY, JSON.stringify(normalized)); + dispatchRecentProjectsChanged(); + } catch (error) { + log.warn('Failed to write recent projects', error); + } +} + +async function deleteFsaHandle(handleKey: string | undefined): Promise { + if (!handleKey) { + return; + } + + try { + await projectDB.deleteHandle(handleKey); + } catch (error) { + log.debug('Failed to delete recent project handle', { handleKey, error }); + } +} + +async function isSameDirectoryHandle( + left: FileSystemDirectoryHandle, + right: FileSystemHandle | null, +): Promise { + if (!right || right.kind !== 'directory') { + return false; + } + + try { + return await left.isSameEntry(right); + } catch { + return false; + } +} + +async function findFsaEntry( + entries: RecentProjectEntry[], + handle: FileSystemDirectoryHandle, +): Promise { + for (const entry of entries) { + if (entry.backend !== 'fsa' || !entry.handleKey) { + continue; + } + + const storedHandle = await projectDB.getStoredHandle(entry.handleKey); + if (await isSameDirectoryHandle(handle, storedHandle)) { + return entry; + } + } + + return null; +} + +function upsertEntry(entries: RecentProjectEntry[], nextEntry: RecentProjectEntry): RecentProjectEntry[] { + return [ + nextEntry, + ...entries.filter((entry) => entry.id !== nextEntry.id), + ]; +} + +function pruneRemovedEntries(before: RecentProjectEntry[], after: RecentProjectEntry[]): RecentProjectEntry[] { + const keptIds = new Set(after.map((entry) => entry.id)); + return before.filter((entry) => !keptIds.has(entry.id)); +} + +async function persistEntries(entries: RecentProjectEntry[]): Promise { + const sorted = entries.toSorted((left, right) => right.lastOpenedAt - left.lastOpenedAt); + const kept = sorted.slice(0, MAX_RECENT_PROJECTS); + const removed = pruneRemovedEntries(sorted, kept); + + await Promise.all(removed.map((entry) => entry.backend === 'fsa' + ? deleteFsaHandle(entry.handleKey) + : Promise.resolve())); + + writeRecentProjects(kept); +} + +export function getRecentProjects(): RecentProjectEntry[] { + return readRecentProjects(); +} + +export function getRecentProject(id: string): RecentProjectEntry | null { + return getRecentProjects().find((entry) => entry.id === id) ?? null; +} + +export async function addRecentFsaProject( + handle: FileSystemDirectoryHandle, + projectData: ProjectFile | null, +): Promise { + const entries = getRecentProjects(); + const existing = await findFsaEntry(entries, handle); + const id = existing?.id ?? createRecentId(); + const handleKey = existing?.handleKey ?? `${RECENT_PROJECT_HANDLE_PREFIX}${id}`; + + try { + await projectDB.storeHandle(handleKey, handle); + } catch (error) { + log.warn('Failed to store recent project handle', error); + return; + } + + const nextEntry: RecentProjectEntry = { + id, + name: projectData?.name || handle.name || 'Project', + backend: 'fsa', + handleKey, + updatedAt: projectData?.updatedAt, + lastOpenedAt: Date.now(), + }; + + await persistEntries(upsertEntry(entries, nextEntry)); +} + +export async function addRecentNativeProject( + path: string, + projectData: ProjectFile | null, +): Promise { + const normalizedPath = normalizeNativePath(path); + if (!normalizedPath) { + return; + } + + const entries = getRecentProjects(); + const existing = entries.find((entry) => entry.backend === 'native' && entry.path === normalizedPath); + const nextEntry: RecentProjectEntry = { + id: existing?.id ?? createRecentId(), + name: projectData?.name || getNameFromPath(normalizedPath), + backend: 'native', + path: normalizedPath, + updatedAt: projectData?.updatedAt, + lastOpenedAt: Date.now(), + }; + + await persistEntries(upsertEntry(entries, nextEntry)); +} + +export async function removeRecentFsaProject(handle: FileSystemDirectoryHandle): Promise { + const entries = getRecentProjects(); + const entry = await findFsaEntry(entries, handle); + if (!entry) { + return; + } + + await removeRecentProject(entry.id); +} + +export async function removeRecentNativeProject(path: string): Promise { + const normalizedPath = normalizeNativePath(path); + const entry = getRecentProjects() + .find((candidate) => candidate.backend === 'native' && candidate.path === normalizedPath); + + if (!entry) { + return; + } + + await removeRecentProject(entry.id); +} + +export async function removeRecentProject(id: string): Promise { + const entries = getRecentProjects(); + const removed = entries.find((entry) => entry.id === id); + + if (removed?.backend === 'fsa') { + await deleteFsaHandle(removed.handleKey); + } + + writeRecentProjects(entries.filter((entry) => entry.id !== id)); +} + +export async function clearRecentProjects(): Promise { + const entries = getRecentProjects(); + await Promise.all(entries.map((entry) => entry.backend === 'fsa' + ? deleteFsaHandle(entry.handleKey) + : Promise.resolve())); + writeRecentProjects([]); +} diff --git a/src/services/projectFileService.ts b/src/services/projectFileService.ts index 1025bdc9e..2cf9bf275 100644 --- a/src/services/projectFileService.ts +++ b/src/services/projectFileService.ts @@ -16,4 +16,7 @@ export { type ProjectKeyframe, type ProjectMarker, type ProjectFolder, + RECENT_PROJECTS_CHANGED_EVENT, + type RecentProjectEntry, + type RecentProjectBackend, } from './project'; 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, };