Files
yellowjacket/docs/dev/roadmap.md
T

45 KiB

Yellowjacket Development Roadmap

This document outlines the phased development plan for Yellowjacket, from current state to a fully-featured desktop music player.

Project Vision

Yellowjacket aims to be a modern, cross-platform desktop music player inspired by MusicBee, featuring:

  • High performance with large libraries (50,000+ tracks)
  • MusicBrainz-powered metadata autotagging
  • Device syncing with re-encoding
  • Highly customizable UI with arrangeable components

Current State

As of the start of this roadmap:

  • Basic playback working (MP3, FLAC, OGG, WAV)
  • Library scanning with metadata extraction
  • Track list and album grid views
  • Play/pause, seek, volume (backend)
  • Configuration page

Cross-Cutting Concern: Configuration/Settings Infrastructure

IMPORTANT: Settings and configuration should be considered at the forefront of every feature. Each new feature should have its configurable options designed alongside the feature itself, not bolted on afterwards.

Settings Architecture Overview

The application needs a unified settings system that:

  1. Stores preferences persistently (TOML config file on backend)
  2. Exposes settings to both backend and frontend
  3. Allows components to register their own settings sections
  4. Provides a consistent UI for editing settings

Backend Settings Infrastructure

Config Package Structure

The existing backend/config/ package should be extended to support:

// backend/config/config.go

type Config struct {
    Library  LibraryConfig  `toml:"library"`
    Player   PlayerConfig   `toml:"player"`
    Queue    QueueConfig    `toml:"queue"`
    UI       UIConfig       `toml:"ui"`
    // New sections added as features are built
}

type PlayerConfig struct {
    DefaultVolume     int    `toml:"default_volume"`      // 0-100
    ResumePlayback    bool   `toml:"resume_playback"`     // Resume on startup
    CrossfadeSeconds  int    `toml:"crossfade_seconds"`   // 0 = disabled
    ReplayGain        string `toml:"replay_gain"`         // "off", "track", "album"
}

type QueueConfig struct {
    RememberQueue     bool   `toml:"remember_queue"`      // Persist queue across sessions
    DefaultRepeat     string `toml:"default_repeat"`      // "none", "all", "one"
    DefaultShuffle    bool   `toml:"default_shuffle"`
}

type UIConfig struct {
    Theme             string            `toml:"theme"`
    SidebarWidth      int               `toml:"sidebar_width"`
    Layout            string            `toml:"layout_preset"`
    ColumnConfig      map[string][]string `toml:"column_config"`  // Per-view column selection
}

Config Change Notification

When config values change, components need to be notified:

// Config change callback pattern (already exists for Library)
type ConfigSection interface {
    OnConfigChanged(newConfig any) error
}

// Or use events
runtime.EventsEmit(ctx, events.ConfigChanged, map[string]any{
    "section": "player",
    "key":     "default_volume", 
    "value":   75,
})

Frontend Settings Infrastructure

SettingsStore

// frontend/src/store/settings-store.ts

interface SettingsState {
    player: PlayerSettings;
    queue: QueueSettings;
    ui: UISettings;
    library: LibrarySettings;
    // Extensible for new features
}

class SettingsStore {
    private state: SettingsState;
    
    // Load all settings from backend on startup
    async initialize(): Promise<void>;
    
    // Get a specific setting
    get<T>(section: string, key: string): T;
    
    // Update a setting (persists to backend)
    async set(section: string, key: string, value: any): Promise<void>;
    
    // Subscribe to changes
    subscribe(callback: () => void): () => void;
}

Settings UI Component Architecture

Each feature's settings should be encapsulated in a dedicated component:

// Pattern for settings sub-panels
interface SettingsPanel {
    id: string;           // e.g., "player-settings"
    title: string;        // e.g., "Playback"
    icon: string;         // Icon for settings nav
    component: typeof LitElement;  // The settings panel component
    order: number;        // Display order in settings nav
}

// Registry for settings panels
class SettingsRegistry {
    register(panel: SettingsPanel): void;
    getAll(): SettingsPanel[];
}

Unified Settings Window

// frontend/src/components/settings/settings-window.ts

@customElement('settings-window')
class SettingsWindow extends LitElement {
    // Left sidebar: list of settings sections
    // Right panel: active settings section component
    // Each section component handles its own settings
}

Settings Design Checklist for New Features

When implementing any new feature, consider:

  1. What user preferences exist?

    • Default values
    • Behavior toggles
    • Display options
  2. Where should settings be stored?

    • Backend config (persistent, affects backend behavior)
    • Frontend localStorage (UI-only preferences)
    • Both (synced)
  3. How are settings exposed?

    • Add to appropriate Config struct section
    • Create settings panel component
    • Register with SettingsRegistry
  4. How do components react to changes?

    • Subscribe to SettingsStore
    • Handle ConfigChanged events
    • Apply changes immediately vs. on restart

Settings Infrastructure Tasks (Integrated with Features)

These tasks should be completed early and extended as features are added:

Task: Extend backend Config structure

As each feature is built, add its configuration section to backend/config/config.go. Follow the existing pattern used for LibraryConfig.

Task: Create SettingsStore on frontend

Create frontend/src/store/settings-store.ts following the same pattern as PlayerStore. Load settings from backend on app startup.

Task: Create settings panel registry

Create frontend/src/registry/settings-registry.ts to allow features to register their settings panels.

Task: Create unified settings window component

Create frontend/src/components/settings/settings-window.ts that:

  • Shows navigation sidebar with all registered settings panels
  • Renders the active panel
  • Handles save/cancel/apply actions

Task: Migrate existing config page

The current HTMX-based config page (/config) should be migrated to use the new settings infrastructure, becoming the "Library" settings panel.


