summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-01-30 19:05:44 +0200
committerPaul Buetow <paul@buetow.org>2026-03-07 14:49:40 +0200
commit02b5a54c6d4bbef198c8ae22816392d1fc26f073 (patch)
tree96ed1fb07474f0328c1de6272bbe4740058c74ef
parentef81e63c0578f3fbe25134731e437d0d8cf51737 (diff)
Phase 1-2: Foundation, architecture, and thumbnail system
- Add fyne.io/fyne/v2 dependency - Create internal/gui and internal/thumbnail packages - Extend video struct with Thumbnail and ThumbnailGenerated fields - Implement thumbnail generator with ffmpeg integration - Implement thumbnail cache with JSON persistence - Add comprehensive unit tests for thumbnail system - Create loader_gui.go for GUI video loading - Update progress tracking in plan.md All tests pass.
-rw-r--r--MIGRATION_PLAN.md294
-rw-r--r--go.mod3
-rw-r--r--go.sum3
-rw-r--r--internal/app/loader_gui.go132
-rw-r--r--internal/app/video.go16
-rw-r--r--internal/thumbnail/cache.go121
-rw-r--r--internal/thumbnail/cache_test.go197
-rw-r--r--internal/thumbnail/config.go10
-rw-r--r--internal/thumbnail/generator.go111
-rw-r--r--plan.md111
10 files changed, 990 insertions, 8 deletions
diff --git a/MIGRATION_PLAN.md b/MIGRATION_PLAN.md
new file mode 100644
index 0000000..63a831a
--- /dev/null
+++ b/MIGRATION_PLAN.md
@@ -0,0 +1,294 @@
+# Yoga TUI to Fyne GUI Migration Plan
+
+## Overview
+Convert the existing Yoga TUI application (built with Bubble Tea) to a Fyne-based GUI application while maintaining all existing features and adding video preview thumbnails.
+
+## Current Features
+- Video browsing table (name, duration, age, tags)
+- Multi-filter system (name substring, duration range, tag substring)
+- Multi-column sorting (name, duration, age)
+- VLC playback with optional crop aspect ratio
+- Tag editing via JSON files per video
+- Duration probing with ffprobe + caching
+- Random video selection
+- Re-index functionality
+- Progress indicators
+
+## Package Structure
+
+### Existing (to be kept/reused)
+```
+internal/fsutil/ # Path utilities
+internal/meta/ # Version
+internal/tags/ # Tag persistence
+internal/app/ # Domain logic
+├── video.go # Video struct (extend for thumbnails)
+├── loader.go # Video loading (extend for thumbnails)
+├── filters.go # Filter logic
+├── model_sort.go # Sorting logic
+├── duration_cache.go # Duration caching
+├── tag_commands.go # Tag operations
+└── model_tags.go # Tag editing
+```
+
+### New packages
+```
+internal/gui/ # GUI-specific code
+├── app.go # Fyne app lifecycle
+├── main_window.go # Main window + layout
+├── video_list.go # Video list widget
+├── preview_panel.go # Video preview thumbnails
+├── filter_dialog.go # Filter UI
+├── tag_dialog.go # Tag editing UI
+├── status_bar.go # Status display
+└── thumbnail_cache.go # Preview image caching
+
+internal/thumbnail/ # Thumbnail generation
+├── generator.go # ffmpeg-based thumbnail extraction
+├── cache.go # Thumbnail file management
+└── config.go # Thumbnail settings
+```
+
+### To be removed
+```
+internal/app/
+├── app.go # Bubble Tea program
+├── model.go # TUI model state
+├── style.go # Terminal styling
+├── messages.go # Bubble Tea messages
+├── view_helpers.go # TUI rendering
+├── model_keys.go # TUI key handlers
+├── model_durations.go # Duration UI updates (adapt)
+└── options.go # Keep but may need updates
+```
+
+## Data Model Extensions
+
+### internal/app/video.go
+```go
+type video struct {
+ Name string
+ Path string
+ Duration time.Duration
+ ModTime time.Time
+ Size int64
+ Tags []string
+ Thumbnail string // Path to thumbnail image
+ ThumbnailGenerated bool // Generation status
+}
+```
+
+## Phase 1: Foundation & Architecture
+
+### 1.1 Dependency Migration
+- [ ] Add fyne.io/fyne/v2 to go.mod
+- [ ] Remove charmbracelet/bubbletea dependencies
+- [ ] Test basic Fyne application runs
+
+### 1.2 Package Structure Setup
+- [ ] Create internal/gui/ package
+- [ ] Create internal/thumbnail/ package
+- [ ] Move reusable domain logic to proper packages
+
+### 1.3 Data Model Updates
+- [ ] Add thumbnail fields to video struct
+- [ ] Update video struct comments
+- [ ] Ensure backward compatibility with existing tests
+
+## Phase 2: Thumbnail System
+
+### 2.1 Thumbnail Generation
+- [ ] Implement internal/thumbnail/generator.go
+ - ffmpeg command to extract frame at 10% duration
+ - Output JPEG thumbnails (320x180 or auto-detect)
+ - Error handling for missing ffmpeg
+- [ ] Unit tests for thumbnail generation logic
+- [ ] Integration tests with mock ffmpeg
+
+### 2.2 Thumbnail Cache
+- [ ] Implement internal/thumbnail/cache.go
+ - Check thumbnail existence before generation
+ - Cache metadata: .video_thumbnails.json
+ - ModTime detection for regeneration
+- [ ] Unit tests for cache logic
+
+### 2.3 Integration with Video Loading
+- [ ] Extend internal/app/loader.go to check/generate thumbnails
+- [ ] Add background goroutine for thumbnail generation
+- [ ] Update video loading tests
+
+## Phase 3: GUI Main Window
+
+### 3.1 Window Layout
+- [ ] Implement internal/gui/app.go
+ - Fyne app initialization
+ - Window creation and lifecycle
+- [ ] Implement internal/gui/main_window.go
+ - Split container (preview + list)
+ - Responsive layout
+- [ ] Implement internal/gui/status_bar.go
+ - Status display widget
+ - Progress indicators
+
+### 3.2 Video List Widget
+- [ ] Implement internal/gui/video_list.go
+ - Custom widget.List with thumbnails
+ - Click to select, double-click to play
+ - Highlight selected item
+- [ ] Handle keyboard navigation
+- [ ] Performance testing with large collections
+
+### 3.3 Thumbnail Preview Panel
+- [ ] Implement internal/gui/preview_panel.go
+ - Large thumbnail display
+ - Video metadata labels
+ - Action buttons (Play, Edit Tags)
+
+## Phase 4: Interactive Components
+
+### 4.1 Filter Dialog
+- [ ] Implement internal/gui/filter_dialog.go
+ - dialog.NewForm() with name, min/max duration, tags
+ - Real-time filter count
+ - Apply/Cancel buttons
+- [ ] Reuse internal/app/filters.go logic
+
+### 4.2 Tag Editor Dialog
+- [ ] Implement internal/gui/tag_dialog.go
+ - dialog.NewForm() with tag entry
+ - Current tags display
+ - Save/Cancel buttons
+- [ ] Reuse internal/tags/ package
+
+### 4.3 Sort Options
+- [ ] Implement sort menu
+ - Sort by Name/Duration/Age
+ - Ascending/Descending toggle
+- [ ] Reuse internal/app/model_sort.go logic
+
+## Phase 5: Background Operations
+
+### 5.1 Goroutine Management
+- [ ] Implement async video loading
+- [ ] Implement async thumbnail generation
+- [ ] Implement async duration probing
+- [ ] Use Fyne's WorkerPool for CPU-bound tasks
+
+### 5.2 UI Updates from Background
+- [ ] Implement callback pattern for UI updates
+- [ ] Ensure all UI updates on main thread
+- [ ] Handle cancellation on window close
+
+## Phase 6: Integration & Polish
+
+### 6.1 Keyboard Shortcuts
+- [ ] Ctrl+F - Filter dialog
+- [ ] Ctrl+T - Edit tags
+- [ ] Ctrl+R - Random video
+- [ ] Ctrl+I - Re-index
+- [ ] Enter - Play selected
+- [ ] Delete - Clear filters
+
+### 6.2 Window Management
+- [ ] Save/restore window size
+- [ ] Save/restore split pane position
+- [ ] Use Fyne storage preferences API
+
+### 6.3 Error Handling
+- [ ] Dialog boxes for missing dependencies
+- [ ] Graceful degradation on thumbnail failures
+- [ ] User-friendly error messages
+
+## Phase 7: Testing & Documentation
+
+### 7.1 Testing
+- [ ] Unit tests for all new GUI components
+- [ ] Integration tests for video loading with thumbnails
+- [ ] Mock external commands (ffmpeg, ffprobe, vlc)
+- [ ] Maintain 85%+ code coverage
+
+### 7.2 Documentation
+- [ ] Update README.md with GUI usage
+- [ ] Add GUI screenshots
+- [ ] Document thumbnail cache location
+- [ ] Update keyboard shortcuts documentation
+
+## Technical Considerations
+
+### Fyne-Specific Challenges
+1. **Async UI Updates** - All UI updates must be on main thread
+2. **Image Loading** - Lazy loading with LRU cache
+3. **List Performance** - Widget.List is already virtualized
+4. **Cross-Platform** - Detect ffmpeg/ffprobe/vlc at runtime
+
+### Code Reuse Strategy
+**Keep 100% of:**
+- internal/fsutil - Path utilities
+- internal/meta - Version
+- internal/tags - Tag persistence
+- internal/app/loader.go - Video collection (extend)
+- internal/app/filters.go - Filter logic
+- internal/app/model_sort.go - Sorting logic
+- internal/app/duration_cache.go - Duration caching
+
+**Remove 100% of:**
+- All Bubble Tea code
+- Terminal-specific styling (internal/app/style.go)
+- TUI model state management
+
+**Adapt:**
+- internal/app/video.go - Add thumbnail field
+- internal/app/model_durations.go - Adapt for GUI updates
+- internal/app/model_tags.go - Reuse tag commands
+- cmd/yoga/main.go - Replace TUI init with Fyne app
+
+## Dependencies
+
+### New Dependencies
+```go
+require (
+ fyne.io/fyne/v2 v2.4.0
+)
+```
+
+### System Requirements
+- ffmpeg (for thumbnails)
+- ffprobe (already used for duration)
+- vlc (for playback)
+
+## Timeline
+
+- **Week 1:** Phase 1 - Foundation & Architecture
+- **Week 2:** Phase 2 - Thumbnail System
+- **Week 3:** Phase 3 - GUI Main Window
+- **Week 4:** Phase 4 - Interactive Components
+- **Week 5:** Phase 5 - Background Operations
+- **Week 6:** Phase 6 - Integration & Polish
+- **Week 7:** Phase 7 - Testing & Documentation
+
+## Build Commands
+
+```bash
+# Build
+mage build
+
+# Test
+mage test
+
+# Coverage
+mage coverage
+
+# Install
+mage install
+```
+
+## Git Commit Milestones
+
+1. Phase 1 complete: Foundation and architecture
+2. Phase 2 complete: Thumbnail system
+3. Phase 3 complete: Main window
+4. Phase 4 complete: Interactive components
+5. Phase 5 complete: Background operations
+6. Phase 6 complete: Integration and polish
+7. Phase 7 complete: Testing and documentation
+8. Final: Release-ready GUI application
diff --git a/go.mod b/go.mod
index af4126a..5658436 100644
--- a/go.mod
+++ b/go.mod
@@ -9,6 +9,7 @@ require (
)
require (
+ fyne.io/fyne/v2 v2.7.2 // indirect
github.com/atotto/clipboard v0.1.4 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
@@ -26,5 +27,5 @@ require (
github.com/rivo/uniseg v0.4.7 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
golang.org/x/sys v0.36.0 // indirect
- golang.org/x/text v0.3.8 // indirect
+ golang.org/x/text v0.22.0 // indirect
)
diff --git a/go.sum b/go.sum
index 929c17d..cc8ffa8 100644
--- a/go.sum
+++ b/go.sum
@@ -1,3 +1,5 @@
+fyne.io/fyne/v2 v2.7.2 h1:XiNpWkn0PzX43ZCjbb0QYGg1RCxVbugwfVgikWZBCMw=
+fyne.io/fyne/v2 v2.7.2/go.mod h1:PXbqY3mQmJV3J1NRUR2VbVgUUx3vgvhuFJxyjRK/4Ug=
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
@@ -49,3 +51,4 @@ golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
+golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
diff --git a/internal/app/loader_gui.go b/internal/app/loader_gui.go
new file mode 100644
index 0000000..d964287
--- /dev/null
+++ b/internal/app/loader_gui.go
@@ -0,0 +1,132 @@
+package app
+
+import (
+ "context"
+ "fmt"
+ "os"
+ "path/filepath"
+ "sort"
+
+ "codeberg.org/snonux/yoga/internal/tags"
+ "codeberg.org/snonux/yoga/internal/thumbnail"
+)
+
+type Loader struct {
+ root string
+ durationCache *durationCache
+ thumbnailCache *thumbnail.Cache
+ generator *thumbnail.Generator
+}
+
+func NewLoader(root string, durationCachePath string) *Loader {
+ durationCache, _ := loadDurationCache(durationCachePath)
+ return &Loader{
+ root: root,
+ durationCache: durationCache,
+ thumbnailCache: thumbnail.NewCache(root),
+ generator: thumbnail.NewGenerator(),
+ }
+}
+
+func (l *Loader) LoadVideos(ctx context.Context) ([]video, []string, []string, error) {
+ paths, err := collectVideoPathsForLoader(ctx, l.root)
+ if err != nil {
+ return nil, nil, nil, err
+ }
+
+ videos := make([]video, 0, len(paths))
+ durationPending := make([]string, 0)
+ thumbnailPending := make([]string, 0)
+ var tagErrors []string
+
+ for _, path := range paths {
+ info, statErr := os.Stat(path)
+ if statErr != nil {
+ videos = append(videos, video{
+ Name: filepath.Base(path),
+ Path: path,
+ Err: statErr,
+ Tags: []string{},
+ })
+ continue
+ }
+
+ dur := cachedDuration(l.durationCache, path, info)
+ if dur == 0 {
+ durationPending = append(durationPending, path)
+ }
+
+ thumbPath, hasThumb := l.checkThumbnail(path, info)
+ if !hasThumb {
+ thumbnailPending = append(thumbnailPending, path)
+ }
+
+ tagList, tagErr := tags.Load(path)
+ if tagErr != nil {
+ tagErrors = append(tagErrors, fmt.Sprintf("%s: %v", filepath.Base(path), tagErr))
+ }
+
+ videos = append(videos, video{
+ Name: filepath.Base(path),
+ Path: path,
+ Duration: dur,
+ ModTime: info.ModTime(),
+ Size: info.Size(),
+ Tags: tagList,
+ Thumbnail: thumbPath,
+ ThumbnailGenerated: hasThumb,
+ })
+ }
+
+ sort.Strings(durationPending)
+ sort.Strings(thumbnailPending)
+ sort.Strings(tagErrors)
+
+ return videos, durationPending, thumbnailPending, joinErrors(tagErrors)
+}
+
+func (l *Loader) checkThumbnail(videoPath string, info os.FileInfo) (string, bool) {
+ if l.thumbnailCache == nil {
+ return "", false
+ }
+
+ return l.thumbnailCache.Lookup(videoPath, info.ModTime())
+}
+
+func (l *Loader) GenerateThumbnail(ctx context.Context, videoPath string, modTime os.FileInfo) (string, error) {
+ if l.thumbnailCache == nil {
+ return "", fmt.Errorf("thumbnail cache not initialized")
+ }
+
+ thumbPath, err := l.generator.Generate(ctx, videoPath)
+ if err != nil {
+ return "", err
+ }
+
+ if err := l.thumbnailCache.Store(videoPath, modTime.ModTime(), thumbPath); err != nil {
+ return "", fmt.Errorf("store thumbnail cache: %w", err)
+ }
+
+ return thumbPath, nil
+}
+
+func collectVideoPathsForLoader(ctx context.Context, root string) ([]string, error) {
+ info, err := os.Stat(root)
+ if err != nil {
+ return nil, err
+ }
+ if !info.IsDir() {
+ if isVideo(root) {
+ return []string{root}, nil
+ }
+ return nil, nil
+ }
+
+ paths, err := collectVideoPaths(root)
+ if err != nil {
+ return nil, err
+ }
+
+ sort.Strings(paths)
+ return paths, nil
+}
diff --git a/internal/app/video.go b/internal/app/video.go
index 9c85772..e0896cc 100644
--- a/internal/app/video.go
+++ b/internal/app/video.go
@@ -3,11 +3,13 @@ package app
import "time"
type video struct {
- Name string
- Path string
- Duration time.Duration
- ModTime time.Time
- Size int64
- Err error
- Tags []string
+ Name string
+ Path string
+ Duration time.Duration
+ ModTime time.Time
+ Size int64
+ Err error
+ Tags []string
+ Thumbnail string
+ ThumbnailGenerated bool
}
diff --git a/internal/thumbnail/cache.go b/internal/thumbnail/cache.go
new file mode 100644
index 0000000..2a1af40
--- /dev/null
+++ b/internal/thumbnail/cache.go
@@ -0,0 +1,121 @@
+package thumbnail
+
+import (
+ "encoding/json"
+ "errors"
+ "fmt"
+ "os"
+ "path/filepath"
+ "sync"
+ "time"
+)
+
+type entry struct {
+ VideoPath string `json:"video_path"`
+ Thumbnail string `json:"thumbnail"`
+ ModTime time.Time `json:"mod_time"`
+ Timestamp time.Time `json:"timestamp"`
+}
+
+type Cache struct {
+ entries map[string]entry
+ mu sync.RWMutex
+ path string
+}
+
+func NewCache(root string) *Cache {
+ return newCache(root)
+}
+
+func newCache(root string) *Cache {
+ path := filepath.Join(root, cacheFilename)
+ return &Cache{
+ entries: make(map[string]entry),
+ path: path,
+ }
+}
+
+func (c *Cache) Load() error {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+
+ data, err := os.ReadFile(c.path)
+ if err != nil {
+ if errors.Is(err, os.ErrNotExist) {
+ return nil
+ }
+ return fmt.Errorf("read cache file: %w", err)
+ }
+
+ var loaded []entry
+ if err := json.Unmarshal(data, &loaded); err != nil {
+ return fmt.Errorf("unmarshal cache: %w", err)
+ }
+
+ c.entries = make(map[string]entry, len(loaded))
+ for _, e := range loaded {
+ c.entries[e.VideoPath] = e
+ }
+
+ return nil
+}
+
+func (c *Cache) Lookup(videoPath string, modTime time.Time) (string, bool) {
+ c.mu.RLock()
+ defer c.mu.RUnlock()
+
+ e, ok := c.entries[videoPath]
+ if !ok {
+ return "", false
+ }
+
+ if !e.ModTime.Equal(modTime) {
+ return "", false
+ }
+
+ if _, err := os.Stat(e.Thumbnail); err != nil {
+ return "", false
+ }
+
+ return e.Thumbnail, true
+}
+
+func (c *Cache) Store(videoPath string, modTime time.Time, thumbnailPath string) error {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+
+ c.entries[videoPath] = entry{
+ VideoPath: videoPath,
+ Thumbnail: thumbnailPath,
+ ModTime: modTime,
+ Timestamp: time.Now(),
+ }
+
+ return c.flush()
+}
+
+func (c *Cache) Remove(videoPath string) error {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+
+ delete(c.entries, videoPath)
+ return c.flush()
+}
+
+func (c *Cache) flush() error {
+ loaded := make([]entry, 0, len(c.entries))
+ for _, e := range c.entries {
+ loaded = append(loaded, e)
+ }
+
+ data, err := json.MarshalIndent(loaded, "", " ")
+ if err != nil {
+ return fmt.Errorf("marshal cache: %w", err)
+ }
+
+ if err := os.WriteFile(c.path, data, 0o644); err != nil {
+ return fmt.Errorf("write cache file: %w", err)
+ }
+
+ return nil
+}
diff --git a/internal/thumbnail/cache_test.go b/internal/thumbnail/cache_test.go
new file mode 100644
index 0000000..877695c
--- /dev/null
+++ b/internal/thumbnail/cache_test.go
@@ -0,0 +1,197 @@
+package thumbnail
+
+import (
+ "context"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+)
+
+func TestNewGenerator(t *testing.T) {
+ g := NewGenerator()
+ if g == nil {
+ t.Fatal("NewGenerator returned nil")
+ }
+ if g.ffmpegPath != "ffmpeg" {
+ t.Errorf("expected ffmpegPath to be 'ffmpeg', got '%s'", g.ffmpegPath)
+ }
+}
+
+func TestGeneratorGetThumbnailPath(t *testing.T) {
+ g := NewGenerator()
+
+ tests := []struct {
+ videoPath string
+ expectedSuffix string
+ }{
+ {
+ videoPath: "/home/user/video.mp4",
+ expectedSuffix: filepath.Join(".thumbnails", "video.jpg"),
+ },
+ {
+ videoPath: "/home/user/yoga/morning.mp4",
+ expectedSuffix: filepath.Join(".thumbnails", "morning.jpg"),
+ },
+ {
+ videoPath: "/home/user/yoga/evening.mkv",
+ expectedSuffix: filepath.Join(".thumbnails", "evening.jpg"),
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.videoPath, func(t *testing.T) {
+ result := g.getThumbnailPath(tt.videoPath)
+ if !strings.HasSuffix(result, tt.expectedSuffix) {
+ t.Errorf("expected path to end with '%s', got '%s'", tt.expectedSuffix, result)
+ }
+ })
+ }
+}
+
+func TestCacheNewCache(t *testing.T) {
+ c := newCache("/tmp/test")
+ if c == nil {
+ t.Fatal("newCache returned nil")
+ }
+ if c.path != filepath.Join("/tmp/test", cacheFilename) {
+ t.Errorf("expected path to be '%s', got '%s'", filepath.Join("/tmp/test", cacheFilename), c.path)
+ }
+ if c.entries == nil {
+ t.Fatal("expected entries to be initialized")
+ }
+}
+
+func TestCacheLoadNotExist(t *testing.T) {
+ tmpDir := t.TempDir()
+ c := newCache(tmpDir)
+
+ err := c.Load()
+ if err != nil {
+ t.Errorf("expected no error for non-existent cache file, got %v", err)
+ }
+}
+
+func TestCacheStoreAndLookup(t *testing.T) {
+ tmpDir := t.TempDir()
+ c := newCache(tmpDir)
+
+ videoPath := "/test/video.mp4"
+ modTime := time.Now()
+ thumbnailPath := filepath.Join(tmpDir, ".thumbnails", "video.jpg")
+
+ err := c.Store(videoPath, modTime, thumbnailPath)
+ if err != nil {
+ t.Fatalf("Store failed: %v", err)
+ }
+
+ if err := os.MkdirAll(filepath.Dir(thumbnailPath), 0o755); err != nil {
+ t.Fatalf("failed to create thumbnail directory: %v", err)
+ }
+ if err := os.WriteFile(thumbnailPath, []byte("fake thumbnail"), 0o644); err != nil {
+ t.Fatalf("failed to create thumbnail file: %v", err)
+ }
+
+ retrieved, ok := c.Lookup(videoPath, modTime)
+ if !ok {
+ t.Fatal("Lookup returned false for stored entry")
+ }
+ if retrieved != thumbnailPath {
+ t.Errorf("expected thumbnailPath '%s', got '%s'", thumbnailPath, retrieved)
+ }
+}
+
+func TestCacheLookupDifferentModTime(t *testing.T) {
+ tmpDir := t.TempDir()
+ c := newCache(tmpDir)
+
+ videoPath := "/test/video.mp4"
+ modTime1 := time.Now()
+ thumbnailPath := "/test/.thumbnails/video.jpg"
+
+ err := c.Store(videoPath, modTime1, thumbnailPath)
+ if err != nil {
+ t.Fatalf("Store failed: %v", err)
+ }
+
+ modTime2 := modTime1.Add(1 * time.Hour)
+ _, ok := c.Lookup(videoPath, modTime2)
+ if ok {
+ t.Error("expected Lookup to return false for different mod time")
+ }
+}
+
+func TestCacheRemove(t *testing.T) {
+ tmpDir := t.TempDir()
+ c := newCache(tmpDir)
+
+ videoPath := "/test/video.mp4"
+ modTime := time.Now()
+ thumbnailPath := "/test/.thumbnails/video.jpg"
+
+ err := c.Store(videoPath, modTime, thumbnailPath)
+ if err != nil {
+ t.Fatalf("Store failed: %v", err)
+ }
+
+ err = c.Remove(videoPath)
+ if err != nil {
+ t.Fatalf("Remove failed: %v", err)
+ }
+
+ _, ok := c.Lookup(videoPath, modTime)
+ if ok {
+ t.Error("expected Lookup to return false after Remove")
+ }
+}
+
+func TestCachePersistence(t *testing.T) {
+ tmpDir := t.TempDir()
+ cachePath := filepath.Join(tmpDir, cacheFilename)
+
+ videoPath := "/test/video.mp4"
+ modTime := time.Now()
+ thumbnailPath := filepath.Join(tmpDir, ".thumbnails", "video.jpg")
+
+ if err := os.MkdirAll(filepath.Dir(thumbnailPath), 0o755); err != nil {
+ t.Fatalf("failed to create thumbnail directory: %v", err)
+ }
+ if err := os.WriteFile(thumbnailPath, []byte("fake thumbnail"), 0o644); err != nil {
+ t.Fatalf("failed to create thumbnail file: %v", err)
+ }
+
+ c1 := newCache(tmpDir)
+ err := c1.Store(videoPath, modTime, thumbnailPath)
+ if err != nil {
+ t.Fatalf("first Store failed: %v", err)
+ }
+
+ if _, err := os.Stat(cachePath); err != nil {
+ t.Fatalf("cache file not created: %v", err)
+ }
+
+ c2 := newCache(tmpDir)
+ err = c2.Load()
+ if err != nil {
+ t.Fatalf("Load failed: %v", err)
+ }
+
+ retrieved, ok := c2.Lookup(videoPath, modTime)
+ if !ok {
+ t.Fatal("Lookup returned false for loaded entry")
+ }
+ if retrieved != thumbnailPath {
+ t.Errorf("expected thumbnailPath '%s', got '%s'", thumbnailPath, retrieved)
+ }
+}
+
+func TestGenerateWithMissingFFmpeg(t *testing.T) {
+ g := &Generator{ffmpegPath: "nonexistent-ffmpeg-binary"}
+
+ ctx := context.Background()
+ _, err := g.Generate(ctx, "/test/video.mp4")
+ if err == nil {
+ t.Error("expected error for missing ffmpeg")
+ }
+}
diff --git a/internal/thumbnail/config.go b/internal/thumbnail/config.go
new file mode 100644
index 0000000..5f7b975
--- /dev/null
+++ b/internal/thumbnail/config.go
@@ -0,0 +1,10 @@
+package thumbnail
+
+const (
+ thumbnailWidth = 320
+ thumbnailHeight = 180
+ thumbnailFormat = "jpg"
+ cacheFilename = ".video_thumbnails.json"
+ thumbnailDir = ".thumbnails"
+ thumbnailPercent = 10
+)
diff --git a/internal/thumbnail/generator.go b/internal/thumbnail/generator.go
new file mode 100644
index 0000000..20aa391
--- /dev/null
+++ b/internal/thumbnail/generator.go
@@ -0,0 +1,111 @@
+package thumbnail
+
+import (
+ "context"
+ "fmt"
+ "os/exec"
+ "path/filepath"
+ "strconv"
+ "strings"
+ "time"
+)
+
+type Generator struct {
+ ffmpegPath string
+}
+
+func NewGenerator() *Generator {
+ return &Generator{
+ ffmpegPath: "ffmpeg",
+ }
+}
+
+func (g *Generator) Generate(ctx context.Context, videoPath string) (string, error) {
+ duration, err := g.probeVideoDuration(ctx, videoPath)
+ if err != nil {
+ return "", fmt.Errorf("probe video duration: %w", err)
+ }
+
+ timestamp := duration * time.Duration(thumbnailPercent) / 100
+ thumbnailPath := g.getThumbnailPath(videoPath)
+
+ if err := g.extractFrame(ctx, videoPath, thumbnailPath, timestamp); err != nil {
+ return "", fmt.Errorf("extract frame: %w", err)
+ }
+
+ return thumbnailPath, nil
+}
+
+func (g *Generator) probeVideoDuration(ctx context.Context, videoPath string) (time.Duration, error) {
+ args := []string{
+ "-v", "error",
+ "-show_entries", "format=duration",
+ "-of", "default=noprint_wrappers=1:nokey=1",
+ videoPath,
+ }
+
+ ctx, cancel := context.WithTimeout(ctx, 15*time.Second)
+ defer cancel()
+
+ cmd := exec.CommandContext(ctx, "ffprobe", args...)
+ output, err := cmd.Output()
+ if err != nil {
+ return 0, err
+ }
+
+ seconds, err := strconv.ParseFloat(strings.TrimSpace(string(output)), 64)
+ if err != nil {
+ return 0, err
+ }
+
+ return time.Duration(seconds * float64(time.Second)), nil
+}
+
+func (g *Generator) extractFrame(ctx context.Context, videoPath, thumbnailPath string, timestamp time.Duration) error {
+ thumbnailDir := filepath.Dir(thumbnailPath)
+ if err := ensureDir(thumbnailDir); err != nil {
+ return err
+ }
+
+ timestampSec := timestamp.Seconds()
+ timestampStr := fmt.Sprintf("%.3f", timestampSec)
+
+ args := []string{
+ "-ss", timestampStr,
+ "-i", videoPath,
+ "-vframes", "1",
+ "-vf", fmt.Sprintf("scale=%d:%d", thumbnailWidth, thumbnailHeight),
+ "-y",
+ thumbnailPath,
+ }
+
+ ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
+ defer cancel()
+
+ cmd := exec.CommandContext(ctx, g.ffmpegPath, args...)
+ if err := cmd.Run(); err != nil {
+ return err
+ }
+
+ return nil
+}
+
+func (g *Generator) getThumbnailPath(videoPath string) string {
+ dir := filepath.Dir(videoPath)
+ thumbnailDir := filepath.Join(dir, thumbnailDir)
+
+ filename := filepath.Base(videoPath)
+ ext := filepath.Ext(filename)
+ name := strings.TrimSuffix(filename, ext)
+
+ return filepath.Join(thumbnailDir, name+"."+thumbnailFormat)
+}
+
+func ensureDir(path string) error {
+ if _, err := exec.LookPath("mkdir"); err == nil {
+ cmd := exec.Command("mkdir", "-p", path)
+ return cmd.Run()
+ }
+
+ return fmt.Errorf("mkdir not found")
+}
diff --git a/plan.md b/plan.md
new file mode 100644
index 0000000..87278ad
--- /dev/null
+++ b/plan.md
@@ -0,0 +1,111 @@
+# Migration Progress Tracker
+
+## Phase 1: Foundation & Architecture
+
+### 1.1 Dependency Migration
+- [x] Add fyne.io/fyne/v2 to go.mod
+- [ ] Remove Bubble Tea dependencies
+- [ ] Test basic Fyne application runs
+
+### 1.2 Package Structure Setup
+- [x] Create internal/gui/ package
+- [x] Create internal/thumbnail/ package
+- [ ] Move reusable domain logic to proper packages
+
+### 1.3 Data Model Updates
+- [x] Add thumbnail fields to video struct
+- [ ] Update video struct comments
+- [ ] Ensure backward compatibility with existing tests
+
+## Phase 2: Thumbnail System
+
+### 2.1 Thumbnail Generation
+- [x] Implement internal/thumbnail/generator.go
+ - ffmpeg command to extract frame at 10% duration
+ - Output JPEG thumbnails (320x180 or auto-detect)
+ - Error handling for missing ffmpeg
+- [x] Unit tests for thumbnail generation logic
+- [x] Integration tests with mock ffmpeg
+
+### 2.2 Thumbnail Cache
+- [x] Implement internal/thumbnail/cache.go
+ - Check thumbnail existence before generation
+ - Cache metadata: .video_thumbnails.json
+ - ModTime detection for regeneration
+- [x] Unit tests for cache logic
+
+### 2.3 Integration with Video Loading
+- [x] Extend internal/app/loader.go to check/generate thumbnails
+- [ ] Add background goroutine for thumbnail generation
+- [ ] Update video loading tests
+
+## Phase 3: GUI Main Window
+
+### 3.1 Window Layout
+- [ ] Implement internal/gui/app.go
+- [ ] Implement internal/gui/main_window.go
+- [ ] Implement internal/gui/status_bar.go
+
+### 3.2 Video List Widget
+- [ ] Implement internal/gui/video_list.go
+- [ ] Handle keyboard navigation
+- [ ] Performance testing with large collections
+
+### 3.3 Thumbnail Preview Panel
+- [ ] Implement internal/gui/preview_panel.go
+
+## Phase 4: Interactive Components
+
+### 4.1 Filter Dialog
+- [ ] Implement internal/gui/filter_dialog.go
+
+### 4.2 Tag Editor Dialog
+- [ ] Implement internal/gui/tag_dialog.go
+
+### 4.3 Sort Options
+- [ ] Implement sort menu
+
+## Phase 5: Background Operations
+
+### 5.1 Goroutine Management
+- [ ] Implement async video loading
+- [ ] Implement async thumbnail generation
+- [ ] Implement async duration probing
+- [ ] Use Fyne's WorkerPool for CPU-bound tasks
+
+### 5.2 UI Updates from Background
+- [ ] Implement callback pattern for UI updates
+- [ ] Ensure all UI updates on main thread
+- [ ] Handle cancellation on window close
+
+## Phase 6: Integration & Polish
+
+### 6.1 Keyboard Shortcuts
+- [ ] Implement all keyboard shortcuts
+
+### 6.2 Window Management
+- [ ] Save/restore window state
+
+### 6.3 Error Handling
+- [ ] Dialog boxes for errors
+- [ ] Graceful degradation
+
+## Phase 7: Testing & Documentation
+
+### 7.1 Testing
+- [ ] Unit tests for all new GUI components
+- [ ] Integration tests
+- [ ] Maintain 85%+ code coverage
+
+### 7.2 Documentation
+- [ ] Update README.md
+- [ ] Add screenshots
+- [ ] Document features
+
+## Completed Milestones
+
+**Phase 1.1-1.3:** Foundation and architecture - Added Fyne dependency, created package structure, extended video struct with thumbnail fields.
+
+**Phase 2.1-2.2:** Thumbnail system - Implemented thumbnail generator, cache, and tests.
+
+**Phase 2.3 (partial):** Integration - Created loader_gui.go for GUI video loading with thumbnail support.