Files
yellowjacket/docs/dev/roadmap.md
T

1649 lines
45 KiB
Markdown

# 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:
```go
// 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:
```go
// 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
```typescript
// 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:
```typescript
// 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
```typescript
// 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/`:
```sql
-- 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
```go
// 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
```go
// 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
```go
// 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`:
```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
```go
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
```go
// 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
```go
// 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
```go
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
```go
func (q *Queue) SetRepeat(mode RepeatMode)
```
This just sets the mode; the logic is in `Next()`.
#### Task 1.1.12: Implement queue state getters
```go
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
```go
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
```go
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`:
```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`:
```typescript
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`:
```typescript
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`:
```typescript
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`:
```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:
```sql
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`:
```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
```sql
-- 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`:
```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
```bash
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`:
```typescript
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:
```typescript
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`:
```typescript
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
```typescript
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
```typescript
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
```sql
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
```typescript
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
```typescript
// 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
```typescript
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
```typescript
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.
```typescript
@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
#### Task 5.1.2: Implement recording search
```go
func (c *Client) SearchRecordings(query string) ([]Recording, error)
func (c *Client) GetRecording(mbid string) (*Recording, error)
```
#### Task 5.1.3: Implement release search
```go
func (c *Client) SearchReleases(query string) ([]Release, error)
func (c *Client) GetRelease(mbid string) (*Release, error)
```
#### Task 5.1.4: Implement artist search
```go
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
```go
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`:
```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
#### Task 5.4.3: Link local tracks to MB entities
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
```go
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
```go
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 |