Roadmap Phases


Phase 1: Core Playback Experience

Goal: Complete the fundamental playback features that users expect from a music player.

Settings to consider for this phase:

  • Default volume level
  • Resume playback on startup (remember last track/position)
  • Remember queue across sessions
  • Default shuffle/repeat modes
  • "Previous" button behavior (restart threshold in seconds)

1.1 Playlist System (Go Backend)

Implement a playlist system in the Go backend. The "Now Playing" queue is a special transient playlist that coordinates with the Player. All playlists (including the queue) share the same underlying data structures and operations.

Key insight: The queue is simply a playlist with special behavior:

  • It's transient (not persisted by default, but optionally can be)
  • It's always "active" (connected to the Player)
  • It has shuffle/repeat modes that affect playback order

Task 1.1.1: Create playlist package structure

Create backend/playlist/ package with the following files:

  • playlist.go - Playlist struct and methods
  • queue.go - Queue (active playlist) with Player integration
  • storage.go - Persistence for saved playlists

Task 1.1.2: Design playlist database schema

Add to backend/database/sql/schemas/:

-- playlists.sql
CREATE TABLE IF NOT EXISTS playlists (
    id INTEGER PRIMARY KEY,
    name TEXT NOT NULL,
    description TEXT,
    is_smart BOOLEAN NOT NULL DEFAULT false,
    smart_rules TEXT,  -- JSON for smart playlist rules
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

-- playlist_tracks.sql
CREATE TABLE IF NOT EXISTS playlist_tracks (
    id INTEGER PRIMARY KEY,
    playlist_id INTEGER NOT NULL,
    audio_file_id INTEGER NOT NULL,
    position INTEGER NOT NULL,
    added_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    FOREIGN KEY(playlist_id) REFERENCES playlists(id) ON DELETE CASCADE,
    FOREIGN KEY(audio_file_id) REFERENCES audio_files(id) ON DELETE CASCADE
);

CREATE INDEX idx_playlist_tracks_playlist ON playlist_tracks(playlist_id);
CREATE INDEX idx_playlist_tracks_position ON playlist_tracks(playlist_id, position);

Task 1.1.3: Define Playlist data structures

// backend/playlist/playlist.go

type PlaylistTrack struct {
    ID          int64
    FilePath    string
    FileName    string
    Title       string
    Artist      string
    Album       string
    TrackLength int64  // milliseconds
    Position    int    // Position in playlist
}

type Playlist struct {
    ID          int64
    Name        string
    Description string
    IsSmart     bool
    SmartRules  string  // JSON
    Tracks      []PlaylistTrack
    CreatedAt   time.Time
    UpdatedAt   time.Time
}

Task 1.1.4: Define Queue (Active Playlist) data structures

// backend/playlist/queue.go

type RepeatMode string
const (
    RepeatNone RepeatMode = "none"
    RepeatAll  RepeatMode = "all"  
    RepeatOne  RepeatMode = "one"
)

// Queue is the "Now Playing" playlist - always exactly one exists
type Queue struct {
    ctx            context.Context
    logger         *slog.Logger
    db             *database.DB
    player         *player.Player
    
    tracks         []PlaylistTrack
    currentIndex   int
    originalOrder  []PlaylistTrack  // For unshuffle restoration
    shuffleOrder   []int            // Shuffled indices
    
    shuffleEnabled bool
    repeatMode     RepeatMode
    
    // Config-driven settings
    rememberQueue  bool  // Persist queue across sessions
}

Task 1.1.5: Implement Playlist CRUD operations

// backend/playlist/storage.go

func (s *Storage) CreatePlaylist(name, description string) (*Playlist, error)
func (s *Storage) GetPlaylist(id int64) (*Playlist, error)
func (s *Storage) GetAllPlaylists() ([]Playlist, error)
func (s *Storage) UpdatePlaylist(playlist *Playlist) error
func (s *Storage) DeletePlaylist(id int64) error

func (s *Storage) AddTracksToPlaylist(playlistID int64, trackIDs []int64) error
func (s *Storage) RemoveTrackFromPlaylist(playlistID int64, position int) error
func (s *Storage) ReorderPlaylistTrack(playlistID int64, fromPos, toPos int) error
func (s *Storage) GetPlaylistTracks(playlistID int64) ([]PlaylistTrack, error)

Task 1.1.6: Implement playlist CRUD SQL queries

Add to backend/database/sql/queries/playlists.sql:

-- name: CreatePlaylist :one
INSERT INTO playlists (name, description, is_smart, smart_rules) 
VALUES (?, ?, ?, ?) RETURNING *;

-- name: GetPlaylist :one
SELECT * FROM playlists WHERE id = ?;

-- name: GetAllPlaylists :many
SELECT * FROM playlists WHERE is_smart = false ORDER BY name;

-- name: UpdatePlaylist :exec
UPDATE playlists SET name = ?, description = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?;

-- name: DeletePlaylist :exec
DELETE FROM playlists WHERE id = ?;

-- name: GetPlaylistTracks :many
SELECT 
    af.id, af.file_path, af.length_milliseconds,
    r.name as title, 
    COALESCE(ac.text, '') as artist,
    COALESCE(rg.name, '') as album,
    pt.position
FROM playlist_tracks pt
JOIN audio_files af ON pt.audio_file_id = af.id
JOIN recordings r ON af.recording_id = r.id
LEFT JOIN artist_credit ac ON r.artist_credit_id = ac.id
LEFT JOIN release_group_recordings rgr ON r.id = rgr.recording_id
LEFT JOIN release_groups rg ON rgr.release_group_id = rg.id
WHERE pt.playlist_id = ?
ORDER BY pt.position;

-- name: AddTrackToPlaylist :exec
INSERT INTO playlist_tracks (playlist_id, audio_file_id, position)
VALUES (?, ?, (SELECT COALESCE(MAX(position), 0) + 1 FROM playlist_tracks WHERE playlist_id = ?));

-- name: RemoveTrackFromPlaylist :exec
DELETE FROM playlist_tracks WHERE playlist_id = ? AND position = ?;

-- name: ReorderPlaylistTracks :exec
UPDATE playlist_tracks SET position = ? WHERE playlist_id = ? AND audio_file_id = ?;

Task 1.1.7: Implement Queue constructor

func NewQueue(ctx context.Context, logger *slog.Logger, db *database.DB, player *player.Player, config *config.QueueConfig) *Queue
  • Initialize with empty tracks slice
  • Set currentIndex to -1 (nothing playing)
  • Load shuffle/repeat defaults from config
  • Store reference to Player for playback control
  • If config.RememberQueue is true, load persisted queue from DB

Task 1.1.8: Implement queue manipulation methods

// Set the entire queue (e.g., when user clicks "Play All" or clicks a track)
func (q *Queue) Set(tracks []PlaylistTrack, startIndex int)

// Add tracks to end of queue
func (q *Queue) Add(tracks ...PlaylistTrack)

// Insert tracks at specific position  
func (q *Queue) InsertAt(index int, tracks ...PlaylistTrack)

// Remove track at index
func (q *Queue) Remove(index int)

// Clear entire queue
func (q *Queue) Clear()

// Move track from one position to another (for drag-and-drop reordering)
func (q *Queue) Move(fromIndex, toIndex int)

// Load from a saved playlist
func (q *Queue) LoadPlaylist(playlistID int64) error

// Save current queue as a new playlist
func (q *Queue) SaveAsPlaylist(name string) (*Playlist, error)

Task 1.1.9: Implement playback control methods

// Play track at current index
func (q *Queue) PlayCurrent() error

// Skip to next track (respects shuffle and repeat modes)
func (q *Queue) Next() error

// Skip to previous track  
func (q *Queue) Previous() error

// Jump to specific index in queue
func (q *Queue) PlayAt(index int) error

Logic for Next():

  1. If repeat mode is "one", restart current track
  2. If shuffle enabled, pick next from shuffle order
  3. Otherwise, increment currentIndex
  4. If at end of queue:
    • If repeat mode is "all", go to index 0
    • Otherwise, stop playback
  5. Call q.player.LoadFile() and q.player.Play()

Logic for Previous():

  1. If current position > N seconds (configurable, default 3), restart current track
  2. Otherwise, go to previous track (respecting shuffle order)
  3. If at beginning, stay at index 0

Task 1.1.10: Implement shuffle functionality

func (q *Queue) SetShuffle(enabled bool)

When enabling shuffle:

  1. Store current order in originalOrder
  2. Create shuffled index order (Fisher-Yates shuffle)
  3. Keep current track at current position in shuffle order

When disabling shuffle:

  1. Restore originalOrder
  2. Find current track's position in original order
  3. Set currentIndex to that position

Task 1.1.11: Implement repeat functionality

func (q *Queue) SetRepeat(mode RepeatMode)

This just sets the mode; the logic is in Next().

Task 1.1.12: Implement queue state getters

func (q *Queue) GetTracks() []PlaylistTrack
func (q *Queue) GetCurrentIndex() int
func (q *Queue) GetCurrentTrack() *PlaylistTrack
func (q *Queue) IsShuffleEnabled() bool
func (q *Queue) GetRepeatMode() RepeatMode
func (q *Queue) GetDuration() int64  // Total queue duration in ms

Task 1.1.13: Register event handlers for queue

func (q *Queue) registerEventHandlers() {
    // Listen for PlaybackFinished to auto-advance
    runtime.EventsOn(q.ctx, events.PlaybackFinished, func(_ ...any) {
        q.Next()
    })
    
    // Listen for frontend requests
    runtime.EventsOn(q.ctx, events.RequestNext, func(_ ...any) {
        q.Next()
    })
    
    runtime.EventsOn(q.ctx, events.RequestPrevious, func(_ ...any) {
        q.Previous()
    })
    
    runtime.EventsOn(q.ctx, events.RequestSetShuffle, func(data ...any) {
        enabled := data[0].(bool)
        q.SetShuffle(enabled)
    })
    
    runtime.EventsOn(q.ctx, events.RequestSetRepeat, func(data ...any) {
        mode := RepeatMode(data[0].(string))
        q.SetRepeat(mode)
    })
}

Task 1.1.14: Implement queue change event emission

func (q *Queue) emitQueueChanged() {
    runtime.EventsEmit(q.ctx, events.QueueChanged, map[string]any{
        "tracks":       q.tracks,
        "currentIndex": q.currentIndex,
        "shuffle":      q.shuffleEnabled,
        "repeat":       string(q.repeatMode),
    })
}

Call this after any queue modification.

Task 1.1.15: Add queue and playlist events to events package

Update backend/events/events.go:

// Queue events
const (
    QueueChanged       = "QueueChanged"
    RequestNext        = "RequestNext"
    RequestPrevious    = "RequestPrevious"
    RequestSetShuffle  = "RequestSetShuffle"
    RequestSetRepeat   = "RequestSetRepeat"
    RequestAddToQueue  = "RequestAddToQueue"
    RequestClearQueue  = "RequestClearQueue"
)

// Playlist events
const (
    PlaylistsChanged   = "PlaylistsChanged"   // When playlists are created/deleted/renamed
    PlaylistUpdated    = "PlaylistUpdated"    // When a playlist's tracks change
)

Task 1.1.16: Add events to frontend events.ts

Update frontend/src/events.ts to mirror backend events for queue and playlists.

Task 1.1.17: Integrate Queue and Playlist Storage into app.go

  • Create playlist Storage in NewYellowJacketApp()
  • Create Queue in OnStartup() after Player is created
  • Pass Player reference and config to Queue constructor
  • Add Queue and playlist Storage to FEBindings
  • Ensure Queue's event handlers are registered

Task 1.1.18: Update PlayerStore to cache queue state

Update frontend/src/store/player-store.ts:

interface PlayerState {
    // Existing...
    isPlaying: boolean;
    currentTrack: TrackInfo | null;
    volume: number;
    
    // New queue state
    queue: PlaylistTrack[];
    queueIndex: number;
    shuffleEnabled: boolean;
    repeatMode: 'none' | 'all' | 'one';
}

Add event listener for QueueChanged.

Task 1.1.19: Update PlayerController with queue actions

Add methods to PlayerController:

  • next(), previous()
  • setShuffle(enabled: boolean)
  • setRepeat(mode: string)
  • addToQueue(tracks: Track[])
  • clearQueue()
  • loadPlaylist(playlistId: number)
  • saveQueueAsPlaylist(name: string)

Task 1.1.20: Create PlaylistStore for saved playlists

Create frontend/src/store/playlist-store.ts:

interface PlaylistState {
    playlists: Playlist[];
    loading: boolean;
}

class PlaylistStore {
    // Load all playlists from backend
    async loadPlaylists(): Promise<void>;
    
    // CRUD operations (delegate to backend)
    async createPlaylist(name: string): Promise<Playlist>;
    async deletePlaylist(id: number): Promise<void>;
    async renamePlaylist(id: number, name: string): Promise<void>;
    
    // Track operations
    async addTracksToPlaylist(playlistId: number, trackIds: number[]): Promise<void>;
    async removeTrackFromPlaylist(playlistId: number, position: number): Promise<void>;
}

1.2 Playlist UI Components

Task 1.2.1: Create playlist sidebar section

Update frontend/src/components/sidebar/app-sidebar.ts or create new component:

  • Show list of playlists below navigation
  • "New Playlist" button
  • Click playlist to view contents
  • Right-click for context menu (rename, delete)

Task 1.2.2: Create playlist view component

Create frontend/src/components/playlist/playlist-view.ts:

  • Display tracks in a playlist
  • Drag to reorder tracks
  • Remove track button
  • Play all / shuffle play buttons
  • Edit playlist name/description

Task 1.2.3: Add "Add to Playlist" context menu

Create reusable context menu component:

  • Right-click track -> Add to Playlist -> [list of playlists]
  • Option to create new playlist

Task 1.2.4: Create "Now Playing" queue panel

Create frontend/src/components/queue/queue-panel.ts:

  • Shows current queue
  • Highlights currently playing track
  • Drag to reorder
  • Remove tracks
  • Clear queue button
  • Save as playlist button

1.3 Skip Next/Previous

Task 1.3.1: Add skip buttons to player-controls component

Update frontend/src/components/audio-player/controls/player-controls.ts:

  • Add "previous" button (calls controller.previous())
  • Add "next" button (calls controller.next())
  • Use appropriate icons from webawesome

Task 1.3.2: Style skip buttons

Ensure buttons match existing play/pause styling.


1.4 Shuffle & Repeat Modes

Task 1.4.1: Add shuffle toggle to player-controls

  • Add shuffle button that toggles controller.setShuffle(!current)
  • Visual indicator when shuffle is enabled (icon color change or background)

Task 1.4.2: Add repeat toggle to player-controls

  • Add repeat button that cycles through modes: none -> all -> one -> none
  • Different icon or indicator for each mode:
    • none: repeat icon, dimmed
    • all: repeat icon, highlighted
    • one: repeat-one icon, highlighted

1.5 Keyboard Shortcuts

Task 1.5.1: Create keyboard shortcut handler

Create frontend/src/utils/keyboard-shortcuts.ts:

import { playerStore } from '@store/player-store';

export function initKeyboardShortcuts() {
    document.addEventListener('keydown', (e) => {
        // Don't trigger if user is typing in an input
        if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) {
            return;
        }
        
        switch (e.code) {
            case 'Space':
                e.preventDefault();
                playerStore.getState().isPlaying ? playerStore.pause() : playerStore.play();
                break;
            case 'ArrowRight':
                if (e.ctrlKey || e.metaKey) {
                    playerStore.next();
                }
                break;
            case 'ArrowLeft':
                if (e.ctrlKey || e.metaKey) {
                    playerStore.previous();
                }
                break;
            // Add more shortcuts as needed
        }
    });
}

