diff --git a/docs/COMPARE_FULLSCREEN.md b/docs/COMPARE_FULLSCREEN.md
new file mode 100644
index 0000000..ad0cd1b
--- /dev/null
+++ b/docs/COMPARE_FULLSCREEN.md
@@ -0,0 +1,150 @@
+# Compare Module - Fullscreen Mode
+
+## Overview
+The Compare module now includes a **Fullscreen Compare** mode that displays two videos side-by-side in a larger view, optimized for detailed visual comparison.
+
+## Features
+
+### Current (v0.1)
+- ✅ Side-by-side fullscreen layout
+- ✅ Larger video players for better visibility
+- ✅ Individual playback controls for each video
+- ✅ File labels showing video names
+- ✅ Back button to return to regular Compare view
+- ✅ Pink colored header/footer matching Compare module
+
+### Planned (Future - requires VT_Player enhancements)
+- ⏳ **Synchronized playback** - Play/Pause both videos simultaneously
+- ⏳ **Linked seeking** - Seek to same timestamp in both videos
+- ⏳ **Frame-by-frame sync** - Step through both videos in lockstep
+- ⏳ **Volume link** - Adjust volume on both players together
+- ⏳ **Playback speed sync** - Change speed on both players at once
+
+## Usage
+
+### Accessing Fullscreen Mode
+1. Load two videos in the Compare module
+2. Click the **"Fullscreen Compare"** button
+3. Videos will display side-by-side in larger players
+
+### Controls
+- **Individual players**: Each video has its own play/pause/seek controls
+- **"Play Both" button**: Placeholder for future synchronized playback
+- **"Pause Both" button**: Placeholder for future synchronized pause
+- **"< BACK TO COMPARE"**: Return to regular Compare view
+
+## Use Cases
+
+### Visual Quality Comparison
+Compare encoding settings or compression quality:
+- Original vs. compressed
+- Different codec outputs
+- Before/after color grading
+- Different resolution scaling
+
+### Frame-Accurate Comparison
+When VT_Player sync is implemented:
+- Compare edits side-by-side
+- Check for sync issues in re-encodes
+- Validate frame-accurate cuts
+- Compare different filter applications
+
+### A/B Testing
+Test different processing settings:
+- Different deinterlacing methods
+- Upscaling algorithms
+- Noise reduction levels
+- Color correction approaches
+
+## Technical Notes
+
+### Current Implementation
+- Uses standard `buildVideoPane()` for each side
+- 640x360 minimum player size (scales with window)
+- Independent playback state per video
+- No shared controls between players yet
+
+### VT_Player API Requirements for Sync
+For synchronized playback, VT_Player will need:
+
+```go
+// Playback state access
+player.IsPlaying() bool
+player.GetPosition() time.Duration
+
+// Event callbacks
+player.OnPlaybackStateChanged(callback func(playing bool))
+player.OnPositionChanged(callback func(position time.Duration))
+
+// Synchronized control
+player.SyncWith(otherPlayer *Player)
+player.Unsync()
+```
+
+### Synchronization Strategy
+When VT_Player supports it:
+1. **Master-Slave Pattern**: One player is master, other follows
+2. **Linked Events**: Play/pause/seek events trigger on both
+3. **Position Polling**: Periodically check for drift and correct
+4. **Frame-Accurate Sync**: Step both players frame-by-frame together
+
+## Keyboard Shortcuts (Planned)
+When implemented in VT_Player:
+- `Space` - Play/Pause both videos
+- `J` / `L` - Rewind/Forward both videos
+- `←` / `→` - Step both videos frame-by-frame
+- `K` - Pause both videos
+- `0-9` - Seek to percentage (0% to 90%) in both
+- `Esc` - Exit fullscreen mode
+
+## UI Layout
+
+```
+┌─────────────────────────────────────────────────────────────┐
+│ < BACK TO COMPARE │ ← Pink header
+├─────────────────────────────────────────────────────────────┤
+│ │
+│ Side-by-side fullscreen comparison. Use individual... │
+│ │
+│ [▶ Play Both] [⏸ Pause Both] │
+│ ───────────────────────────────────────────────────────── │
+│ │
+│ ┌─────────────────────────┬─────────────────────────────┐ │
+│ │ File 1: video1.mp4 │ File 2: video2.mp4 │ │
+│ ├─────────────────────────┼─────────────────────────────┤ │
+│ │ │ │ │
+│ │ Video Player 1 │ Video Player 2 │ │
+│ │ (640x360 min) │ (640x360 min) │ │
+│ │ │ │ │
+│ │ [Play] [Pause] [Seek] │ [Play] [Pause] [Seek] │ │
+│ │ │ │ │
+│ └─────────────────────────┴─────────────────────────────┘ │
+│ │
+└─────────────────────────────────────────────────────────────┘
+ ← Pink footer
+```
+
+## Future Enhancements
+
+### v0.2 - Synchronized Playback
+- Implement master-slave sync between players
+- Add "Link" toggle button to enable/disable sync
+- Visual indicator when players are synced
+
+### v0.3 - Advanced Sync
+- Offset compensation (e.g., if videos start at different times)
+- Manual sync adjustment (nudge one video forward/back)
+- Sync validation indicator (shows if videos are in sync)
+
+### v0.4 - Comparison Tools
+- Split-screen view with adjustable divider
+- A/B quick toggle (show only one at a time)
+- Difference overlay (highlight changed regions)
+- Frame difference metrics display
+
+## Notes
+- Fullscreen mode is accessible from regular Compare view
+- Videos must be loaded before entering fullscreen mode
+- Synchronized controls are placeholders until VT_Player API is enhanced
+- Window can be resized freely - players will scale
+- Each player maintains independent state for now
diff --git a/docs/LOSSLESSCUT_INSPIRATION.md b/docs/LOSSLESSCUT_INSPIRATION.md
new file mode 100644
index 0000000..554c861
--- /dev/null
+++ b/docs/LOSSLESSCUT_INSPIRATION.md
@@ -0,0 +1,460 @@
+# LosslessCut Features - Inspiration for VideoTools Trim Module
+
+## Overview
+LosslessCut is a mature, feature-rich video trimming application built on Electron/React with FFmpeg backend. This document extracts key features and UX patterns that should inspire VideoTools' Trim module development.
+
+---
+
+## 🎯 Core Trim Features to Adopt
+
+### 1. **Segment-Based Editing** ⭐⭐⭐ (HIGHEST PRIORITY)
+LosslessCut uses "segments" as first-class citizens rather than simple In/Out points.
+
+**How it works:**
+- Each segment has: start time, end time (optional), label, tags, and segment number
+- Multiple segments can exist on timeline simultaneously
+- Segments without end time = "markers" (vertical lines on timeline)
+- Segments can be reordered by drag-drop in segment list
+
+**Benefits for VideoTools:**
+- User can mark multiple trim regions in one session
+- Export all segments at once (batch trim)
+- Save/load trim projects for later refinement
+- More flexible than single In/Out point workflow
+
+**Implementation priority:** HIGH
+- Start with single segment (In/Out points)
+- Phase 2: Add multiple segments support
+- Phase 3: Add segment labels/tags
+
+**Example workflow:**
+```
+1. User loads video
+2. Finds first good section: 0:30 to 1:45 → Press I, seek, press O → Segment 1 created
+3. Press + to add new segment
+4. Finds second section: 3:20 to 5:10 → Segment 2 created
+5. Export → Creates 2 output files (or 1 merged file if mode set)
+```
+
+---
+
+### 2. **Keyboard-First Workflow** ⭐⭐⭐ (HIGHEST PRIORITY)
+LosslessCut is designed for speed via keyboard shortcuts.
+
+**Essential shortcuts:**
+| Key | Action | Notes |
+|-----|--------|-------|
+| `SPACE` | Play/Pause | Standard |
+| `I` | Set segment In point | Industry standard (Adobe, FCP) |
+| `O` | Set segment Out point | Industry standard |
+| `←` / `→` | Seek backward/forward | Frame or keyframe stepping |
+| `,` / `.` | Frame step | Precise frame-by-frame (1 frame) |
+| `+` | Add new segment | Quick workflow |
+| `B` | Split segment at cursor | Divide segment into two |
+| `BACKSPACE` | Delete segment/cutpoint | Quick removal |
+| `E` | Export | Fast export shortcut |
+| `C` | Capture screenshot | Snapshot current frame |
+| Mouse wheel | Seek timeline | Smooth scrubbing |
+
+**Why keyboard shortcuts matter:**
+- Professional users edit faster with keyboard
+- Reduces mouse movement fatigue
+- Enables "flow state" editing
+- Standard shortcuts reduce learning curve
+
+**Implementation for VideoTools:**
+- Integrate keyboard handling into VT_Player
+- Show keyboard shortcut overlay (SHIFT+/)
+- Allow user customization later
+
+---
+
+### 3. **Timeline Zoom** ⭐⭐⭐ (HIGH PRIORITY)
+Timeline can zoom in/out for precision editing.
+
+**How it works:**
+- Zoom slider or mouse wheel on timeline
+- Zoomed view shows: thumbnails, waveform, keyframes
+- Timeline scrolls horizontally when zoomed
+- Zoom follows playhead (keeps current position centered)
+
+**Benefits:**
+- Find exact cut points in long videos
+- Frame-accurate editing even in 2-hour files
+- See waveform detail for audio-based cuts
+
+**Implementation notes:**
+- VT_Player needs horizontal scrolling timeline widget
+- Zoom level: 1x (full video) to 100x (extreme detail)
+- Auto-scroll to keep playhead in view
+
+---
+
+### 4. **Waveform Display** ⭐⭐ (MEDIUM PRIORITY)
+Audio waveform shown on timeline for visual reference.
+
+**Features:**
+- Shows amplitude over time
+- Useful for finding speech/silence boundaries
+- Click waveform to seek
+- Updates as timeline zooms
+
+**Use cases:**
+- Trim silence from beginning/end
+- Find exact start of dialogue
+- Cut between sentences
+- Detect audio glitches
+
+**Implementation:**
+- FFmpeg can generate waveform images: `ffmpeg -i input.mp4 -filter_complex showwavespic output.png`
+- Display as timeline background
+- Optional feature (enable/disable)
+
+---
+
+### 5. **Keyframe Visualization** ⭐⭐ (MEDIUM PRIORITY)
+Timeline shows video keyframes (I-frames) as markers.
+
+**Why keyframes matter:**
+- Lossless copy (`-c copy`) only cuts at keyframes
+- Cutting between keyframes requires re-encode
+- Users need visual feedback on keyframe positions
+
+**How LosslessCut handles it:**
+- Vertical lines on timeline = keyframes
+- Color-coded: bright = keyframe, dim = P/B frame
+- "Smart cut" mode: cuts at keyframe + re-encodes small section
+
+**Implementation for VideoTools:**
+- Probe keyframes: `ffprobe -select_streams v -show_frames -show_entries frame=pict_type,pts_time`
+- Display on timeline
+- Warn user if cut point not on keyframe (when using `-c copy`)
+
+---
+
+### 6. **Invert Cut Mode** ⭐⭐ (MEDIUM PRIORITY)
+Yin-yang toggle: Keep segments vs. Remove segments
+
+**Two modes:**
+1. **Keep mode** (default): Export marked segments, discard rest
+2. **Cut mode** (inverted): Remove marked segments, keep rest
+
+**Example:**
+```
+Video: [────────────────────]
+Segments: [ SEG1 ] [ SEG2 ]
+
+Keep mode → Output: SEG1.mp4, SEG2.mp4
+Cut mode → Output: parts between segments (commercials removed)
+```
+
+**Use cases:**
+- **Keep**: Extract highlights from long recording
+- **Cut**: Remove commercials from TV recording
+
+**Implementation:**
+- Simple boolean toggle in UI
+- Changes FFmpeg command logic
+- Useful for both workflows
+
+---
+
+### 7. **Merge Mode** ⭐⭐ (MEDIUM PRIORITY)
+Option to merge multiple segments into single output file.
+
+**Export options:**
+- **Separate files**: Each segment → separate file
+- **Merge cuts**: All segments → 1 merged file
+- **Merge + separate**: Both outputs
+
+**FFmpeg technique:**
+```bash
+# Create concat file listing segments
+echo "file 'segment1.mp4'" > concat.txt
+echo "file 'segment2.mp4'" >> concat.txt
+
+# Merge with concat demuxer
+ffmpeg -f concat -safe 0 -i concat.txt -c copy merged.mp4
+```
+
+**Implementation:**
+- UI toggle: "Merge segments"
+- Temp directory for segment exports
+- Concat demuxer for lossless merge
+- Clean up temp files after
+
+---
+
+### 8. **Manual Timecode Entry** ⭐ (LOW PRIORITY)
+Type exact timestamps instead of scrubbing.
+
+**Features:**
+- Click In/Out time → text input appears
+- Type: `1:23:45.123` or `83.456`
+- Formats: HH:MM:SS.mmm, MM:SS, seconds
+- Paste timestamps from clipboard
+
+**Use cases:**
+- User has exact timestamps from notes
+- Import cut times from CSV/spreadsheet
+- Frame-accurate entry (1:23:45.033)
+
+**Implementation:**
+- Text input next to In/Out displays
+- Parse various time formats
+- Validate against video duration
+
+---
+
+### 9. **Project Files (.llc)** ⭐ (LOW PRIORITY - FUTURE)
+Save segments to file, resume editing later.
+
+**LosslessCut project format (JSON5):**
+```json
+{
+ "version": 1,
+ "cutSegments": [
+ {
+ "start": 30.5,
+ "end": 105.3,
+ "name": "Opening scene",
+ "tags": { "category": "intro" }
+ },
+ {
+ "start": 180.0,
+ "end": 245.7,
+ "name": "Action sequence"
+ }
+ ]
+}
+```
+
+**Benefits:**
+- Resume trim session after closing app
+- Share trim points with team
+- Version control trim decisions
+
+**Implementation (later):**
+- Simple JSON format
+- Save/load from File menu
+- Auto-save to temp on changes
+
+---
+
+## 🎨 UX Patterns to Adopt
+
+### 1. **Timeline Interaction Model**
+- Click timeline → seek to position
+- Drag timeline → scrub (live preview)
+- Mouse wheel → seek forward/backward
+- Shift+wheel → zoom timeline
+- Right-click → context menu (set In/Out, add segment, etc.)
+
+### 2. **Visual Feedback**
+- **Current time indicator**: Vertical line with triangular markers (top/bottom)
+- **Segment visualization**: Colored rectangles on timeline
+- **Hover preview**: Show timestamp on hover
+- **Segment labels**: Display segment names on timeline
+
+### 3. **Segment List Panel**
+LosslessCut shows sidebar with all segments:
+```
+┌─ Segments ─────────────────┐
+│ 1. [00:30 - 01:45] Intro │ ← Selected
+│ 2. [03:20 - 05:10] Action │
+│ 3. [07:00 - 09:30] Ending │
+└────────────────────────────┘
+```
+**Features:**
+- Click segment → select & seek to start
+- Drag to reorder
+- Right-click for options (rename, delete, duplicate)
+
+### 4. **Export Preview Dialog**
+Before final export, show summary:
+```
+┌─ Export Preview ──────────────────────────┐
+│ Export mode: Separate files │
+│ Output format: MP4 (same as source) │
+│ Keyframe mode: Smart cut │
+│ │
+│ Segments to export: │
+│ 1. Intro.mp4 (0:30 - 1:45) → 1.25 min │
+│ 2. Action.mp4 (3:20 - 5:10) → 1.83 min │
+│ 3. Ending.mp4 (7:00 - 9:30) → 2.50 min │
+│ │
+│ Total output size: ~125 MB │
+│ │
+│ [Cancel] [Export] │
+└───────────────────────────────────────────┘
+```
+
+---
+
+## 🚀 Advanced Features (Future Inspiration)
+
+### 1. **Scene Detection**
+Auto-create segments at scene changes.
+```bash
+ffmpeg -i input.mp4 -filter_complex \
+ "select='gt(scene,0.4)',metadata=print:file=scenes.txt" \
+ -f null -
+```
+
+### 2. **Silence Detection**
+Auto-trim silent sections.
+```bash
+ffmpeg -i input.mp4 -af silencedetect=noise=-30dB:d=0.5 -f null -
+```
+
+### 3. **Black Screen Detection**
+Find and remove black sections.
+```bash
+ffmpeg -i input.mp4 -vf blackdetect=d=0.5:pix_th=0.10 -f null -
+```
+
+### 4. **Chapter Import/Export**
+- Load MKV/MP4 chapters as segments
+- Export segments as chapter markers
+- Useful for DVD/Blu-ray rips
+
+### 5. **Thumbnail Scrubbing**
+- Generate thumbnail strip
+- Show preview on timeline hover
+- Faster visual navigation
+
+---
+
+## 📋 Implementation Roadmap for VideoTools
+
+### Phase 1: Essential Trim (Week 1-2)
+**Goal:** Basic usable trim functionality
+- ✅ VT_Player keyframing API (In/Out points)
+- ✅ Keyboard shortcuts (I, O, Space, ←/→)
+- ✅ Timeline markers visualization
+- ✅ Single segment export
+- ✅ Keep/Cut mode toggle
+
+### Phase 2: Professional Workflow (Week 3-4)
+**Goal:** Multi-segment editing
+- Multiple segments support
+- Segment list panel
+- Drag-to-reorder segments
+- Merge mode
+- Timeline zoom
+
+### Phase 3: Visual Enhancements (Week 5-6)
+**Goal:** Precision editing
+- Waveform display
+- Keyframe visualization
+- Frame-accurate stepping
+- Manual timecode entry
+
+### Phase 4: Advanced Features (Week 7+)
+**Goal:** Power user tools
+- Project save/load
+- Scene detection
+- Silence detection
+- Export presets
+- Batch processing
+
+---
+
+## 🎓 Key Lessons from LosslessCut
+
+### 1. **Start Simple, Scale Later**
+LosslessCut began with basic trim, added features over time. Don't over-engineer initial release.
+
+### 2. **Keyboard Shortcuts are Critical**
+Professional users demand keyboard efficiency. Design around keyboard-first workflow.
+
+### 3. **Visual Feedback Matters**
+Users need to SEE what they're doing:
+- Timeline markers
+- Segment rectangles
+- Waveforms
+- Keyframes
+
+### 4. **Lossless is Tricky**
+Educate users about keyframes, smart cut, and when re-encode is necessary.
+
+### 5. **FFmpeg Does the Heavy Lifting**
+LosslessCut is primarily a UI wrapper around FFmpeg. Focus on great UX, let FFmpeg handle processing.
+
+---
+
+## 🔗 References
+
+- **LosslessCut GitHub**: https://github.com/mifi/lossless-cut
+- **Documentation**: `~/tools/lossless-cut/docs.md`
+- **Source code**: `~/tools/lossless-cut/src/`
+- **Keyboard shortcuts**: `~/tools/lossless-cut/README.md` (search "keyboard")
+
+---
+
+## 💡 VideoTools-Specific Considerations
+
+### Advantages VideoTools Has:
+1. **Native Go + Fyne**: Faster startup, smaller binary than Electron
+2. **Integrated workflow**: Trim → Convert → Compare in one app
+3. **Queue system**: Already have batch processing foundation
+4. **Smart presets**: Leverage existing quality presets
+
+### Unique Features to Add:
+1. **Trim + Convert**: Set In/Out, choose quality preset, export in one step
+2. **Compare integration**: Auto-load trimmed vs. original for verification
+3. **Batch trim**: Apply same trim offsets to multiple files (e.g., remove first 30s from all)
+4. **Smart defaults**: Detect intros/outros and suggest trim points
+
+---
+
+## ✅ Action Items for VT_Player Team
+
+Based on LosslessCut analysis, VT_Player needs:
+
+### Essential APIs:
+1. **Keyframe API**
+ ```go
+ SetInPoint(time.Duration)
+ SetOutPoint(time.Duration)
+ GetInPoint() (time.Duration, bool)
+ GetOutPoint() (time.Duration, bool)
+ ClearKeyframes()
+ ```
+
+2. **Timeline Visualization**
+ - Draw In/Out markers on timeline
+ - Highlight segment region between markers
+ - Support multiple segments (future)
+
+3. **Keyboard Shortcuts**
+ - I/O for In/Out points
+ - ←/→ for frame stepping
+ - Space for play/pause
+ - Mouse wheel for seek
+
+4. **Frame Navigation**
+ ```go
+ StepForward() // Next frame
+ StepBackward() // Previous frame
+ GetCurrentFrame() int64
+ SeekToFrame(int64)
+ ```
+
+5. **Timeline Zoom** (Phase 2)
+ ```go
+ SetZoomLevel(float64) // 1.0 to 100.0
+ GetZoomLevel() float64
+ ScrollToTime(time.Duration)
+ ```
+
+### Reference Implementation:
+- Study LosslessCut's Timeline.tsx for zoom logic
+- Study TimelineSeg.tsx for segment visualization
+- Study useSegments.tsx for segment state management
+
+---
+
+**Document created**: 2025-12-04
+**Source**: LosslessCut v3.x codebase analysis
+**Next steps**: Share with VT_Player team, begin Phase 1 implementation
diff --git a/docs/TRIM_MODULE_DESIGN.md b/docs/TRIM_MODULE_DESIGN.md
new file mode 100644
index 0000000..5bd68ea
--- /dev/null
+++ b/docs/TRIM_MODULE_DESIGN.md
@@ -0,0 +1,169 @@
+# Trim Module Design
+
+## Overview
+The Trim module allows users to cut portions of video files using visual keyframe markers. Users can set In/Out points on the timeline and preview the trimmed segment before processing.
+
+## Core Features
+
+### 1. Visual Timeline Editing
+- Load video with VT_Player
+- Set **In Point** (start of keep region) - Press `I` or click button
+- Set **Out Point** (end of keep region) - Press `O` or click button
+- Visual markers on timeline showing trim region
+- Scrub through video to find exact frames
+
+### 2. Keyframe Controls
+```
+[In Point] ←────────────────→ [Out Point]
+ 0:10 Keep Region 2:45
+ ═══════════════════════════════════════════
+```
+
+### 3. Frame-Accurate Navigation
+- `←` / `→` - Step backward/forward one frame
+- `Shift+←` / `Shift+→` - Jump 1 second
+- `I` - Set In Point at current position
+- `O` - Set Out Point at current position
+- `Space` - Play/Pause
+- `C` - Clear all keyframes
+
+### 4. Multiple Trim Modes
+
+#### Mode 1: Keep Region (Default)
+Keep video between In and Out points, discard rest.
+```
+Input: [─────IN════════OUT─────]
+Output: [════════]
+```
+
+#### Mode 2: Cut Region
+Remove video between In and Out points, keep rest.
+```
+Input: [─────IN════════OUT─────]
+Output: [─────] [─────]
+```
+
+#### Mode 3: Multiple Segments (Advanced)
+Define multiple keep/cut regions using segment list.
+
+## UI Layout
+
+```
+┌─────────────────────────────────────────────┐
+│ < TRIM │ ← Cyan header bar
+├─────────────────────────────────────────────┤
+│ │
+│ ┌───────────────────────────────────────┐ │
+│ │ Video Player (VT_Player) │ │
+│ │ │ │
+│ │ [Timeline with In/Out markers] │ │
+│ │ ────I═══════════════O──────── │ │
+│ │ │ │
+│ │ [Play] [Pause] [In] [Out] [Clear] │ │
+│ └───────────────────────────────────────┘ │
+│ │
+│ Trim Mode: ○ Keep Region ○ Cut Region │
+│ │
+│ In Point: 00:01:23.456 [Set In] [Clear] │
+│ Out Point: 00:04:56.789 [Set Out] [Clear] │
+│ Duration: 00:03:33.333 │
+│ │
+│ Output Settings: │
+│ ┌─────────────────────────────────────┐ │
+│ │ Format: [Same as source ▼] │ │
+│ │ Re-encode: [ ] Smart copy (fast) │ │
+│ │ Quality: [Source quality] │ │
+│ └─────────────────────────────────────┘ │
+│ │
+│ [Preview Trimmed] [Add to Queue] │
+│ │
+└─────────────────────────────────────────────┘
+ ← Cyan footer bar
+```
+
+## VT_Player API Requirements
+
+### Required Methods
+```go
+// Keyframe management
+player.SetInPoint(position time.Duration)
+player.SetOutPoint(position time.Duration)
+player.GetInPoint() time.Duration
+player.GetOutPoint() time.Duration
+player.ClearKeyframes()
+
+// Frame-accurate navigation
+player.StepForward() // Advance one frame
+player.StepBackward() // Go back one frame
+player.GetCurrentTime() time.Duration
+player.GetFrameRate() float64
+
+// Visual feedback
+player.ShowMarkers(in, out time.Duration) // Draw on timeline
+```
+
+### Required Events
+```go
+// Keyboard shortcuts
+- OnKeyPress('I') -> Set In Point
+- OnKeyPress('O') -> Set Out Point
+- OnKeyPress('→') -> Step Forward
+- OnKeyPress('←') -> Step Backward
+- OnKeyPress('Space') -> Play/Pause
+- OnKeyPress('C') -> Clear Keyframes
+```
+
+## FFmpeg Integration
+
+### Keep Region Mode
+```bash
+ffmpeg -i input.mp4 -ss 00:01:23.456 -to 00:04:56.789 -c copy output.mp4
+```
+
+### Cut Region Mode (Complex filter)
+```bash
+ffmpeg -i input.mp4 \
+ -filter_complex "[0:v]split[v1][v2]; \
+ [v1]trim=start=0:end=83.456[v1t]; \
+ [v2]trim=start=296.789[v2t]; \
+ [v1t][v2t]concat=n=2:v=1:a=0[outv]" \
+ -map [outv] output.mp4
+```
+
+### Smart Copy (Fast)
+- Use `-c copy` when no re-encoding needed
+- Only works at keyframe boundaries
+- Show warning if In/Out not at keyframes
+
+## Workflow
+
+1. **Load Video** - Drag video onto Trim tile or use Load button
+2. **Navigate** - Scrub or use keyboard to find start point
+3. **Set In** - Press `I` or click "Set In" button
+4. **Find End** - Navigate to end of region to keep
+5. **Set Out** - Press `O` or click "Set Out" button
+6. **Preview** - Click "Preview Trimmed" to see result
+7. **Queue** - Click "Add to Queue" to process
+
+## Technical Notes
+
+### Precision Considerations
+- Frame-accurate requires seeking to exact frame boundaries
+- Display timestamps with millisecond precision (HH:MM:SS.mmm)
+- VT_Player must handle fractional frame positions
+- Consider GOP (Group of Pictures) boundaries for smart copy
+
+### Performance
+- Preview shouldn't require full re-encode
+- Show preview using VT_Player with constrained timeline
+- Cache preview segments for quick playback testing
+
+## Future Enhancements
+- Multiple trim regions in single operation
+- Batch trim multiple files with same In/Out offsets
+- Save trim presets (e.g., "Remove first 30s and last 10s")
+- Visual waveform for audio-based trimming
+- Chapter-aware trimming (trim to chapter boundaries)
+
+## Module Color
+**Cyan** - #44DDFF (already defined in modulesList)
diff --git a/docs/VIDEO_METADATA_GUIDE.md b/docs/VIDEO_METADATA_GUIDE.md
new file mode 100644
index 0000000..666f34f
--- /dev/null
+++ b/docs/VIDEO_METADATA_GUIDE.md
@@ -0,0 +1,612 @@
+# Video Metadata Guide for VideoTools
+
+## Overview
+This guide covers adding custom metadata fields to video files, NFO generation, and integration with VideoTools modules.
+
+---
+
+## 📦 Container Format Metadata Capabilities
+
+### MP4 / MOV (MPEG-4)
+**Metadata storage:** Atoms in `moov` container
+
+**Standard iTunes-compatible tags:**
+```
+©nam - Title
+©ART - Artist
+©alb - Album
+©day - Year
+©gen - Genre
+©cmt - Comment
+desc - Description
+©too - Encoding tool
+©enc - Encoded by
+cprt - Copyright
+```
+
+**Custom tags (with proper keys):**
+```
+----:com.apple.iTunes:DIRECTOR - Director
+----:com.apple.iTunes:PERFORMERS - Performers
+----:com.apple.iTunes:STUDIO - Studio/Production
+----:com.apple.iTunes:SERIES - Series name
+----:com.apple.iTunes:SCENE - Scene number
+----:com.apple.iTunes:CATEGORIES - Categories/Tags
+```
+
+**Setting metadata with FFmpeg:**
+```bash
+ffmpeg -i input.mp4 -c copy \
+ -metadata title="Scene Title" \
+ -metadata artist="Performer Name" \
+ -metadata album="Series Name" \
+ -metadata date="2025" \
+ -metadata genre="Category" \
+ -metadata comment="Scene description" \
+ -metadata description="Full scene info" \
+ output.mp4
+```
+
+**Custom fields:**
+```bash
+ffmpeg -i input.mp4 -c copy \
+ -metadata:s:v:0 custom_field="Custom Value" \
+ output.mp4
+```
+
+---
+
+### MKV (Matroska)
+**Metadata storage:** Tags element (XML-based)
+
+**Built-in tag support:**
+```xml
+
+
+
+ TITLE
+ Scene Title
+
+
+ ARTIST
+ Performer Name
+
+
+ DIRECTOR
+ Director Name
+
+
+ STUDIO
+ Production Studio
+
+
+
+ PERFORMERS
+ Performer 1, Performer 2
+
+
+ SCENE_NUMBER
+ EP042
+
+
+ CATEGORIES
+ Cat1, Cat2, Cat3
+
+
+
+```
+
+**Setting metadata with FFmpeg:**
+```bash
+ffmpeg -i input.mkv -c copy \
+ -metadata title="Scene Title" \
+ -metadata artist="Performer Name" \
+ -metadata director="Director" \
+ -metadata studio="Studio Name" \
+ output.mkv
+```
+
+**Advantages of MKV:**
+- Unlimited custom tags (any key-value pairs)
+- Can attach files (NFO, images, scripts)
+- Hierarchical metadata structure
+- Best for archival/preservation
+
+---
+
+### MOV (QuickTime)
+Same as MP4 (both use MPEG-4 structure), but QuickTime supports additional proprietary tags.
+
+---
+
+## 📄 NFO File Format
+
+NFO (Info) files are plain text/XML files that contain detailed metadata. Common in media libraries (Kodi, Plex, etc.).
+
+### NFO Format for Movies:
+```xml
+
+
+ Scene Title
+ Original Title
+ Sort Title
+ 2025
+ 2025-12-04
+ Scene description and plot summary
+ 45
+ Production Studio
+ Director Name
+
+
+ Performer 1
+ Role 1
+ path/to/performer1.jpg
+
+
+ Performer 2
+ Role 2
+
+
+ Category 1
+ Category 2
+
+ Tag1
+ Tag2
+
+ 8.5
+ 9.0
+
+
+
+
+
+
+
+
+
+ Series Name
+ 42
+ EP042
+
+```
+
+### NFO Format for TV Episodes:
+```xml
+
+
+ Episode Title
+ Series Name
+ 1
+ 5
+ 2025-12-04
+ Episode description
+ 30
+ Director Name
+
+
+ Performer 1
+ Character
+
+
+ Production Studio
+ 8.0
+
+```
+
+---
+
+## 🛠️ VideoTools Integration Plan
+
+### Module: **Metadata Editor** (New Module)
+**Purpose:** Edit video metadata and generate NFO files
+
+**Features:**
+1. **Load video** → Extract existing metadata
+2. **Edit fields** → Standard + custom fields
+3. **NFO generation** → Auto-generate from metadata
+4. **Embed metadata** → Write back to video file (lossless remux)
+5. **Batch metadata** → Apply same metadata to multiple files
+6. **Templates** → Save/load metadata templates
+
+**UI Layout:**
+```
+┌─────────────────────────────────────────────────┐
+│ < METADATA │ ← Purple header
+├─────────────────────────────────────────────────┤
+│ │
+│ File: scene_042.mp4 │
+│ │
+│ ┌─ Basic Info ──────────────────────────────┐ │
+│ │ Title: [________________] │ │
+│ │ Studio: [________________] │ │
+│ │ Series: [________________] │ │
+│ │ Scene #: [____] │ │
+│ │ Date: [2025-12-04] │ │
+│ │ Duration: 45:23 (auto) │ │
+│ └──────────────────────────────────────────────┘ │
+│ │
+│ ┌─ Performers ────────────────────────────────┐ │
+│ │ Performer 1: [________________] [X] │ │
+│ │ Performer 2: [________________] [X] │ │
+│ │ [+ Add Performer] │ │
+│ └──────────────────────────────────────────────┘ │
+│ │
+│ ┌─ Categories/Tags ──────────────────────────┐ │
+│ │ [Tag1] [Tag2] [Tag3] [+ Add] │ │
+│ └──────────────────────────────────────────────┘ │
+│ │
+│ ┌─ Description ────────────────────────────────┐ │
+│ │ [Multiline text area for plot/description] │ │
+│ │ │ │
+│ └──────────────────────────────────────────────┘ │
+│ │
+│ ┌─ Custom Fields ────────────────────────────┐ │
+│ │ Director: [________________] │ │
+│ │ IMDB ID: [________________] │ │
+│ │ Custom 1: [________________] │ │
+│ │ [+ Add Field] │ │
+│ └──────────────────────────────────────────────┘ │
+│ │
+│ [Generate NFO] [Embed in Video] [Save Template]│
+│ │
+└─────────────────────────────────────────────────┘
+```
+
+---
+
+## 🔧 Implementation Details
+
+### 1. Reading Metadata
+**Using FFprobe:**
+```bash
+ffprobe -v quiet -print_format json -show_format input.mp4
+
+# Output includes:
+{
+ "format": {
+ "filename": "input.mp4",
+ "tags": {
+ "title": "Scene Title",
+ "artist": "Performer Name",
+ "album": "Series Name",
+ "date": "2025",
+ "genre": "Category",
+ "comment": "Description"
+ }
+ }
+}
+```
+
+**Go implementation:**
+```go
+type VideoMetadata struct {
+ Title string
+ Studio string
+ Series string
+ SceneNumber string
+ Date string
+ Performers []string
+ Director string
+ Categories []string
+ Description string
+ CustomFields map[string]string
+}
+
+func probeMetadata(path string) (*VideoMetadata, error) {
+ cmd := exec.Command("ffprobe",
+ "-v", "quiet",
+ "-print_format", "json",
+ "-show_format",
+ path,
+ )
+
+ output, err := cmd.Output()
+ if err != nil {
+ return nil, err
+ }
+
+ var result struct {
+ Format struct {
+ Tags map[string]string `json:"tags"`
+ } `json:"format"`
+ }
+
+ json.Unmarshal(output, &result)
+
+ metadata := &VideoMetadata{
+ Title: result.Format.Tags["title"],
+ Studio: result.Format.Tags["studio"],
+ Series: result.Format.Tags["album"],
+ Date: result.Format.Tags["date"],
+ Categories: strings.Split(result.Format.Tags["genre"], ", "),
+ Description: result.Format.Tags["comment"],
+ CustomFields: make(map[string]string),
+ }
+
+ return metadata, nil
+}
+```
+
+---
+
+### 2. Writing Metadata
+**Using FFmpeg (lossless remux):**
+```go
+func embedMetadata(inputPath string, metadata *VideoMetadata, outputPath string) error {
+ args := []string{
+ "-i", inputPath,
+ "-c", "copy", // Lossless copy
+ }
+
+ // Add standard tags
+ if metadata.Title != "" {
+ args = append(args, "-metadata", fmt.Sprintf("title=%s", metadata.Title))
+ }
+ if metadata.Studio != "" {
+ args = append(args, "-metadata", fmt.Sprintf("studio=%s", metadata.Studio))
+ }
+ if metadata.Series != "" {
+ args = append(args, "-metadata", fmt.Sprintf("album=%s", metadata.Series))
+ }
+ if metadata.Date != "" {
+ args = append(args, "-metadata", fmt.Sprintf("date=%s", metadata.Date))
+ }
+ if len(metadata.Categories) > 0 {
+ args = append(args, "-metadata", fmt.Sprintf("genre=%s", strings.Join(metadata.Categories, ", ")))
+ }
+ if metadata.Description != "" {
+ args = append(args, "-metadata", fmt.Sprintf("comment=%s", metadata.Description))
+ }
+
+ // Add custom fields
+ for key, value := range metadata.CustomFields {
+ args = append(args, "-metadata", fmt.Sprintf("%s=%s", key, value))
+ }
+
+ args = append(args, outputPath)
+
+ cmd := exec.Command("ffmpeg", args...)
+ return cmd.Run()
+}
+```
+
+---
+
+### 3. Generating NFO
+```go
+func generateNFO(metadata *VideoMetadata, videoPath string) (string, error) {
+ nfo := `
+
+ ` + escapeXML(metadata.Title) + `
+ ` + escapeXML(metadata.Studio) + `
+ ` + escapeXML(metadata.Series) + `
+ ` + metadata.Date + `
+ ` + escapeXML(metadata.Description) + `
+`
+
+ // Add performers
+ for _, performer := range metadata.Performers {
+ nfo += `
+ ` + escapeXML(performer) + `
+
+`
+ }
+
+ // Add categories/genres
+ for _, category := range metadata.Categories {
+ nfo += ` ` + escapeXML(category) + `
+`
+ }
+
+ // Add custom fields
+ for key, value := range metadata.CustomFields {
+ nfo += ` <` + key + `>` + escapeXML(value) + `` + key + `>
+`
+ }
+
+ nfo += ``
+
+ // Save to file (same name as video + .nfo extension)
+ nfoPath := strings.TrimSuffix(videoPath, filepath.Ext(videoPath)) + ".nfo"
+ return nfoPath, os.WriteFile(nfoPath, []byte(nfo), 0644)
+}
+
+func escapeXML(s string) string {
+ s = strings.ReplaceAll(s, "&", "&")
+ s = strings.ReplaceAll(s, "<", "<")
+ s = strings.ReplaceAll(s, ">", ">")
+ s = strings.ReplaceAll(s, "\"", """)
+ s = strings.ReplaceAll(s, "'", "'")
+ return s
+}
+```
+
+---
+
+### 4. Attaching NFO to MKV
+MKV supports embedded attachments (like NFO files):
+
+```bash
+# Attach NFO file to MKV
+mkvpropedit video.mkv --add-attachment scene_info.nfo --attachment-mime-type text/plain --attachment-name "scene_info.nfo"
+
+# Or with FFmpeg (re-mux required)
+ffmpeg -i input.mkv -i scene_info.nfo -c copy \
+ -attach scene_info.nfo -metadata:s:t:0 mimetype=text/plain \
+ output.mkv
+```
+
+**Go implementation:**
+```go
+func attachNFOtoMKV(mkvPath string, nfoPath string) error {
+ cmd := exec.Command("mkvpropedit", mkvPath,
+ "--add-attachment", nfoPath,
+ "--attachment-mime-type", "text/plain",
+ "--attachment-name", filepath.Base(nfoPath),
+ )
+ return cmd.Run()
+}
+```
+
+---
+
+## 📋 Metadata Templates
+
+Allow users to save metadata templates for batch processing.
+
+**Template JSON:**
+```json
+{
+ "name": "Studio XYZ Default Template",
+ "fields": {
+ "studio": "Studio XYZ",
+ "series": "Series Name",
+ "categories": ["Category1", "Category2"],
+ "custom_fields": {
+ "director": "John Doe",
+ "producer": "Jane Smith"
+ }
+ }
+}
+```
+
+**Usage:**
+1. User creates template with common studio/series info
+2. Load template when editing new video
+3. Only fill in unique fields (title, performers, date, scene #)
+4. Batch apply template to multiple files
+
+---
+
+## 🎯 Use Cases
+
+### 1. Adult Content Library
+```
+Title: "Scene Title"
+Studio: "Production Studio"
+Series: "Series Name - Season 2"
+Scene Number: "EP042"
+Performers: ["Performer A", "Performer B"]
+Director: "Director Name"
+Categories: ["Category1", "Category2", "Category3"]
+Date: "2025-12-04"
+Description: "Full scene description and plot"
+```
+
+### 2. Personal Video Archive
+```
+Title: "Birthday Party 2025"
+Event: "John's 30th Birthday"
+Location: "Los Angeles, CA"
+People: ["John", "Sarah", "Mike", "Emily"]
+Date: "2025-06-15"
+Description: "John's surprise birthday party"
+```
+
+### 3. Movie Collection
+```
+Title: "Movie Title"
+Original Title: "原題"
+Director: "Christopher Nolan"
+Year: "2024"
+IMDB ID: "tt1234567"
+Actors: ["Actor 1", "Actor 2"]
+Genre: ["Sci-Fi", "Thriller"]
+Rating: "8.5/10"
+```
+
+---
+
+## 🔌 Integration with Existing Modules
+
+### Convert Module
+- **Checkbox**: "Preserve metadata" (default: on)
+- **Checkbox**: "Copy metadata from source" (default: on)
+- Allow adding/editing metadata before conversion
+
+### Inspect Module
+- **Add tab**: "Metadata" to view/edit metadata
+- Show both standard and custom fields
+- Quick edit without re-encoding
+
+### Compare Module
+- **Add**: "Compare Metadata" button
+- Show metadata diff between two files
+- Highlight differences
+
+---
+
+## 🚀 Implementation Roadmap
+
+### Phase 1: Basic Metadata Support (Week 1)
+- Read metadata with ffprobe
+- Display in Inspect module
+- Edit basic fields (title, artist, date, comment)
+- Write metadata with FFmpeg (lossless)
+
+### Phase 2: NFO Generation (Week 2)
+- NFO file generation
+- Save alongside video file
+- Load NFO and populate fields
+- Template system
+
+### Phase 3: Advanced Metadata (Week 3)
+- Custom fields support
+- Performers list
+- Categories/tags
+- Metadata Editor module UI
+
+### Phase 4: Batch & Templates (Week 4)
+- Metadata templates
+- Batch apply to multiple files
+- MKV attachment support (embed NFO)
+
+---
+
+## 📚 References
+
+### FFmpeg Metadata Documentation
+- https://ffmpeg.org/ffmpeg-formats.html#Metadata
+- https://wiki.multimedia.cx/index.php/FFmpeg_Metadata
+
+### NFO Format Standards
+- Kodi NFO: https://kodi.wiki/view/NFO_files
+- Plex Agents: https://support.plex.tv/articles/
+
+### Matroska Tags
+- https://www.matroska.org/technical/specs/tagging/index.html
+
+---
+
+## ✅ Summary
+
+**Yes, you can absolutely store custom metadata in video files!**
+
+**Best format for rich metadata:** MKV (unlimited custom tags + file attachments)
+
+**Most compatible:** MP4/MOV (iTunes tags work in QuickTime, VLC, etc.)
+
+**Recommended approach for VideoTools:**
+1. Support both embedded metadata (in video file) AND sidecar NFO files
+2. MKV: Embed NFO as attachment + metadata tags
+3. MP4: Metadata tags + separate .nfo file
+4. Allow users to choose what metadata to embed
+5. Generate NFO for media center compatibility (Kodi, Plex, Jellyfin)
+
+**Next steps:**
+1. Add basic metadata reading to `probeVideo()` function
+2. Add metadata display to Inspect module
+3. Create Metadata Editor module
+4. Implement NFO generation
+5. Add metadata templates
+
+This would be a killer feature for VideoTools! 🚀
diff --git a/docs/VIDEO_PLAYER_FORK.md b/docs/VIDEO_PLAYER_FORK.md
index 363a8eb..529ad32 100644
--- a/docs/VIDEO_PLAYER_FORK.md
+++ b/docs/VIDEO_PLAYER_FORK.md
@@ -1,12 +1,16 @@
# Video Player Fork Plan
+## Status: COMPLETED ✅
+**VT_Player has been forked as a separate project for independent development.**
+
## Overview
-The video player component will be extracted into a separate project to allow independent development and improvement of video playback controls while keeping VideoTools focused on video processing.
+The video player component has been extracted into a separate project (VT_Player) to allow independent development and improvement of video playback controls while keeping VideoTools focused on video processing.
## Current Player Integration
-The player is currently embedded in VideoTools at:
-- `internal/player/` - Player implementation
-- `main.go` - Player state and controls in Convert module
+The player is used in VideoTools at:
+- Convert module - Video preview and playback
+- Compare module - Side-by-side video comparison (as of dev13)
+- Inspect module - Single video playback with metadata (as of dev13)
- Preview frame display
- Playback controls (play/pause, seek, volume)
@@ -19,17 +23,20 @@ The player is currently embedded in VideoTools at:
- Can be used by other projects
### 2. Improved Controls
-Current limitations to address:
+Features to develop in VT_Player:
+- **Keyframing** - Mark in/out points for trimming and chapter creation
- Tighten up video controls
- Better seek bar with thumbnails on hover
- Improved timeline scrubbing
- Keyboard shortcuts for playback
-- Frame-accurate stepping
-- Playback speed controls
+- Frame-accurate stepping (←/→ keys for frame-by-frame)
+- Playback speed controls (0.25x to 2x)
- Better volume control UI
+- Timeline markers for chapters
+- Visual in/out point indicators
### 3. Clean API
-The forked player should expose a clean API:
+VT_Player should expose a clean API for VideoTools integration:
```go
type Player interface {
Load(path string) error
@@ -38,36 +45,42 @@ type Player interface {
Seek(position time.Duration)
GetFrame(position time.Duration) (image.Image, error)
SetVolume(level float64)
+
+ // Keyframing support for Trim/Chapter modules
+ SetInPoint(position time.Duration)
+ SetOutPoint(position time.Duration)
+ GetInPoint() time.Duration
+ GetOutPoint() time.Duration
+ ClearKeyframes()
+
Close()
}
```
-## Migration Strategy
+## VT_Player Development Strategy
-### Phase 1: Extract to Separate Module
-1. Create new repository: `github.com/yourusername/fyne-videoplayer`
-2. Copy `internal/player/` to new repo
-3. Extract player dependencies
-4. Create clean API surface
-5. Add comprehensive tests
+### Phase 1: Core Player Features ✅
+- [x] Basic playback controls (play/pause/seek)
+- [x] Volume control
+- [x] Frame preview display
+- [x] Integration with VideoTools modules
-### Phase 2: Update VideoTools
-1. Import fyne-videoplayer as dependency
-2. Replace internal/player with external package
-3. Update player instantiation
-4. Verify all playback features work
-5. Remove old internal/player code
+### Phase 2: Enhanced Controls (Current Focus)
+Priority features for Trim/Chapter module integration:
+- [ ] **Keyframe markers** - Set In/Out points visually on timeline
+- [ ] **Frame-accurate stepping** - ←/→ keys for frame-by-frame navigation
+- [ ] **Visual timeline with markers** - Show In/Out points on seek bar
+- [ ] **Keyboard shortcuts** - I (in), O (out), Space (play/pause), ←/→ (step)
+- [ ] **Export keyframe data** - Return In/Out timestamps to VideoTools
-### Phase 3: Enhance Player (Post-Fork)
-Features to add after fork:
+### Phase 3: Advanced Features (Future)
- [ ] Thumbnail preview on seek bar hover
-- [ ] Frame-accurate stepping (←/→ keys)
- [ ] Playback speed controls (0.25x to 2x)
-- [ ] Improved volume slider
-- [ ] Keyboard shortcuts (Space, K, J, L, etc.)
-- [ ] Timeline markers
+- [ ] Improved volume slider with visual feedback
+- [ ] Chapter markers on timeline
- [ ] Subtitle support
- [ ] Multi-audio track switching
+- [ ] Zoom timeline for precision editing
## Technical Considerations
@@ -89,18 +102,37 @@ Player must work on:
- Low CPU usage during playback
- Fast seeking
-## Timeline
-1. **Week 1-2**: Extract player code, create repo, clean API
-2. **Week 3**: Integration testing, update VideoTools
-3. **Week 4+**: Enhanced controls and features
+## VideoTools Module Integration
+
+### Modules Using VT_Player
+1. **Convert Module** - Preview video before conversion
+2. **Compare Module** - Side-by-side video playback for comparison
+3. **Inspect Module** - Single video playback with detailed metadata
+4. **Trim Module** (planned) - Keyframe-based trimming with In/Out points
+5. **Chapter Module** (planned) - Mark chapter points on timeline
+
+### Integration Requirements for Trim/Chapter
+The Trim and Chapter modules will require:
+- Keyframe API to set In/Out points
+- Visual markers on timeline showing trim regions
+- Frame-accurate seeking for precise cuts
+- Ability to export timestamp data for FFmpeg commands
+- Preview of trimmed segment before processing
## Benefits
-- **VideoTools**: Leaner codebase, focus on processing
-- **Player**: Independent evolution, reusable component
-- **Users**: Better video controls, more reliable playback
-- **Developers**: Easier to contribute to either project
+- **VideoTools**: Leaner codebase, focus on video processing
+- **VT_Player**: Independent evolution, reusable component, dedicated feature development
+- **Users**: Professional-grade video controls, precise editing capabilities
+- **Developers**: Easier to contribute, clear separation of concerns
+
+## Development Philosophy
+- **VT_Player**: Focus on playback, navigation, and visual controls
+- **VideoTools**: Focus on video processing, encoding, and batch operations
+- Clean API boundary allows independent versioning
+- VT_Player features can be tested independently before VideoTools integration
## Notes
-- Keep player dependency minimal in VideoTools
-- Player should be optional - frame display can work without playback
-- Consider using player in Compare module for side-by-side playback (future)
+- VT_Player repo: Separate project with independent development cycle
+- VideoTools will import VT_Player as external dependency
+- Keyframing features are priority for Trim/Chapter module development
+- Compare module demonstrates successful multi-player integration
diff --git a/docs/VT_PLAYER_INTEGRATION_NOTES.md b/docs/VT_PLAYER_INTEGRATION_NOTES.md
new file mode 100644
index 0000000..31ed102
--- /dev/null
+++ b/docs/VT_PLAYER_INTEGRATION_NOTES.md
@@ -0,0 +1,373 @@
+# VT_Player Integration Notes for Lead Developer
+
+## Project Context
+
+**VideoTools Repository**: https://git.leaktechnologies.dev/Leak_Technologies/VideoTools.git
+**VT_Player**: Forked video player component for independent development
+
+VT_Player was forked from VideoTools to enable dedicated development of video playback controls and features without impacting the main VideoTools codebase.
+
+## Current Integration Points
+
+### VideoTools Modules Using VT_Player
+
+1. **Convert Module** - Preview video before/during conversion
+2. **Compare Module** - Side-by-side video comparison (2 players)
+3. **Inspect Module** - Single video playback with metadata display
+4. **Compare Fullscreen** - Larger side-by-side view (planned: synchronized playback)
+
+### Current VT_Player Usage Pattern
+
+```go
+// VideoTools calls buildVideoPane() which creates player
+videoPane := buildVideoPane(state, fyne.NewSize(320, 180), videoSource, updateCallback)
+
+// buildVideoPane internally:
+// - Creates player.Controller
+// - Sets up playback controls
+// - Returns fyne.CanvasObject with player UI
+```
+
+## Priority Features Needed in VT_Player
+
+### 1. Keyframing API (HIGHEST PRIORITY)
+**Required for**: Trim Module, Chapter Module
+
+```go
+// Proposed API
+type KeyframeController interface {
+ // Set keyframe markers
+ SetInPoint(position time.Duration) error
+ SetOutPoint(position time.Duration) error
+ ClearInPoint()
+ ClearOutPoint()
+ ClearAllKeyframes()
+
+ // Get keyframe data
+ GetInPoint() (time.Duration, bool) // Returns position and hasInPoint
+ GetOutPoint() (time.Duration, bool)
+ GetSegmentDuration() time.Duration // Duration between In and Out
+
+ // Visual feedback
+ ShowKeyframeMarkers(show bool) // Toggle marker visibility on timeline
+ HighlightSegment(in, out time.Duration) // Highlight region between markers
+}
+```
+
+**Use Case**: User scrubs video, presses `I` to set In point, scrubs to end, presses `O` to set Out point. Visual markers show on timeline. VideoTools reads timestamps for FFmpeg trim command.
+
+### 2. Frame-Accurate Navigation (HIGH PRIORITY)
+**Required for**: Trim Module, Compare sync
+
+```go
+type FrameNavigationController interface {
+ // Step through video frame-by-frame
+ StepForward() error // Advance exactly 1 frame
+ StepBackward() error // Go back exactly 1 frame
+
+ // Frame info
+ GetCurrentFrame() int64 // Current frame number
+ GetFrameAtTime(time.Duration) int64 // Frame number at timestamp
+ GetTimeAtFrame(int64) time.Duration // Timestamp of frame number
+ GetTotalFrames() int64
+
+ // Seek to exact frame
+ SeekToFrame(frameNum int64) error
+}
+```
+
+**Use Case**: User finds exact frame for cut point using arrow keys (←/→), sets In/Out markers precisely.
+
+### 3. Synchronized Playback API (MEDIUM PRIORITY)
+**Required for**: Compare Fullscreen, Compare Module sync
+
+```go
+type SyncController interface {
+ // Link two players together
+ SyncWith(otherPlayer player.Controller) error
+ Unsync()
+ IsSynced() bool
+ GetSyncMaster() player.Controller
+
+ // Callbacks for sync events
+ OnPlayStateChanged(callback func(playing bool))
+ OnPositionChanged(callback func(position time.Duration))
+
+ // Sync with offset (for videos that don't start at same time)
+ SetSyncOffset(offset time.Duration)
+ GetSyncOffset() time.Duration
+}
+```
+
+**Use Case**: Compare module loads two videos. User clicks "Play Both" button. Both players play in sync. When one player is paused/seeked, other follows.
+
+### 4. Playback Speed Control (MEDIUM PRIORITY)
+**Required for**: Trim Module, general UX improvement
+
+```go
+type PlaybackSpeedController interface {
+ SetPlaybackSpeed(speed float64) error // 0.25x to 2.0x
+ GetPlaybackSpeed() float64
+ GetSupportedSpeeds() []float64 // [0.25, 0.5, 0.75, 1.0, 1.25, 1.5, 2.0]
+}
+```
+
+**Use Case**: User slows playback to 0.25x to find exact frame for trim point.
+
+## Integration Architecture
+
+### Current Pattern
+```
+VideoTools (main.go)
+ └─> buildVideoPane()
+ └─> player.New()
+ └─> player.Controller interface
+ └─> Returns fyne.CanvasObject
+```
+
+### Proposed Enhanced Pattern
+```
+VideoTools (main.go)
+ └─> buildVideoPane()
+ └─> player.NewEnhanced()
+ ├─> player.Controller (basic playback)
+ ├─> player.KeyframeController (trim support)
+ ├─> player.FrameNavigationController (frame stepping)
+ ├─> player.SyncController (multi-player sync)
+ └─> player.PlaybackSpeedController (speed control)
+ └─> Returns fyne.CanvasObject
+```
+
+### Backward Compatibility
+- Keep existing `player.Controller` interface unchanged
+- Add new optional interfaces
+- VideoTools checks if player implements enhanced interfaces:
+
+```go
+if keyframer, ok := player.(KeyframeController); ok {
+ // Use keyframe features
+}
+```
+
+## Technical Requirements
+
+### 1. Timeline Visual Enhancements
+
+Current timeline needs:
+- **In/Out Point Markers**: Visual indicators (⬇️ symbols or colored bars)
+- **Segment Highlight**: Show region between In and Out with different color
+- **Frame Number Display**: Show current frame number alongside timestamp
+- **Marker Drag Support**: Allow dragging markers to adjust In/Out points
+
+### 2. Keyboard Shortcuts
+
+Essential shortcuts for VT_Player:
+
+| Key | Action | Notes |
+|-----|--------|-------|
+| `Space` | Play/Pause | Standard |
+| `←` | Step backward 1 frame | Frame-accurate |
+| `→` | Step forward 1 frame | Frame-accurate |
+| `Shift+←` | Jump back 1 second | Quick navigation |
+| `Shift+→` | Jump forward 1 second | Quick navigation |
+| `I` | Set In Point | Trim support |
+| `O` | Set Out Point | Trim support |
+| `C` | Clear keyframes | Reset markers |
+| `K` | Pause | Video editor standard |
+| `J` | Rewind | Video editor standard |
+| `L` | Fast forward | Video editor standard |
+| `0-9` | Seek to % | 0=start, 5=50%, 9=90% |
+
+### 3. Performance Considerations
+
+- **Frame stepping**: Must be instant, no lag
+- **Keyframe display**: Update timeline without stuttering
+- **Sync**: Maximum 1-frame drift between synced players
+- **Memory**: Don't load entire video into RAM for frame navigation
+
+### 4. FFmpeg Integration
+
+VT_Player should expose frame-accurate timestamps that VideoTools can use:
+
+```bash
+# Example: VideoTools gets In=83.456s, Out=296.789s from VT_Player
+ffmpeg -ss 83.456 -to 296.789 -i input.mp4 -c copy output.mp4
+```
+
+Frame-accurate seeking requires:
+- Seek to nearest keyframe before target
+- Decode frames until exact target reached
+- Display correct frame with minimal latency
+
+## Data Flow Examples
+
+### Trim Module Workflow
+```
+1. User loads video in Trim module
+2. VideoTools creates VT_Player with keyframe support
+3. User navigates with arrow keys (VT_Player handles frame stepping)
+4. User presses 'I' → VT_Player sets In point marker
+5. User navigates to end point
+6. User presses 'O' → VT_Player sets Out point marker
+7. User clicks "Preview Trim" → VT_Player plays segment between markers
+8. User clicks "Add to Queue"
+9. VideoTools reads keyframes: in = player.GetInPoint(), out = player.GetOutPoint()
+10. VideoTools builds FFmpeg command with timestamps
+11. FFmpeg trims video
+```
+
+### Compare Sync Workflow
+```
+1. User loads 2 videos in Compare module
+2. VideoTools creates 2 VT_Player instances
+3. User clicks "Play Both"
+4. VideoTools calls: player1.SyncWith(player2)
+5. VideoTools calls: player1.Play()
+6. VT_Player automatically plays player2 in sync
+7. User pauses player1 → VT_Player pauses player2
+8. User seeks player1 → VT_Player seeks player2 to same position
+```
+
+## Testing Requirements
+
+VT_Player should include tests for:
+
+1. **Keyframe Accuracy**
+ - Set In/Out points, verify exact timestamps returned
+ - Clear markers, verify they're removed
+ - Test edge cases (In > Out, negative times, beyond duration)
+
+2. **Frame Navigation**
+ - Step forward/backward through entire video
+ - Verify frame numbers are sequential
+ - Test at video start (can't go back) and end (can't go forward)
+
+3. **Sync Reliability**
+ - Play two videos for 30 seconds, verify max drift < 1 frame
+ - Pause/seek operations propagate correctly
+ - Unsync works properly
+
+4. **Performance**
+ - Frame step latency < 50ms
+ - Timeline marker updates < 16ms (60fps)
+ - Memory usage stable during long playback sessions
+
+## Communication Protocol
+
+### VideoTools → VT_Player
+
+VideoTools will request features through interface methods:
+
+```go
+// Example: VideoTools wants to enable trim mode
+if trimmer, ok := player.(TrimController); ok {
+ trimmer.EnableTrimMode(true)
+ trimmer.OnInPointSet(func(t time.Duration) {
+ // Update VideoTools UI to show In point timestamp
+ })
+ trimmer.OnOutPointSet(func(t time.Duration) {
+ // Update VideoTools UI to show Out point timestamp
+ })
+}
+```
+
+### VT_Player → VideoTools
+
+VT_Player communicates state changes through callbacks:
+
+```go
+player.OnPlaybackStateChanged(func(playing bool) {
+ // VideoTools updates UI (play button ↔ pause button)
+})
+
+player.OnPositionChanged(func(position time.Duration) {
+ // VideoTools updates position display
+})
+
+player.OnKeyframeSet(func(markerType string, position time.Duration) {
+ // VideoTools logs keyframe for FFmpeg command
+})
+```
+
+## Migration Strategy
+
+### Phase 1: Core API (Immediate)
+- Define interfaces for keyframe, frame nav, sync
+- Implement basic keyframe markers (In/Out points)
+- Add frame stepping (←/→ keys)
+- Document API for VideoTools integration
+
+### Phase 2: Visual Enhancements (Week 2)
+- Enhanced timeline with marker display
+- Segment highlighting between In/Out
+- Frame number display
+- Keyboard shortcuts
+
+### Phase 3: Sync Features (Week 3)
+- Implement synchronized playback API
+- Master-slave pattern for linked players
+- Offset compensation for non-aligned videos
+
+### Phase 4: Advanced Features (Week 4+)
+- Playback speed control
+- Timeline zoom for precision editing
+- Thumbnail preview on hover
+- Chapter markers
+
+## Notes for VT_Player Developer
+
+1. **Keep backward compatibility**: Existing VideoTools code using basic player.Controller should continue working
+
+2. **Frame-accurate is critical**: Trim module requires exact frame positioning. Off-by-one frame errors are unacceptable.
+
+3. **Performance over features**: Frame stepping must be instant. Users will hold arrow keys to scrub through video.
+
+4. **Visual feedback matters**: Keyframe markers must be immediately visible. Timeline updates should be smooth.
+
+5. **Cross-platform testing**: VT_Player must work on Linux (GNOME/X11/Wayland), macOS, and Windows
+
+6. **FFmpeg integration**: VT_Player doesn't run FFmpeg, but must provide precise timestamps that VideoTools can pass to FFmpeg
+
+7. **Minimize dependencies**: Keep VT_Player focused on playback/navigation. VideoTools handles video processing.
+
+## Questions to Consider
+
+1. **Keyframe storage**: Should keyframes be stored in VT_Player or passed back to VideoTools immediately?
+
+2. **Sync drift handling**: If synced players drift apart, which one is "correct"? Should we periodically resync?
+
+3. **Frame stepping during playback**: Can user step frame-by-frame while video is playing, or must they pause first?
+
+4. **Memory management**: For long videos (hours), how do we efficiently support frame-accurate navigation without excessive memory?
+
+5. **Hardware acceleration**: Should frame stepping use GPU decoding, or is CPU sufficient for single frames?
+
+## Current VideoTools Status
+
+### Working Modules
+- ✅ Convert - Video conversion with preview
+- ✅ Compare - Side-by-side comparison (basic)
+- ✅ Inspect - Single video with metadata
+- ✅ Compare Fullscreen - Larger view (sync placeholders added)
+
+### Planned Modules Needing VT_Player Features
+- ⏳ Trim - **Needs**: Keyframing + frame navigation
+- ⏳ Chapter - **Needs**: Multiple keyframe markers on timeline
+- ⏳ Merge - May need synchronized preview of multiple clips
+
+### Auto-Compare Feature (NEW)
+- ✅ Checkbox in Convert module: "Compare After"
+- ✅ After conversion completes, automatically loads:
+ - File 1 (Original) = source video
+ - File 2 (Converted) = output video
+- ✅ User can immediately inspect conversion quality
+
+## Contact & Coordination
+
+For questions about VideoTools integration:
+- Review this document
+- Check `/docs/VIDEO_PLAYER_FORK.md` for fork strategy
+- Check `/docs/TRIM_MODULE_DESIGN.md` for detailed trim module requirements
+- Check `/docs/COMPARE_FULLSCREEN.md` for sync requirements
+
+VideoTools will track VT_Player changes and update integration code as new features become available.
diff --git a/internal/queue/queue.go b/internal/queue/queue.go
index b248add..df2beb7 100644
--- a/internal/queue/queue.go
+++ b/internal/queue/queue.go
@@ -330,6 +330,22 @@ func (q *Queue) processJobs() {
return
}
+ // Check if there's already a running job (only process one at a time)
+ hasRunningJob := false
+ for _, job := range q.jobs {
+ if job.Status == JobStatusRunning {
+ hasRunningJob = true
+ break
+ }
+ }
+
+ // If a job is already running, wait and check again later
+ if hasRunningJob {
+ q.mu.Unlock()
+ time.Sleep(500 * time.Millisecond)
+ continue
+ }
+
// Find highest priority pending job
var nextJob *Job
highestPriority := -1
diff --git a/internal/ui/components.go b/internal/ui/components.go
index c131f1f..800375f 100644
--- a/internal/ui/components.go
+++ b/internal/ui/components.go
@@ -4,6 +4,7 @@ import (
"fmt"
"image/color"
"strings"
+ "time"
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/canvas"
@@ -49,11 +50,13 @@ func (m *MonoTheme) Size(name fyne.ThemeSizeName) float32 {
// ModuleTile is a clickable tile widget for module selection
type ModuleTile struct {
widget.BaseWidget
- label string
- color color.Color
- enabled bool
- onTapped func()
- onDropped func([]fyne.URI)
+ label string
+ color color.Color
+ enabled bool
+ onTapped func()
+ onDropped func([]fyne.URI)
+ flashing bool
+ draggedOver bool
}
// NewModuleTile creates a new module tile
@@ -72,15 +75,40 @@ func NewModuleTile(label string, col color.Color, enabled bool, tapped func(), d
// DraggedOver implements desktop.Droppable interface
func (m *ModuleTile) DraggedOver(pos fyne.Position) {
logging.Debug(logging.CatUI, "DraggedOver tile=%s enabled=%v pos=%v", m.label, m.enabled, pos)
+ if m.enabled {
+ m.draggedOver = true
+ m.Refresh()
+ }
+}
+
+// DraggedOut is called when drag leaves the tile
+func (m *ModuleTile) DraggedOut() {
+ logging.Debug(logging.CatUI, "DraggedOut tile=%s", m.label)
+ m.draggedOver = false
+ m.Refresh()
}
// Dropped implements desktop.Droppable interface
func (m *ModuleTile) Dropped(pos fyne.Position, items []fyne.URI) {
+ fmt.Printf("[DROPTILE] Dropped on tile=%s enabled=%v itemCount=%d\n", m.label, m.enabled, len(items))
logging.Debug(logging.CatUI, "Dropped on tile=%s enabled=%v items=%v", m.label, m.enabled, items)
+ // Reset dragged over state
+ m.draggedOver = false
+
if m.enabled && m.onDropped != nil {
+ fmt.Printf("[DROPTILE] Calling callback for %s\n", m.label)
logging.Debug(logging.CatUI, "Calling onDropped callback for %s", m.label)
+ // Trigger flash animation
+ m.flashing = true
+ m.Refresh()
+ // Reset flash after 300ms
+ time.AfterFunc(300*time.Millisecond, func() {
+ m.flashing = false
+ m.Refresh()
+ })
m.onDropped(items)
} else {
+ fmt.Printf("[DROPTILE] Drop IGNORED on %s: enabled=%v hasCallback=%v\n", m.label, m.enabled, m.onDropped != nil)
logging.Debug(logging.CatUI, "Drop ignored: enabled=%v hasCallback=%v", m.enabled, m.onDropped != nil)
}
}
@@ -145,6 +173,22 @@ func (r *moduleTileRenderer) MinSize() fyne.Size {
func (r *moduleTileRenderer) Refresh() {
r.bg.FillColor = r.tile.color
+
+ // Apply visual feedback based on state
+ if r.tile.flashing {
+ // Flash animation - white outline
+ r.bg.StrokeColor = color.White
+ r.bg.StrokeWidth = 3
+ } else if r.tile.draggedOver {
+ // Dragging over - cyan/blue outline to indicate drop zone
+ r.bg.StrokeColor = color.NRGBA{R: 0, G: 200, B: 255, A: 255}
+ r.bg.StrokeWidth = 3
+ } else {
+ // Normal state
+ r.bg.StrokeColor = GridColor
+ r.bg.StrokeWidth = 1
+ }
+
r.bg.Refresh()
r.label.Text = r.tile.label
r.label.Refresh()
diff --git a/internal/ui/mainmenu.go b/internal/ui/mainmenu.go
index 1308e2b..709227b 100644
--- a/internal/ui/mainmenu.go
+++ b/internal/ui/mainmenu.go
@@ -34,18 +34,25 @@ func BuildMainMenu(modules []ModuleInfo, onModuleClick func(string), onModuleDro
)
var tileObjects []fyne.CanvasObject
- for _, mod := range modules {
- modID := mod.ID // Capture for closure
+ for i := range modules {
+ mod := modules[i] // Create new variable for this iteration
+ modID := mod.ID // Capture for closure
var tapFunc func()
var dropFunc func([]fyne.URI)
if mod.Enabled {
+ // Create new closure with properly captured modID
+ id := modID // Explicit capture
tapFunc = func() {
- onModuleClick(modID)
+ onModuleClick(id)
}
dropFunc = func(items []fyne.URI) {
- onModuleDrop(modID, items)
+ fmt.Printf("[MAINMENU] dropFunc called for module=%s itemCount=%d\n", id, len(items))
+ logging.Debug(logging.CatUI, "MainMenu dropFunc called for module=%s itemCount=%d", id, len(items))
+ onModuleDrop(id, items)
}
}
+ fmt.Printf("[MAINMENU] Creating tile for module=%s enabled=%v hasDropFunc=%v\n", modID, mod.Enabled, dropFunc != nil)
+ logging.Debug(logging.CatUI, "Creating tile for module=%s enabled=%v hasDropFunc=%v", modID, mod.Enabled, dropFunc != nil)
tileObjects = append(tileObjects, buildModuleTile(mod, tapFunc, dropFunc))
}
diff --git a/internal/ui/queueview.go b/internal/ui/queueview.go
index 911ac95..c6a3017 100644
--- a/internal/ui/queueview.go
+++ b/internal/ui/queueview.go
@@ -228,7 +228,22 @@ func getStatusText(job *queue.Job) string {
if job.StartedAt != nil {
elapsed = fmt.Sprintf(" | Elapsed: %s", time.Since(*job.StartedAt).Round(time.Second))
}
- return fmt.Sprintf("Status: Running | Progress: %.1f%%%s", job.Progress, elapsed)
+
+ // Add FPS and speed info if available in Config
+ var extras string
+ if job.Config != nil {
+ if fps, ok := job.Config["fps"].(float64); ok && fps > 0 {
+ extras += fmt.Sprintf(" | %.0f fps", fps)
+ }
+ if speed, ok := job.Config["speed"].(float64); ok && speed > 0 {
+ extras += fmt.Sprintf(" | %.2fx", speed)
+ }
+ if etaDuration, ok := job.Config["eta"].(time.Duration); ok && etaDuration > 0 {
+ extras += fmt.Sprintf(" | ETA %s", etaDuration.Round(time.Second))
+ }
+ }
+
+ return fmt.Sprintf("Status: Running | Progress: %.1f%%%s%s", job.Progress, elapsed, extras)
case queue.JobStatusPaused:
return "Status: Paused"
case queue.JobStatusCompleted:
@@ -259,23 +274,23 @@ func buildModuleBadge(t queue.JobType) fyne.CanvasObject {
return container.NewMax(bg, container.NewCenter(label))
}
-// moduleColor maps job types to distinct colors for quick visual scanning.
+// moduleColor maps job types to distinct colors matching the main module colors
func moduleColor(t queue.JobType) color.Color {
switch t {
case queue.JobTypeConvert:
- return color.RGBA{R: 76, G: 232, B: 112, A: 255} // green
+ return color.RGBA{R: 139, G: 68, B: 255, A: 255} // Violet (#8B44FF)
case queue.JobTypeMerge:
- return color.RGBA{R: 68, G: 136, B: 255, A: 255} // blue
+ return color.RGBA{R: 68, G: 136, B: 255, A: 255} // Blue (#4488FF)
case queue.JobTypeTrim:
- return color.RGBA{R: 255, G: 193, B: 7, A: 255} // amber
+ return color.RGBA{R: 68, G: 221, B: 255, A: 255} // Cyan (#44DDFF)
case queue.JobTypeFilter:
- return color.RGBA{R: 160, G: 86, B: 255, A: 255} // purple
+ return color.RGBA{R: 68, G: 255, B: 136, A: 255} // Green (#44FF88)
case queue.JobTypeUpscale:
- return color.RGBA{R: 255, G: 138, B: 101, A: 255} // coral
+ return color.RGBA{R: 170, G: 255, B: 68, A: 255} // Yellow-Green (#AAFF44)
case queue.JobTypeAudio:
- return color.RGBA{R: 255, G: 215, B: 64, A: 255} // gold
+ return color.RGBA{R: 255, G: 215, B: 68, A: 255} // Yellow (#FFD744)
case queue.JobTypeThumb:
- return color.RGBA{R: 102, G: 217, B: 239, A: 255} // teal
+ return color.RGBA{R: 255, G: 136, B: 68, A: 255} // Orange (#FF8844)
default:
return color.Gray{Y: 180}
}
diff --git a/main.go b/main.go
index b83f541..b1b2f9a 100644
--- a/main.go
+++ b/main.go
@@ -194,6 +194,9 @@ type appState struct {
convertActiveIn string
convertActiveOut string
convertProgress float64
+ convertFPS float64
+ convertSpeed float64
+ convertETA time.Duration
playSess *playSession
jobQueue *queue.Queue
statsBar *ui.ConversionStatsBar
@@ -202,6 +205,8 @@ type appState struct {
queueOffset fyne.Position
compareFile1 *videoSource
compareFile2 *videoSource
+ inspectFile *videoSource
+ autoCompare bool // Auto-load Compare module after conversion
}
func (s *appState) stopPreview() {
@@ -430,7 +435,7 @@ func (s *appState) showMainMenu() {
ID: m.ID,
Label: m.Label,
Color: m.Color,
- Enabled: m.ID == "convert" || m.ID == "compare", // Convert and compare modules are functional
+ Enabled: m.ID == "convert" || m.ID == "compare" || m.ID == "inspect", // Convert, compare, and inspect modules are functional
})
}
@@ -491,6 +496,11 @@ func (s *appState) refreshQueueView() {
Title: fmt.Sprintf("Direct convert: %s", in),
Description: fmt.Sprintf("Output: %s", out),
Progress: s.convertProgress,
+ Config: map[string]interface{}{
+ "fps": s.convertFPS,
+ "speed": s.convertSpeed,
+ "eta": s.convertETA,
+ },
}}, jobs...)
}
@@ -568,9 +578,18 @@ func (s *appState) refreshQueueView() {
// Restore scroll offset
s.queueScroll = scroll
- if s.queueScroll != nil {
- s.queueScroll.Offset = s.queueOffset
- s.queueScroll.Refresh()
+ if s.queueScroll != nil && s.active == "queue" {
+ // Use ScrollTo instead of directly setting Offset to prevent rubber banding
+ // Defer to allow UI to settle first
+ go func() {
+ time.Sleep(50 * time.Millisecond)
+ fyne.CurrentApp().Driver().DoFromGoroutine(func() {
+ if s.queueScroll != nil {
+ s.queueScroll.Offset = s.queueOffset
+ s.queueScroll.Refresh()
+ }
+ }, false)
+ }()
}
s.setContent(container.NewPadded(view))
@@ -659,6 +678,8 @@ func (s *appState) showModule(id string) {
s.showConvertView(nil)
case "compare":
s.showCompareView()
+ case "inspect":
+ s.showInspectView()
default:
logging.Debug(logging.CatUI, "UI module %s not wired yet", id)
}
@@ -735,10 +756,24 @@ func (s *appState) handleModuleDrop(moduleID string, items []fyne.URI) {
}, false)
}
- // Update state and show module
+ // Update state and show module (with small delay to allow flash animation to be seen)
+ time.Sleep(350 * time.Millisecond)
fyne.CurrentApp().Driver().DoFromGoroutine(func() {
- s.compareFile1 = src1
- s.compareFile2 = src2
+ // Smart slot assignment: if dropping 2 videos, fill both slots
+ if len(videoPaths) >= 2 {
+ s.compareFile1 = src1
+ s.compareFile2 = src2
+ } else {
+ // Single video: fill the empty slot, or slot 1 if both empty
+ if s.compareFile1 == nil {
+ s.compareFile1 = src1
+ } else if s.compareFile2 == nil {
+ s.compareFile2 = src1
+ } else {
+ // Both slots full, overwrite slot 1
+ s.compareFile1 = src1
+ }
+ }
s.showModule(moduleID)
logging.Debug(logging.CatModule, "loaded %d video(s) for compare module", len(videoPaths))
}, false)
@@ -746,6 +781,30 @@ func (s *appState) handleModuleDrop(moduleID string, items []fyne.URI) {
return
}
+ // If inspect module, load video into inspect slot
+ if moduleID == "inspect" {
+ path := videoPaths[0]
+ go func() {
+ src, err := probeVideo(path)
+ if err != nil {
+ logging.Debug(logging.CatModule, "failed to load video for inspect: %v", err)
+ fyne.CurrentApp().Driver().DoFromGoroutine(func() {
+ dialog.ShowError(fmt.Errorf("failed to load video: %w", err), s.window)
+ }, false)
+ return
+ }
+
+ // Update state and show module (with small delay to allow flash animation)
+ time.Sleep(350 * time.Millisecond)
+ fyne.CurrentApp().Driver().DoFromGoroutine(func() {
+ s.inspectFile = src
+ s.showModule(moduleID)
+ logging.Debug(logging.CatModule, "loaded video for inspect module")
+ }, false)
+ }()
+ return
+ }
+
// Single file or non-convert module: load first video and show module
path := videoPaths[0]
logging.Debug(logging.CatModule, "drop on module %s path=%s - starting load", moduleID, path)
@@ -753,7 +812,8 @@ func (s *appState) handleModuleDrop(moduleID string, items []fyne.URI) {
go func() {
logging.Debug(logging.CatModule, "loading video in goroutine")
s.loadVideo(path)
- // After loading, switch to the module
+ // After loading, switch to the module (with small delay to allow flash animation)
+ time.Sleep(350 * time.Millisecond)
fyne.CurrentApp().Driver().DoFromGoroutine(func() {
logging.Debug(logging.CatModule, "showing module %s after load", moduleID)
s.showModule(moduleID)
@@ -931,6 +991,20 @@ func (s *appState) showCompareView() {
s.setContent(buildCompareView(s))
}
+func (s *appState) showInspectView() {
+ s.stopPreview()
+ s.lastModule = s.active
+ s.active = "inspect"
+ s.setContent(buildInspectView(s))
+}
+
+func (s *appState) showCompareFullscreen() {
+ s.stopPreview()
+ s.lastModule = s.active
+ s.active = "compare-fullscreen"
+ s.setContent(buildCompareFullscreenView(s))
+}
+
// jobExecutor executes a job from the queue
func (s *appState) jobExecutor(ctx context.Context, job *queue.Job, progressCallback func(float64)) error {
logging.Debug(logging.CatSystem, "executing job %s: %s", job.ID, job.Title)
@@ -2529,20 +2603,23 @@ func buildConvertView(state *appState, src *videoSource) fyne.CanvasObject {
}
convertBtn = widget.NewButton("CONVERT NOW", func() {
- // Check if queue is already running
- if state.jobQueue != nil && state.jobQueue.IsRunning() {
- dialog.ShowInformation("Queue Active",
- "The conversion queue is currently running. Click \"Add to Queue\" to add this video to the queue instead.",
- state.window)
- // Auto-add to queue instead
- if err := state.addConvertToQueue(); err != nil {
- dialog.ShowError(err, state.window)
- } else {
- dialog.ShowInformation("Queue", "Job added to queue!", state.window)
- }
+ // Add job to queue and start immediately
+ if err := state.addConvertToQueue(); err != nil {
+ dialog.ShowError(err, state.window)
return
}
- state.startConvert(statusLabel, convertBtn, cancelBtn, activity)
+
+ // Start the queue if not already running
+ if state.jobQueue != nil && !state.jobQueue.IsRunning() {
+ state.jobQueue.Start()
+ logging.Debug(logging.CatSystem, "started queue from Convert Now")
+ }
+
+ // Clear the loaded video from convert module
+ state.clearVideo()
+
+ // Show success message
+ dialog.ShowInformation("Convert", "Conversion started! View progress in Job Queue.", state.window)
})
convertBtn.Importance = widget.HighImportance
if src == nil {
@@ -2560,7 +2637,13 @@ func buildConvertView(state *appState, src *videoSource) fyne.CanvasObject {
addQueueBtn.Enable()
}
- leftControls := container.NewHBox(resetBtn)
+ // Auto-compare checkbox
+ autoCompareCheck := widget.NewCheck("Compare After", func(checked bool) {
+ state.autoCompare = checked
+ })
+ autoCompareCheck.SetChecked(state.autoCompare)
+
+ leftControls := container.NewHBox(resetBtn, autoCompareCheck)
centerStatus := container.NewHBox(activity, statusLabel)
rightControls := container.NewHBox(cancelBtn, addQueueBtn, convertBtn)
actionInner := container.NewBorder(nil, nil, leftControls, rightControls, centerStatus)
@@ -3702,7 +3785,7 @@ func (s *appState) handleDrop(pos fyne.Position, items []fyne.URI) {
// Load videos sequentially to avoid race conditions
go func() {
if len(videoPaths) == 1 {
- // Single video dropped - fill first empty slot
+ // Single video dropped - use smart slot assignment
src, err := probeVideo(videoPaths[0])
if err != nil {
logging.Debug(logging.CatModule, "failed to load video: %v", err)
@@ -3713,19 +3796,17 @@ func (s *appState) handleDrop(pos fyne.Position, items []fyne.URI) {
}
fyne.CurrentApp().Driver().DoFromGoroutine(func() {
- // Fill first empty slot
+ // Smart slot assignment: fill the empty slot, or slot 1 if both empty
if s.compareFile1 == nil {
s.compareFile1 = src
- logging.Debug(logging.CatModule, "loaded video into slot 1")
+ logging.Debug(logging.CatModule, "loaded video into empty slot 1")
} else if s.compareFile2 == nil {
s.compareFile2 = src
- logging.Debug(logging.CatModule, "loaded video into slot 2")
+ logging.Debug(logging.CatModule, "loaded video into empty slot 2")
} else {
- // Both slots full - ask which to replace
- dialog.ShowInformation("Both Slots Full",
- "Both comparison slots are full. Use the Clear button to empty a slot first.",
- s.window)
- return
+ // Both slots full, overwrite slot 1
+ s.compareFile1 = src
+ logging.Debug(logging.CatModule, "both slots full, overwriting slot 1")
}
s.showCompareView()
}, false)
@@ -3765,6 +3846,47 @@ func (s *appState) handleDrop(pos fyne.Position, items []fyne.URI) {
return
}
+ // If in inspect module, handle single video file
+ if s.active == "inspect" {
+ // Collect video files from dropped items
+ var videoPaths []string
+ for _, uri := range items {
+ if uri.Scheme() != "file" {
+ continue
+ }
+ path := uri.Path()
+ if s.isVideoFile(path) {
+ videoPaths = append(videoPaths, path)
+ }
+ }
+
+ if len(videoPaths) == 0 {
+ logging.Debug(logging.CatUI, "no valid video files in dropped items")
+ dialog.ShowInformation("Inspect Video", "No video files found in dropped items.", s.window)
+ return
+ }
+
+ // Load first video
+ go func() {
+ src, err := probeVideo(videoPaths[0])
+ if err != nil {
+ logging.Debug(logging.CatModule, "failed to load video for inspect: %v", err)
+ fyne.CurrentApp().Driver().DoFromGoroutine(func() {
+ dialog.ShowError(fmt.Errorf("failed to load video: %w", err), s.window)
+ }, false)
+ return
+ }
+
+ fyne.CurrentApp().Driver().DoFromGoroutine(func() {
+ s.inspectFile = src
+ s.showInspectView()
+ logging.Debug(logging.CatModule, "loaded video into inspect module")
+ }, false)
+ }()
+
+ return
+ }
+
// Other modules don't handle file drops yet
logging.Debug(logging.CatUI, "drop ignored; module %s cannot handle files", s.active)
}
@@ -4592,6 +4714,7 @@ func (s *appState) startConvert(status *widget.Label, btn, cancelBtn *widget.But
progressQuit := make(chan struct{})
go func() {
scanner := bufio.NewScanner(stdout)
+ var currentFPS float64
for scanner.Scan() {
select {
case <-progressQuit:
@@ -4604,6 +4727,15 @@ func (s *appState) startConvert(status *widget.Label, btn, cancelBtn *widget.But
continue
}
key, val := parts[0], parts[1]
+
+ // Capture FPS value
+ if key == "fps" {
+ if fps, err := strconv.ParseFloat(val, 64); err == nil {
+ currentFPS = fps
+ }
+ continue
+ }
+
if key != "out_time_ms" && key != "progress" {
continue
}
@@ -4628,9 +4760,26 @@ func (s *appState) startConvert(status *widget.Label, btn, cancelBtn *widget.But
if elapsedWall > 0 {
speed = elapsedProc / elapsedWall
}
- lbl := fmt.Sprintf("Converting… %.0f%% | elapsed %s | ETA %s | %.2fx", pct, formatShortDuration(elapsedWall), etaOrDash(eta), speed)
+
+ var etaDuration time.Duration
+ if pct > 0 && elapsedWall > 0 && pct < 100 {
+ remaining := elapsedWall * (100 - pct) / pct
+ etaDuration = time.Duration(remaining * float64(time.Second))
+ }
+
+ // Build status with FPS
+ var lbl string
+ if currentFPS > 0 {
+ lbl = fmt.Sprintf("Converting… %.0f%% | %.0f fps | elapsed %s | ETA %s | %.2fx", pct, currentFPS, formatShortDuration(elapsedWall), etaOrDash(eta), speed)
+ } else {
+ lbl = fmt.Sprintf("Converting… %.0f%% | elapsed %s | ETA %s | %.2fx", pct, formatShortDuration(elapsedWall), etaOrDash(eta), speed)
+ }
+
fyne.CurrentApp().Driver().DoFromGoroutine(func() {
s.convertProgress = pct
+ s.convertFPS = currentFPS
+ s.convertSpeed = speed
+ s.convertETA = etaDuration
setStatus(lbl)
// Keep stats bar and queue view in sync during direct converts
s.updateStatsBar()
@@ -4736,6 +4885,26 @@ func (s *appState) startConvert(status *widget.Label, btn, cancelBtn *widget.But
s.convertActiveOut = ""
s.convertProgress = 100
setStatus("Done")
+
+ // Auto-compare if enabled
+ if s.autoCompare {
+ go func() {
+ // Probe the output file
+ convertedSrc, err := probeVideo(outPath)
+ if err != nil {
+ logging.Debug(logging.CatModule, "auto-compare: failed to probe converted file: %v", err)
+ return
+ }
+
+ // Load original and converted into compare slots
+ fyne.CurrentApp().Driver().DoFromGoroutine(func() {
+ s.compareFile1 = src // Original
+ s.compareFile2 = convertedSrc // Converted
+ s.showCompareView()
+ logging.Debug(logging.CatModule, "auto-compare: loaded original vs converted")
+ }, false)
+ }()
+ }
}, false)
s.convertCancel = nil
}()
@@ -5287,6 +5456,15 @@ type CropValues struct {
// detectCrop runs cropdetect analysis on a video to find black bars
// Returns nil if no crop is detected or if detection fails
func detectCrop(path string, duration float64) *CropValues {
+ // First, get source video dimensions for validation
+ src, err := probeVideo(path)
+ if err != nil {
+ logging.Debug(logging.CatFFMPEG, "failed to probe video for crop detection: %v", err)
+ return nil
+ }
+ sourceWidth := src.Width
+ sourceHeight := src.Height
+ logging.Debug(logging.CatFFMPEG, "source dimensions: %dx%d", sourceWidth, sourceHeight)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
@@ -5336,6 +5514,58 @@ func detectCrop(path string, duration float64) *CropValues {
y, _ := strconv.Atoi(lastMatch[4])
logging.Debug(logging.CatFFMPEG, "detected crop: %dx%d at %d,%d", width, height, x, y)
+
+ // Validate crop dimensions
+ if width <= 0 || height <= 0 {
+ logging.Debug(logging.CatFFMPEG, "invalid crop dimensions: width=%d height=%d", width, height)
+ return nil
+ }
+
+ // Ensure crop doesn't exceed source dimensions
+ if width > sourceWidth {
+ logging.Debug(logging.CatFFMPEG, "crop width %d exceeds source width %d, clamping", width, sourceWidth)
+ width = sourceWidth
+ }
+ if height > sourceHeight {
+ logging.Debug(logging.CatFFMPEG, "crop height %d exceeds source height %d, clamping", height, sourceHeight)
+ height = sourceHeight
+ }
+
+ // Ensure crop position + size doesn't exceed source
+ if x + width > sourceWidth {
+ logging.Debug(logging.CatFFMPEG, "crop x+width exceeds source, adjusting x from %d to %d", x, sourceWidth-width)
+ x = sourceWidth - width
+ if x < 0 {
+ x = 0
+ width = sourceWidth
+ }
+ }
+ if y + height > sourceHeight {
+ logging.Debug(logging.CatFFMPEG, "crop y+height exceeds source, adjusting y from %d to %d", y, sourceHeight-height)
+ y = sourceHeight - height
+ if y < 0 {
+ y = 0
+ height = sourceHeight
+ }
+ }
+
+ // Ensure even dimensions (required for many codecs)
+ if width % 2 != 0 {
+ width -= 1
+ logging.Debug(logging.CatFFMPEG, "adjusted width to even number: %d", width)
+ }
+ if height % 2 != 0 {
+ height -= 1
+ logging.Debug(logging.CatFFMPEG, "adjusted height to even number: %d", height)
+ }
+
+ // If crop is the same as source, no cropping needed
+ if width == sourceWidth && height == sourceHeight {
+ logging.Debug(logging.CatFFMPEG, "crop dimensions match source, no cropping needed")
+ return nil
+ }
+
+ logging.Debug(logging.CatFFMPEG, "validated crop: %dx%d at %d,%d", width, height, x, y)
return &CropValues{
Width: width,
Height: height,
@@ -5374,6 +5604,169 @@ func buildCompareView(state *appState) fyne.CanvasObject {
instructions.Wrapping = fyne.TextWrapWord
instructions.Alignment = fyne.TextAlignCenter
+ // Fullscreen Compare button
+ fullscreenBtn := widget.NewButton("Fullscreen Compare", func() {
+ if state.compareFile1 == nil && state.compareFile2 == nil {
+ dialog.ShowInformation("No Videos", "Load two videos to use fullscreen comparison.", state.window)
+ return
+ }
+ state.showCompareFullscreen()
+ })
+ fullscreenBtn.Importance = widget.MediumImportance
+
+ // Copy Comparison button - copies both files' metadata side by side
+ copyComparisonBtn := widget.NewButton("Copy Comparison", func() {
+ if state.compareFile1 == nil && state.compareFile2 == nil {
+ dialog.ShowInformation("No Videos", "Load at least one video to copy comparison metadata.", state.window)
+ return
+ }
+
+ // Format side-by-side comparison
+ var comparisonText strings.Builder
+ comparisonText.WriteString("═══════════════════════════════════════════════════════════════════════\n")
+ comparisonText.WriteString(" VIDEO COMPARISON REPORT\n")
+ comparisonText.WriteString("═══════════════════════════════════════════════════════════════════════\n\n")
+
+ // File names header
+ file1Name := "Not loaded"
+ file2Name := "Not loaded"
+ if state.compareFile1 != nil {
+ file1Name = filepath.Base(state.compareFile1.Path)
+ }
+ if state.compareFile2 != nil {
+ file2Name = filepath.Base(state.compareFile2.Path)
+ }
+
+ comparisonText.WriteString(fmt.Sprintf("FILE 1: %s\n", file1Name))
+ comparisonText.WriteString(fmt.Sprintf("FILE 2: %s\n", file2Name))
+ comparisonText.WriteString("───────────────────────────────────────────────────────────────────────\n\n")
+
+ // Helper to get field value or placeholder
+ getField := func(src *videoSource, getter func(*videoSource) string) string {
+ if src == nil {
+ return "—"
+ }
+ return getter(src)
+ }
+
+ // File Info section
+ comparisonText.WriteString("━━━ FILE INFO ━━━\n")
+
+ file1Size := getField(state.compareFile1, func(src *videoSource) string {
+ if fi, err := os.Stat(src.Path); err == nil {
+ sizeMB := float64(fi.Size()) / (1024 * 1024)
+ if sizeMB >= 1024 {
+ return fmt.Sprintf("%.2f GB", sizeMB/1024)
+ }
+ return fmt.Sprintf("%.2f MB", sizeMB)
+ }
+ return "Unknown"
+ })
+ file2Size := getField(state.compareFile2, func(src *videoSource) string {
+ if fi, err := os.Stat(src.Path); err == nil {
+ sizeMB := float64(fi.Size()) / (1024 * 1024)
+ if sizeMB >= 1024 {
+ return fmt.Sprintf("%.2f GB", sizeMB/1024)
+ }
+ return fmt.Sprintf("%.2f MB", sizeMB)
+ }
+ return "Unknown"
+ })
+
+ comparisonText.WriteString(fmt.Sprintf("%-25s | %-20s | %s\n", "File Size:", file1Size, file2Size))
+ comparisonText.WriteString(fmt.Sprintf("%-25s | %-20s | %s\n",
+ "Format:",
+ getField(state.compareFile1, func(s *videoSource) string { return s.Format }),
+ getField(state.compareFile2, func(s *videoSource) string { return s.Format })))
+
+ // Video section
+ comparisonText.WriteString("\n━━━ VIDEO ━━━\n")
+ comparisonText.WriteString(fmt.Sprintf("%-25s | %-20s | %s\n",
+ "Codec:",
+ getField(state.compareFile1, func(s *videoSource) string { return s.VideoCodec }),
+ getField(state.compareFile2, func(s *videoSource) string { return s.VideoCodec })))
+ comparisonText.WriteString(fmt.Sprintf("%-25s | %-20s | %s\n",
+ "Resolution:",
+ getField(state.compareFile1, func(s *videoSource) string { return fmt.Sprintf("%dx%d", s.Width, s.Height) }),
+ getField(state.compareFile2, func(s *videoSource) string { return fmt.Sprintf("%dx%d", s.Width, s.Height) })))
+ comparisonText.WriteString(fmt.Sprintf("%-25s | %-20s | %s\n",
+ "Aspect Ratio:",
+ getField(state.compareFile1, func(s *videoSource) string { return s.AspectRatioString() }),
+ getField(state.compareFile2, func(s *videoSource) string { return s.AspectRatioString() })))
+ comparisonText.WriteString(fmt.Sprintf("%-25s | %-20s | %s\n",
+ "Frame Rate:",
+ getField(state.compareFile1, func(s *videoSource) string { return fmt.Sprintf("%.2f fps", s.FrameRate) }),
+ getField(state.compareFile2, func(s *videoSource) string { return fmt.Sprintf("%.2f fps", s.FrameRate) })))
+ comparisonText.WriteString(fmt.Sprintf("%-25s | %-20s | %s\n",
+ "Bitrate:",
+ getField(state.compareFile1, func(s *videoSource) string { return formatBitrate(s.Bitrate) }),
+ getField(state.compareFile2, func(s *videoSource) string { return formatBitrate(s.Bitrate) })))
+ comparisonText.WriteString(fmt.Sprintf("%-25s | %-20s | %s\n",
+ "Pixel Format:",
+ getField(state.compareFile1, func(s *videoSource) string { return s.PixelFormat }),
+ getField(state.compareFile2, func(s *videoSource) string { return s.PixelFormat })))
+ comparisonText.WriteString(fmt.Sprintf("%-25s | %-20s | %s\n",
+ "Color Space:",
+ getField(state.compareFile1, func(s *videoSource) string { return s.ColorSpace }),
+ getField(state.compareFile2, func(s *videoSource) string { return s.ColorSpace })))
+ comparisonText.WriteString(fmt.Sprintf("%-25s | %-20s | %s\n",
+ "Color Range:",
+ getField(state.compareFile1, func(s *videoSource) string { return s.ColorRange }),
+ getField(state.compareFile2, func(s *videoSource) string { return s.ColorRange })))
+ comparisonText.WriteString(fmt.Sprintf("%-25s | %-20s | %s\n",
+ "Field Order:",
+ getField(state.compareFile1, func(s *videoSource) string { return s.FieldOrder }),
+ getField(state.compareFile2, func(s *videoSource) string { return s.FieldOrder })))
+ comparisonText.WriteString(fmt.Sprintf("%-25s | %-20s | %s\n",
+ "GOP Size:",
+ getField(state.compareFile1, func(s *videoSource) string { return fmt.Sprintf("%d", s.GOPSize) }),
+ getField(state.compareFile2, func(s *videoSource) string { return fmt.Sprintf("%d", s.GOPSize) })))
+
+ // Audio section
+ comparisonText.WriteString("\n━━━ AUDIO ━━━\n")
+ comparisonText.WriteString(fmt.Sprintf("%-25s | %-20s | %s\n",
+ "Codec:",
+ getField(state.compareFile1, func(s *videoSource) string { return s.AudioCodec }),
+ getField(state.compareFile2, func(s *videoSource) string { return s.AudioCodec })))
+ comparisonText.WriteString(fmt.Sprintf("%-25s | %-20s | %s\n",
+ "Bitrate:",
+ getField(state.compareFile1, func(s *videoSource) string { return formatBitrate(s.AudioBitrate) }),
+ getField(state.compareFile2, func(s *videoSource) string { return formatBitrate(s.AudioBitrate) })))
+ comparisonText.WriteString(fmt.Sprintf("%-25s | %-20s | %s\n",
+ "Sample Rate:",
+ getField(state.compareFile1, func(s *videoSource) string { return fmt.Sprintf("%d Hz", s.AudioRate) }),
+ getField(state.compareFile2, func(s *videoSource) string { return fmt.Sprintf("%d Hz", s.AudioRate) })))
+ comparisonText.WriteString(fmt.Sprintf("%-25s | %-20s | %s\n",
+ "Channels:",
+ getField(state.compareFile1, func(s *videoSource) string { return fmt.Sprintf("%d", s.Channels) }),
+ getField(state.compareFile2, func(s *videoSource) string { return fmt.Sprintf("%d", s.Channels) })))
+
+ // Other section
+ comparisonText.WriteString("\n━━━ OTHER ━━━\n")
+ comparisonText.WriteString(fmt.Sprintf("%-25s | %-20s | %s\n",
+ "Duration:",
+ getField(state.compareFile1, func(s *videoSource) string { return s.DurationString() }),
+ getField(state.compareFile2, func(s *videoSource) string { return s.DurationString() })))
+ comparisonText.WriteString(fmt.Sprintf("%-25s | %-20s | %s\n",
+ "SAR (Pixel Aspect):",
+ getField(state.compareFile1, func(s *videoSource) string { return s.SampleAspectRatio }),
+ getField(state.compareFile2, func(s *videoSource) string { return s.SampleAspectRatio })))
+ comparisonText.WriteString(fmt.Sprintf("%-25s | %-20s | %s\n",
+ "Chapters:",
+ getField(state.compareFile1, func(s *videoSource) string { return fmt.Sprintf("%v", s.HasChapters) }),
+ getField(state.compareFile2, func(s *videoSource) string { return fmt.Sprintf("%v", s.HasChapters) })))
+ comparisonText.WriteString(fmt.Sprintf("%-25s | %-20s | %s\n",
+ "Metadata:",
+ getField(state.compareFile1, func(s *videoSource) string { return fmt.Sprintf("%v", s.HasMetadata) }),
+ getField(state.compareFile2, func(s *videoSource) string { return fmt.Sprintf("%v", s.HasMetadata) })))
+
+ comparisonText.WriteString("\n═══════════════════════════════════════════════════════════════════════\n")
+
+ state.window.Clipboard().SetContent(comparisonText.String())
+ dialog.ShowInformation("Copied", "Comparison metadata copied to clipboard", state.window)
+ })
+ copyComparisonBtn.Importance = widget.LowImportance
+
// Clear All button
clearAllBtn := widget.NewButton("Clear All", func() {
state.compareFile1 = nil
@@ -5382,7 +5775,7 @@ func buildCompareView(state *appState) fyne.CanvasObject {
})
clearAllBtn.Importance = widget.LowImportance
- instructionsRow := container.NewBorder(nil, nil, nil, clearAllBtn, instructions)
+ instructionsRow := container.NewBorder(nil, nil, nil, container.NewHBox(fullscreenBtn, copyComparisonBtn, clearAllBtn), instructions)
// File labels
file1Label := widget.NewLabel("File 1: Not loaded")
@@ -5391,21 +5784,13 @@ func buildCompareView(state *appState) fyne.CanvasObject {
file2Label := widget.NewLabel("File 2: Not loaded")
file2Label.TextStyle = fyne.TextStyle{Bold: true}
- // Thumbnail images - use smaller minimum for better flexibility
- file1Thumbnail := canvas.NewImageFromImage(nil)
- file1Thumbnail.FillMode = canvas.ImageFillContain
- file1Thumbnail.SetMinSize(fyne.NewSize(240, 135))
+ // Video player containers
+ file1VideoContainer := container.NewMax()
+ file2VideoContainer := container.NewMax()
- file2Thumbnail := canvas.NewImageFromImage(nil)
- file2Thumbnail.FillMode = canvas.ImageFillContain
- file2Thumbnail.SetMinSize(fyne.NewSize(240, 135))
-
- // Placeholder backgrounds
- file1ThumbBg := canvas.NewRectangle(utils.MustHex("#0F1529"))
- file1ThumbBg.SetMinSize(fyne.NewSize(240, 135))
-
- file2ThumbBg := canvas.NewRectangle(utils.MustHex("#0F1529"))
- file2ThumbBg.SetMinSize(fyne.NewSize(240, 135))
+ // Initialize with placeholders
+ file1VideoContainer.Objects = []fyne.CanvasObject{container.NewCenter(widget.NewLabel("No video loaded"))}
+ file2VideoContainer.Objects = []fyne.CanvasObject{container.NewCenter(widget.NewLabel("No video loaded"))}
// Info labels
file1Info := widget.NewLabel("No file loaded")
@@ -5476,36 +5861,6 @@ func buildCompareView(state *appState) fyne.CanvasObject {
)
}
- // Helper to load thumbnail for a video
- loadThumbnail := func(src *videoSource, img *canvas.Image) {
- if src == nil {
- return
- }
- // Generate preview frames if not already present
- if len(src.PreviewFrames) == 0 {
- go func() {
- if thumb, err := capturePreviewFrames(src.Path, src.Duration); err == nil && len(thumb) > 0 {
- src.PreviewFrames = thumb
- // Update thumbnail on UI thread
- fyne.CurrentApp().Driver().DoFromGoroutine(func() {
- thumbImg := canvas.NewImageFromFile(src.PreviewFrames[0])
- if thumbImg.Image != nil {
- img.Image = thumbImg.Image
- img.Refresh()
- }
- }, false)
- }
- }()
- return
- }
- // Load the first preview frame as thumbnail
- thumbImg := canvas.NewImageFromFile(src.PreviewFrames[0])
- if thumbImg.Image != nil {
- img.Image = thumbImg.Image
- img.Refresh()
- }
- }
-
// Helper to truncate filename if too long
truncateFilename := func(filename string, maxLen int) string {
if len(filename) <= maxLen {
@@ -5535,12 +5890,18 @@ func buildCompareView(state *appState) fyne.CanvasObject {
displayName := truncateFilename(filename, 35)
file1Label.SetText(fmt.Sprintf("File 1: %s", displayName))
file1Info.SetText(formatMetadata(state.compareFile1))
- loadThumbnail(state.compareFile1, file1Thumbnail)
+ // Build video player with compact size for side-by-side
+ file1VideoContainer.Objects = []fyne.CanvasObject{
+ buildVideoPane(state, fyne.NewSize(320, 180), state.compareFile1, nil),
+ }
+ file1VideoContainer.Refresh()
} else {
file1Label.SetText("File 1: Not loaded")
file1Info.SetText("No file loaded")
- file1Thumbnail.Image = nil
- file1Thumbnail.Refresh()
+ file1VideoContainer.Objects = []fyne.CanvasObject{
+ container.NewCenter(widget.NewLabel("No video loaded")),
+ }
+ file1VideoContainer.Refresh()
}
}
@@ -5550,12 +5911,18 @@ func buildCompareView(state *appState) fyne.CanvasObject {
displayName := truncateFilename(filename, 35)
file2Label.SetText(fmt.Sprintf("File 2: %s", displayName))
file2Info.SetText(formatMetadata(state.compareFile2))
- loadThumbnail(state.compareFile2, file2Thumbnail)
+ // Build video player with compact size for side-by-side
+ file2VideoContainer.Objects = []fyne.CanvasObject{
+ buildVideoPane(state, fyne.NewSize(320, 180), state.compareFile2, nil),
+ }
+ file2VideoContainer.Refresh()
} else {
file2Label.SetText("File 2: Not loaded")
file2Info.SetText("No file loaded")
- file2Thumbnail.Image = nil
- file2Thumbnail.Refresh()
+ file2VideoContainer.Objects = []fyne.CanvasObject{
+ container.NewCenter(widget.NewLabel("No video loaded")),
+ }
+ file2VideoContainer.Refresh()
}
}
@@ -5657,24 +6024,24 @@ func buildCompareView(state *appState) fyne.CanvasObject {
file2InfoScroll := container.NewVScroll(file2Info)
file2InfoScroll.SetMinSize(fyne.NewSize(250, 150))
- // File 1 column: header, thumb, metadata (using Border to make metadata expand)
+ // File 1 column: header, video player, metadata (using Border to make metadata expand)
file1Column := container.NewBorder(
container.NewVBox(
file1Header,
widget.NewSeparator(),
- container.NewMax(file1ThumbBg, file1Thumbnail),
+ file1VideoContainer,
widget.NewSeparator(),
),
nil, nil, nil,
file1InfoScroll,
)
- // File 2 column: header, thumb, metadata (using Border to make metadata expand)
+ // File 2 column: header, video player, metadata (using Border to make metadata expand)
file2Column := container.NewBorder(
container.NewVBox(
file2Header,
widget.NewSeparator(),
- container.NewMax(file2ThumbBg, file2Thumbnail),
+ file2VideoContainer,
widget.NewSeparator(),
),
nil, nil, nil,
@@ -5693,3 +6060,314 @@ func buildCompareView(state *appState) fyne.CanvasObject {
return container.NewBorder(topBar, bottomBar, nil, nil, content)
}
+
+// buildInspectView creates the UI for inspecting a single video with player
+func buildInspectView(state *appState) fyne.CanvasObject {
+ inspectColor := moduleColor("inspect")
+
+ // Back button
+ backBtn := widget.NewButton("< INSPECT", func() {
+ state.showMainMenu()
+ })
+ backBtn.Importance = widget.LowImportance
+
+ // Top bar with module color
+ topBar := ui.TintedBar(inspectColor, container.NewHBox(backBtn, layout.NewSpacer()))
+
+ // Instructions
+ instructions := widget.NewLabel("Load a video to inspect its properties and preview playback. Drag a video here or use the button below.")
+ instructions.Wrapping = fyne.TextWrapWord
+ instructions.Alignment = fyne.TextAlignCenter
+
+ // Clear button
+ clearBtn := widget.NewButton("Clear", func() {
+ state.inspectFile = nil
+ state.showInspectView()
+ })
+ clearBtn.Importance = widget.LowImportance
+
+ instructionsRow := container.NewBorder(nil, nil, nil, clearBtn, instructions)
+
+ // File label
+ fileLabel := widget.NewLabel("No file loaded")
+ fileLabel.TextStyle = fyne.TextStyle{Bold: true}
+
+ // Metadata text
+ metadataText := widget.NewLabel("No file loaded")
+ metadataText.Wrapping = fyne.TextWrapWord
+
+ // Metadata scroll
+ metadataScroll := container.NewScroll(metadataText)
+ metadataScroll.SetMinSize(fyne.NewSize(400, 200))
+
+ // Helper function to format metadata
+ formatMetadata := func(src *videoSource) string {
+ fileSize := "Unknown"
+ if fi, err := os.Stat(src.Path); err == nil {
+ sizeMB := float64(fi.Size()) / (1024 * 1024)
+ if sizeMB >= 1024 {
+ fileSize = fmt.Sprintf("%.2f GB", sizeMB/1024)
+ } else {
+ fileSize = fmt.Sprintf("%.2f MB", sizeMB)
+ }
+ }
+
+ return fmt.Sprintf(
+ "━━━ FILE INFO ━━━\n"+
+ "Path: %s\n"+
+ "File Size: %s\n"+
+ "Format: %s\n"+
+ "\n━━━ VIDEO ━━━\n"+
+ "Codec: %s\n"+
+ "Resolution: %dx%d\n"+
+ "Aspect Ratio: %s\n"+
+ "Frame Rate: %.2f fps\n"+
+ "Bitrate: %s\n"+
+ "Pixel Format: %s\n"+
+ "Color Space: %s\n"+
+ "Color Range: %s\n"+
+ "Field Order: %s\n"+
+ "GOP Size: %d\n"+
+ "\n━━━ AUDIO ━━━\n"+
+ "Codec: %s\n"+
+ "Bitrate: %s\n"+
+ "Sample Rate: %d Hz\n"+
+ "Channels: %d\n"+
+ "\n━━━ OTHER ━━━\n"+
+ "Duration: %s\n"+
+ "SAR (Pixel Aspect): %s\n"+
+ "Chapters: %v\n"+
+ "Metadata: %v",
+ filepath.Base(src.Path),
+ fileSize,
+ src.Format,
+ src.VideoCodec,
+ src.Width, src.Height,
+ src.AspectRatioString(),
+ src.FrameRate,
+ formatBitrate(src.Bitrate),
+ src.PixelFormat,
+ src.ColorSpace,
+ src.ColorRange,
+ src.FieldOrder,
+ src.GOPSize,
+ src.AudioCodec,
+ formatBitrate(src.AudioBitrate),
+ src.AudioRate,
+ src.Channels,
+ src.DurationString(),
+ src.SampleAspectRatio,
+ src.HasChapters,
+ src.HasMetadata,
+ )
+ }
+
+ // Video player container
+ var videoContainer fyne.CanvasObject = container.NewCenter(widget.NewLabel("No video loaded"))
+
+ // Update display function
+ updateDisplay := func() {
+ if state.inspectFile != nil {
+ filename := filepath.Base(state.inspectFile.Path)
+ // Truncate if too long
+ if len(filename) > 50 {
+ ext := filepath.Ext(filename)
+ nameWithoutExt := strings.TrimSuffix(filename, ext)
+ if len(ext) > 10 {
+ filename = filename[:47] + "..."
+ } else {
+ availableLen := 47 - len(ext)
+ if availableLen < 1 {
+ filename = filename[:47] + "..."
+ } else {
+ filename = nameWithoutExt[:availableLen] + "..." + ext
+ }
+ }
+ }
+ fileLabel.SetText(fmt.Sprintf("File: %s", filename))
+ metadataText.SetText(formatMetadata(state.inspectFile))
+
+ // Build video player
+ videoContainer = buildVideoPane(state, fyne.NewSize(640, 360), state.inspectFile, nil)
+ } else {
+ fileLabel.SetText("No file loaded")
+ metadataText.SetText("No file loaded")
+ videoContainer = container.NewCenter(widget.NewLabel("No video loaded"))
+ }
+ }
+
+ // Initialize display
+ updateDisplay()
+
+ // Load button
+ loadBtn := widget.NewButton("Load Video", func() {
+ dialog.ShowFileOpen(func(reader fyne.URIReadCloser, err error) {
+ if err != nil || reader == nil {
+ return
+ }
+ path := reader.URI().Path()
+ reader.Close()
+
+ src, err := probeVideo(path)
+ if err != nil {
+ dialog.ShowError(fmt.Errorf("failed to load video: %w", err), state.window)
+ return
+ }
+
+ state.inspectFile = src
+ state.showInspectView()
+ logging.Debug(logging.CatModule, "loaded inspect file: %s", path)
+ }, state.window)
+ })
+
+ // Copy metadata button
+ copyBtn := widget.NewButton("Copy Metadata", func() {
+ if state.inspectFile == nil {
+ return
+ }
+ metadata := formatMetadata(state.inspectFile)
+ state.window.Clipboard().SetContent(metadata)
+ dialog.ShowInformation("Copied", "Metadata copied to clipboard", state.window)
+ })
+ copyBtn.Importance = widget.LowImportance
+
+ // Action buttons
+ actionButtons := container.NewHBox(loadBtn, copyBtn, clearBtn)
+
+ // Main layout: left side is video player, right side is metadata
+ leftColumn := container.NewBorder(
+ fileLabel,
+ nil, nil, nil,
+ videoContainer,
+ )
+
+ rightColumn := container.NewBorder(
+ widget.NewLabel("Metadata:"),
+ nil, nil, nil,
+ metadataScroll,
+ )
+
+ // Bottom bar with module color
+ bottomBar := ui.TintedBar(inspectColor, container.NewHBox(layout.NewSpacer()))
+
+ // Main content
+ content := container.NewBorder(
+ container.NewVBox(instructionsRow, actionButtons, widget.NewSeparator()),
+ nil, nil, nil,
+ container.NewGridWithColumns(2, leftColumn, rightColumn),
+ )
+
+ return container.NewBorder(topBar, bottomBar, nil, nil, content)
+}
+// buildCompareFullscreenView creates fullscreen side-by-side comparison with synchronized controls
+func buildCompareFullscreenView(state *appState) fyne.CanvasObject {
+ compareColor := moduleColor("compare")
+
+ // Back button
+ backBtn := widget.NewButton("< BACK TO COMPARE", func() {
+ state.showCompareView()
+ })
+ backBtn.Importance = widget.LowImportance
+
+ // Top bar with module color
+ topBar := ui.TintedBar(compareColor, container.NewHBox(backBtn, layout.NewSpacer()))
+
+ // Video player containers - large size for fullscreen
+ file1VideoContainer := container.NewMax()
+ file2VideoContainer := container.NewMax()
+
+ // Build players if videos are loaded - use flexible size that won't force window expansion
+ if state.compareFile1 != nil {
+ file1VideoContainer.Objects = []fyne.CanvasObject{
+ buildVideoPane(state, fyne.NewSize(400, 225), state.compareFile1, nil),
+ }
+ } else {
+ file1VideoContainer.Objects = []fyne.CanvasObject{
+ container.NewCenter(widget.NewLabel("No video loaded")),
+ }
+ }
+
+ if state.compareFile2 != nil {
+ file2VideoContainer.Objects = []fyne.CanvasObject{
+ buildVideoPane(state, fyne.NewSize(400, 225), state.compareFile2, nil),
+ }
+ } else {
+ file2VideoContainer.Objects = []fyne.CanvasObject{
+ container.NewCenter(widget.NewLabel("No video loaded")),
+ }
+ }
+
+ // File labels
+ file1Name := "File 1: Not loaded"
+ if state.compareFile1 != nil {
+ file1Name = fmt.Sprintf("File 1: %s", filepath.Base(state.compareFile1.Path))
+ }
+
+ file2Name := "File 2: Not loaded"
+ if state.compareFile2 != nil {
+ file2Name = fmt.Sprintf("File 2: %s", filepath.Base(state.compareFile2.Path))
+ }
+
+ file1Label := widget.NewLabel(file1Name)
+ file1Label.TextStyle = fyne.TextStyle{Bold: true}
+ file1Label.Alignment = fyne.TextAlignCenter
+
+ file2Label := widget.NewLabel(file2Name)
+ file2Label.TextStyle = fyne.TextStyle{Bold: true}
+ file2Label.Alignment = fyne.TextAlignCenter
+
+ // Synchronized playback controls (note: actual sync would require VT_Player API enhancement)
+ playBtn := widget.NewButton("▶ Play Both", func() {
+ // TODO: When VT_Player API supports it, trigger synchronized playback
+ dialog.ShowInformation("Synchronized Playback",
+ "Synchronized playback control will be available when VT_Player API is enhanced.\n\n"+
+ "For now, use individual player controls.",
+ state.window)
+ })
+ playBtn.Importance = widget.HighImportance
+
+ pauseBtn := widget.NewButton("⏸ Pause Both", func() {
+ // TODO: Synchronized pause
+ dialog.ShowInformation("Synchronized Playback",
+ "Synchronized playback control will be available when VT_Player API is enhanced.",
+ state.window)
+ })
+
+ syncControls := container.NewHBox(
+ layout.NewSpacer(),
+ playBtn,
+ pauseBtn,
+ layout.NewSpacer(),
+ )
+
+ // Info text
+ infoLabel := widget.NewLabel("Side-by-side fullscreen comparison. Use individual player controls until synchronized playback is implemented in VT_Player.")
+ infoLabel.Wrapping = fyne.TextWrapWord
+ infoLabel.Alignment = fyne.TextAlignCenter
+
+ // Left column (File 1)
+ leftColumn := container.NewBorder(
+ file1Label,
+ nil, nil, nil,
+ file1VideoContainer,
+ )
+
+ // Right column (File 2)
+ rightColumn := container.NewBorder(
+ file2Label,
+ nil, nil, nil,
+ file2VideoContainer,
+ )
+
+ // Bottom bar with module color
+ bottomBar := ui.TintedBar(compareColor, container.NewHBox(layout.NewSpacer()))
+
+ // Main content
+ content := container.NewBorder(
+ container.NewVBox(infoLabel, syncControls, widget.NewSeparator()),
+ nil, nil, nil,
+ container.NewGridWithColumns(2, leftColumn, rightColumn),
+ )
+
+ return container.NewBorder(topBar, bottomBar, nil, nil, content)
+}