Task 1.5.2: Initialize shortcuts in index.ts

Call initKeyboardShortcuts() in frontend/index.ts.

Task 1.5.3: Document keyboard shortcuts

Consider adding a help modal or tooltip showing available shortcuts.


1.6 Volume UI

Task 1.6.1: Verify backend volume support

Check that Player.SetVolume() is working and exposed via FEBindings or events.

If not exposed via events, add:

  • RequestSetVolume event in backend/events/events.go
  • Event handler in Player that calls SetVolume()
  • Emit VolumeChanged event after volume changes

Task 1.6.2: Implement volume-control component

Update frontend/src/components/audio-player/volume-control/volume-control.ts:

  • Add PlayerController
  • Render slider from 0-100
  • Display current volume from controller.volume
  • On change, call controller.setVolume(value)

Task 1.6.3: Add mute toggle

  • Add mute button that sets volume to 0 (store previous volume)
  • Click again to restore previous volume
  • Icon changes based on volume level (muted, low, medium, high)

Phase 2: Performance & Scale

Goal: Optimize the application for large music libraries (50,000+ tracks).

Settings to consider for this phase:

  • Page size for virtualized lists
  • Prefetch buffer size (how many pages to load ahead)
  • Library scan behavior (auto-scan on startup, watch for changes)
  • Cache settings (cover art cache size, etc.)

2.1 Database Optimization

Task 2.1.1: Add indexes to frequently queried columns

Create new migration file backend/database/sql/schemas/indexes.sql:

-- Index for audio_files queries
CREATE INDEX IF NOT EXISTS idx_audio_files_recording_id ON audio_files(recording_id);
CREATE INDEX IF NOT EXISTS idx_audio_files_file_type_id ON audio_files(file_type_id);

-- Index for recordings queries  
CREATE INDEX IF NOT EXISTS idx_recordings_artist_credit_id ON recordings(artist_credit_id);
CREATE INDEX IF NOT EXISTS idx_recordings_name ON recordings(name);
CREATE INDEX IF NOT EXISTS idx_recordings_year ON recordings(year);
CREATE INDEX IF NOT EXISTS idx_recordings_genre ON recordings(genre);

-- Index for release_groups queries
CREATE INDEX IF NOT EXISTS idx_release_groups_name ON release_groups(name);
CREATE INDEX IF NOT EXISTS idx_release_groups_album_artist_credit_id ON release_groups(album_artist_credit_id);
CREATE INDEX IF NOT EXISTS idx_release_groups_year ON release_groups(year);

-- Index for artist_credit
CREATE INDEX IF NOT EXISTS idx_artist_credit_text ON artist_credit(text);

-- Index for release_group_recordings
CREATE INDEX IF NOT EXISTS idx_rgr_release_group_id ON release_group_recordings(release_group_id);
CREATE INDEX IF NOT EXISTS idx_rgr_recording_id ON release_group_recordings(recording_id);

Task 2.1.2: Fix release_groups UNIQUE constraint issue

Current schema has name TEXT NOT NULL UNIQUE which breaks if two albums have the same name by different artists.

Options:

  1. Remove UNIQUE constraint (allow duplicates, rely on other fields)
  2. Create composite unique on (name, album_artist_credit_id, year)
  3. Add a generated hash column for uniqueness

Recommended: Option 2 - composite unique constraint.

Create migration to alter table or recreate with proper constraints.

Task 2.1.3: Analyze query performance

Use SQLite EXPLAIN QUERY PLAN on common queries to verify indexes are being used:

EXPLAIN QUERY PLAN SELECT * FROM recordings WHERE artist_credit_id = ?;

2.2 Paginated Backend Queries

Task 2.2.1: Add paginated track query

Add to backend/database/sql/queries/audio_files.sql:

-- name: GetTracksPaginated :many
SELECT 
    af.id,
    af.file_path,
    af.length_milliseconds,
    r.name as title,
    COALESCE(ac.text, '') as artist,
    COALESCE(rg.name, '') as album
FROM audio_files af
JOIN recordings r ON af.recording_id = r.id
LEFT JOIN artist_credit ac ON r.artist_credit_id = ac.id
LEFT JOIN release_group_recordings rgr ON r.id = rgr.recording_id
LEFT JOIN release_groups rg ON rgr.release_group_id = rg.id
ORDER BY r.name
LIMIT ? OFFSET ?;

-- name: GetTracksCount :one
SELECT COUNT(*) FROM audio_files;

Task 2.2.2: Add filtered/sorted track query

-- name: GetTracksFiltered :many
SELECT ...
WHERE 
    (r.name LIKE ? OR ? = '') AND
    (ac.text LIKE ? OR ? = '') AND
    (rg.name LIKE ? OR ? = '')
ORDER BY 
    CASE WHEN ? = 'title' THEN r.name END,
    CASE WHEN ? = 'artist' THEN ac.text END,
    CASE WHEN ? = 'album' THEN rg.name END
LIMIT ? OFFSET ?;

Task 2.2.3: Update Library package with paginated methods

Add to backend/library/query.go:

type TrackQuery struct {
    Offset    int
    Limit     int
    SortBy    string  // "title", "artist", "album", "year"
    SortDir   string  // "asc", "desc"
    Search    string  // Search across title, artist, album
}

type TracksResult struct {
    Tracks     []Track
    TotalCount int
}

func (l *Library) GetTracks(query TrackQuery) (TracksResult, error)

Task 2.2.4: Expose paginated query via Wails binding

Add GetTracks(query TrackQuery) to FEBindings.


2.3 Virtualized Lists

Task 2.3.1: Install @lit-labs/virtualizer

cd frontend && npm install @lit-labs/virtualizer

Task 2.3.2: Create virtualized track list component

Create frontend/src/components/track-list/virtualized-track-list.ts:

import { LitElement, html, css } from 'lit';
import { customElement, state } from 'lit/decorators.js';
import '@lit-labs/virtualizer';
import { flow } from '@lit-labs/virtualizer/layouts/flow.js';
import { GetTracks } from '@go/library/Library';

@customElement('virtualized-track-list')
export class VirtualizedTrackList extends LitElement {
    @state() private tracks: Track[] = [];
    @state() private totalCount = 0;
    
    private pageSize = 100;
    private loadedPages = new Set<number>();

    override async connectedCallback() {
        super.connectedCallback();
        await this.loadPage(0);
    }

    private async loadPage(page: number) {
        if (this.loadedPages.has(page)) return;
        
        const result = await GetTracks({
            offset: page * this.pageSize,
            limit: this.pageSize,
            sortBy: 'title',
            sortDir: 'asc',
            search: '',
        });
        
        this.totalCount = result.TotalCount;
        this.loadedPages.add(page);
        
        // Merge into sparse array
        const newTracks = [...this.tracks];
        result.Tracks.forEach((track, i) => {
            newTracks[page * this.pageSize + i] = track;
        });
        this.tracks = newTracks;
    }

    private onVisibilityChanged(e: CustomEvent) {
        const { first, last } = e;
        const firstPage = Math.floor(first / this.pageSize);
        const lastPage = Math.floor(last / this.pageSize);
        
        for (let p = firstPage; p <= lastPage + 1; p++) {
            this.loadPage(p);
        }
    }

    override render() {
        return html`
            <lit-virtualizer
                scroller
                .items=${Array(this.totalCount).fill(null).map((_, i) => this.tracks[i])}
                .renderItem=${(track: Track | undefined, index: number) => 
                    track 
                        ? html`<track-row .track=${track} @click=${() => this.onTrackClick(track)}></track-row>`
                        : html`<track-row-skeleton></track-row-skeleton>`
                }
                .layout=${flow()}
                @visibilityChanged=${this.onVisibilityChanged}
            ></lit-virtualizer>
        `;
    }
}

Task 2.3.3: Create track-row component

Create frontend/src/components/track-list/track-row.ts for individual track rendering.

Task 2.3.4: Create track-row-skeleton component

Loading placeholder while data is being fetched.

Task 2.3.5: Create virtualized album grid

Similar to track list but using grid layout:

import { grid } from '@lit-labs/virtualizer/layouts/grid.js';

.layout=${grid({ itemSize: { width: '180px', height: '220px' } })}

Task 2.3.6: Replace existing track-list and cover-grid

Swap out the old components for virtualized versions.


Phase 3: Library Organization

Goal: Provide powerful tools for organizing and finding music.

Settings to consider for this phase:

  • Default sort order for track lists
  • Default columns displayed
  • Search behavior (instant vs. press enter, search scope)
  • Smart playlist default settings

3.1 Search & Filtering

Task 3.1.1: Add search input component

Create frontend/src/components/search/search-input.ts:

  • Text input with debounced onChange
  • Dispatches search event or updates LibraryStore

Task 3.1.2: Create LibraryStore

Create frontend/src/store/library-store.ts:

interface LibraryState {
    searchQuery: string;
    sortBy: string;
    sortDir: 'asc' | 'desc';
    filters: {
        genre?: string;
        year?: number;
        artist?: string;
    };
}

Task 3.1.3: Create LibraryController

Similar to PlayerController, connects components to LibraryStore.

Task 3.1.4: Integrate search with virtualized list

When search query changes:

  1. Reset loaded pages
  2. Update query parameters
  3. Reload from page 0

Task 3.1.5: Add filter dropdowns

Genre, year, artist filters that update LibraryStore.


3.2 Custom Columns

Task 3.2.1: Define available columns

interface ColumnDefinition {
    id: string;
    label: string;
    field: string;  // Path into track object
    width: number;
    sortable: boolean;
}

const availableColumns: ColumnDefinition[] = [
    { id: 'title', label: 'Title', field: 'name', width: 200, sortable: true },
    { id: 'artist', label: 'Artist', field: 'artistName', width: 150, sortable: true },
    { id: 'album', label: 'Album', field: 'albumName', width: 150, sortable: true },
    { id: 'duration', label: 'Duration', field: 'lengthMilliseconds', width: 80, sortable: true },
    { id: 'year', label: 'Year', field: 'year', width: 60, sortable: true },
    { id: 'genre', label: 'Genre', field: 'genre', width: 100, sortable: true },
    { id: 'trackNum', label: '#', field: 'trackNumber', width: 40, sortable: true },
    // ... more columns
];

Task 3.2.2: Create column selector UI

Modal or dropdown where user can:

  • Check/uncheck columns to show
  • Drag to reorder columns

Task 3.2.3: Persist column preferences

Save selected columns and order to config.

Task 3.2.4: Update track list to use dynamic columns

Read column configuration and render accordingly.


3.3 Smart Playlists

Note: Basic playlist functionality (database schema, CRUD, UI) is implemented in Phase 1. This section extends playlists with smart/dynamic features.

Task 3.3.1: Define smart playlist rule structure

interface SmartPlaylistRule {
    field: string;      // 'genre', 'year', 'artist', 'playCount', etc.
    operator: string;   // 'equals', 'contains', 'greaterThan', 'lessThan'
    value: string | number;
}

interface SmartPlaylistRules {
    matchType: 'all' | 'any';  // AND vs OR
    rules: SmartPlaylistRule[];
    limit?: number;
    sortBy?: string;
}

Task 3.3.2: Implement smart playlist query builder

Convert rules to SQL WHERE clause dynamically.

Task 3.3.3: Create smart playlist editor UI

Form to add/remove rules, preview results.


3.4 Auto-Playlists

Task 3.4.1: Implement "Recently Added" auto-playlist

Query tracks sorted by date added, limit 100.

Task 3.4.2: Implement "Recently Played" auto-playlist

Requires tracking play history (new table).

Task 3.4.3: Add play history tracking

CREATE TABLE IF NOT EXISTS play_history (
    id INTEGER PRIMARY KEY,
    audio_file_id INTEGER NOT NULL,
    played_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    FOREIGN KEY(audio_file_id) REFERENCES audio_files(id)
);

Update Player to log plays.


Phase 4: UI Customization

Goal: Allow users to arrange and customize the UI layout.

Settings to consider for this phase:

  • Active layout preset
  • Per-component configurations (each component can define its own settings)
  • Theme/appearance settings
  • Sidebar default widths
  • Visibility toggles for UI elements

Important: This phase heavily integrates with the settings infrastructure. Each registered component should be able to define its own settings schema that appears in the settings window.

4.1 Component Registry System

Task 4.1.1: Define component registry interface

interface RegisteredComponent {
    id: string;
    name: string;
    description: string;
    component: typeof LitElement;
    defaultSlot: 'main' | 'left-sidebar' | 'right-sidebar' | 'top-bar' | 'bottom-bar';
    allowedSlots: string[];
    defaultConfig: Record<string, any>;
}

Task 4.1.2: Create component registry

// frontend/src/registry/component-registry.ts
class ComponentRegistry {
    private components = new Map<string, RegisteredComponent>();
    
    register(component: RegisteredComponent): void;
    get(id: string): RegisteredComponent | undefined;
    getAll(): RegisteredComponent[];
    getForSlot(slot: string): RegisteredComponent[];
}

export const componentRegistry = new ComponentRegistry();

Task 4.1.3: Register existing components

componentRegistry.register({
    id: 'track-list',
    name: 'Track List',
    description: 'Display all tracks in a table',
    component: TrackList,
    defaultSlot: 'main',
    allowedSlots: ['main'],
    defaultConfig: {},
});

componentRegistry.register({
    id: 'now-playing',
    name: 'Now Playing',
    description: 'Show current track info',
    component: NowPlaying,
    defaultSlot: 'bottom-bar',
    allowedSlots: ['bottom-bar', 'left-sidebar', 'right-sidebar'],
    defaultConfig: {},
});

4.2 Layout Configuration System

Task 4.2.1: Define layout configuration structure

interface LayoutConfig {
    'top-bar': ComponentPlacement[];
    'bottom-bar': ComponentPlacement[];
    'left-sidebar': ComponentPlacement[];
    'right-sidebar': ComponentPlacement[];
    'main': ComponentPlacement[];
}

interface ComponentPlacement {
    componentId: string;
    config: Record<string, any>;
    order: number;
}

Task 4.2.2: Create LayoutStore

Store current layout configuration, provide methods to modify.

Task 4.2.3: Create layout persistence

Save/load layout from config file or localStorage.

Task 4.2.4: Create dynamic slot renderer

Component that reads layout config and renders appropriate components in each slot.

@customElement('layout-slot')
class LayoutSlot extends LitElement {
    @property() slotName: string;
    
    render() {
        const placements = layoutStore.getSlot(this.slotName);
        return html`
            ${placements.map(p => {
                const reg = componentRegistry.get(p.componentId);
                const tag = reg.component.tagName;
                return html`<${tag} .config=${p.config}></${tag}>`;
            })}
        `;
    }
}

4.3 Layout Editor UI

Task 4.3.1: Create layout editor modal

  • Visual representation of slots
  • Drag components between slots
  • Add/remove components from slots

Task 4.3.2: Create component configurator

Per-component settings panel for components that support configuration.

Task 4.3.3: Add layout presets

Default layouts users can choose from:

  • "Classic" (sidebar + main + bottom bar)
  • "Minimal" (just player controls)
  • "Full" (all panels visible)

Phase 5: MusicBrainz Integration

Goal: Enable automatic metadata tagging via MusicBrainz.

Settings to consider for this phase:

  • AcoustID API key
  • Auto-tag behavior (prompt always, auto-accept high confidence, etc.)
  • Minimum confidence threshold for auto-accept
  • Which metadata fields to overwrite
  • Backup original tags before overwriting

5.1 MusicBrainz API Client

Task 5.1.1: Create MusicBrainz package

backend/musicbrainz/client.go:

  • HTTP client with rate limiting (1 req/sec per MB guidelines)
  • User-Agent header with app name and contact
func (c *Client) SearchRecordings(query string) ([]Recording, error)
func (c *Client) GetRecording(mbid string) (*Recording, error)
func (c *Client) SearchReleases(query string) ([]Release, error)
func (c *Client) GetRelease(mbid string) (*Release, error)
func (c *Client) SearchArtists(query string) ([]Artist, error)

5.2 AcoustID Integration

Task 5.2.1: Integrate chromaprint for fingerprinting

Use chromaprint library to generate audio fingerprints.

Task 5.2.2: Create AcoustID client

func (c *AcoustIDClient) Lookup(fingerprint string, duration int) ([]AcoustIDResult, error)

Task 5.2.3: Map AcoustID results to MusicBrainz

AcoustID returns MusicBrainz recording IDs; use those to fetch full metadata.


5.3 Autotag Workflow

Task 5.3.1: Create autotag service

backend/autotag/autotag.go:

type AutotagResult struct {
    FilePath       string
    MatchConfidence float64
    CurrentMetadata TrackMetadata
    SuggestedMetadata TrackMetadata
    MBRecordingID  string
}

func (s *Service) AnalyzeTrack(filePath string) (*AutotagResult, error)
func (s *Service) AnalyzeAlbum(tracks []string) ([]AutotagResult, error)
func (s *Service) ApplyTags(result *AutotagResult) error

Task 5.3.2: Create autotag UI component

  • Show current vs suggested metadata side-by-side
  • Confidence indicator
  • Accept/reject buttons
  • Batch operations for albums

Task 5.3.3: Implement tag writing

Write accepted metadata back to audio files using tag library.


5.4 MusicBrainz Visual Browser

Task 5.4.1: Create artist browser view

  • Search artists
  • View artist discography
  • Click release to see tracklist

Task 5.4.2: Create release browser view

  • Album art (from Cover Art Archive)
  • Track listing
  • Credits and relationships

Show which local tracks match MB recordings; allow manual linking.


Phase 6: Device Sync

Goal: Sync music to Android devices with optional re-encoding.

Settings to consider for this phase:

  • Per-device sync profiles (encoding quality, playlists to sync)
  • Encoding cache location and size limit
  • Sync behavior (delete removed tracks from device, etc.)
  • Custom encoding profiles (advanced users)
  • FFmpeg binary path (if not bundled)

6.1 Android Sync (MTP)

Task 6.1.1: Research MTP libraries for Go

Options:

  • libmtp bindings
  • gousb for raw USB
  • Call external tools (jmtpfs, go-mtpfs)

Task 6.1.2: Create device detection

Detect connected MTP devices, list storage volumes.

Task 6.1.3: Create file transfer service

type SyncService struct {
    // ...
}

func (s *SyncService) GetDevices() ([]Device, error)
func (s *SyncService) SyncPlaylist(device Device, playlist Playlist, profile EncodingProfile) error
func (s *SyncService) SyncTracks(device Device, tracks []Track, profile EncodingProfile) error

6.2 Re-encoding Pipeline

Task 6.2.1: Integrate FFmpeg

Use FFmpeg for transcoding. Options:

  • Call ffmpeg binary
  • Use go-ffmpeg bindings

Task 6.2.2: Define encoding profiles

type EncodingProfile struct {
    Name       string
    Format     string  // "mp3", "aac", "opus"
    Bitrate    int     // kbps
    SampleRate int     // Hz
}

var presets = []EncodingProfile{
    {Name: "High Quality MP3", Format: "mp3", Bitrate: 320, SampleRate: 44100},
    {Name: "Balanced MP3", Format: "mp3", Bitrate: 192, SampleRate: 44100},
    {Name: "Space Saver", Format: "mp3", Bitrate: 128, SampleRate: 44100},
}

Task 6.2.3: Create encoding cache

Cache encoded files to avoid re-encoding on every sync:

  • Hash source file + profile = cache key
  • Store encoded files in cache directory

Task 6.2.4: Create sync progress UI

  • Device selection
  • Playlist/track selection
  • Encoding profile selection
  • Progress bar with current file
  • Cancel button

Phase 7: Cross-Platform Polish

Goal: Ensure excellent experience on Windows and macOS.

Settings to consider for this phase:

  • System tray behavior (minimize to tray, close to tray)
  • Startup behavior (start minimized, start with system)
  • Media key handling (enable/disable)
  • Notification preferences (track change, etc.)
  • File association preferences

7.1 Platform Testing

Task 7.1.1: Set up Windows build environment

  • Windows VM or machine
  • Go + Node.js toolchain
  • Wails CLI

Task 7.1.2: Set up macOS build environment

  • macOS machine (required for signing)
  • Xcode command line tools
  • Go + Node.js toolchain

Task 7.1.3: Fix platform-specific issues

Test and fix:

  • File paths (forward vs backslash)
  • System directories
  • Audio device handling
  • Window chrome differences

7.2 Platform Integration

Task 7.2.1: System media key support

Respond to keyboard media keys (play/pause, next, previous).

Research:

  • Windows: RegisterHotKey or low-level keyboard hook
  • macOS: SPMediaKeyTap or MediaKeySession
  • Linux: D-Bus MPRIS

Task 7.2.2: MPRIS integration (Linux)

Implement MPRIS D-Bus interface for integration with desktop environments.

Task 7.2.3: System tray icon

Minimize to tray, show playback controls in tray menu.

Task 7.2.4: Native notifications

Show track change notifications using system notification APIs.


7.3 Distribution

Task 7.3.1: Create installer for Windows

  • NSIS or WiX installer
  • Start menu shortcut
  • File associations (.mp3, .flac, etc.)

Task 7.3.2: Create DMG for macOS

  • Signed and notarized app bundle
  • Drag-to-Applications installer

Task 7.3.3: Create packages for Linux

  • AppImage (universal)
  • .deb (Debian/Ubuntu)
  • .rpm (Fedora)
  • Flatpak (sandboxed)

Task 7.3.4: Set up CI/CD for releases

GitHub Actions workflow to:

  • Build for all platforms
  • Run tests
  • Create release artifacts
  • Publish to GitHub Releases

Technical Debt Items

These items should be addressed as time permits, integrated with feature work:

Testing

  • Unit tests for PlayerStore
  • Unit tests for Go Queue package
  • Unit tests for Go Library scanning
  • Integration tests for Wails event flow
  • E2E tests for critical user flows

Documentation

  • User documentation / help pages
  • Developer setup guide
  • Architecture documentation
  • API documentation for plugin authors (future)

Code Quality

  • Consistent error handling patterns in Go
  • Consistent logging throughout
  • Performance profiling and optimization
  • Accessibility audit (keyboard navigation, screen readers)

Security

  • Input validation for all user inputs
  • Safe file path handling
  • Sanitize metadata before display (XSS prevention)

Decision Log

Key architectural decisions made during planning:

Decision Choice Rationale
Queue model Queue as special playlist Unified data structures, queue can be saved as playlist
Queue/Playlist location Go backend Tighter integration with Player, single source of truth
Frontend state Custom store + Controllers Production-ready, integrates with Wails events
Virtualization @lit-labs/virtualizer Native Lit integration, supports both list and grid
State management lib None (custom) Signals not production-ready, custom gives full control
Track per file Yes (no deduplication) Practical for real-world music libraries
Testing Deferred Focus on architecture first, add tests incrementally
Settings architecture Unified, extensible Each feature adds its own config section; single settings UI
Playlists in Phase 1 Yes Core feature, needed for queue; smart playlists in Phase 3