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:
- Stores preferences persistently (TOML config file on backend)
- Exposes settings to both backend and frontend
- Allows components to register their own settings sections
- 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:
-
What user preferences exist?
- Default values
- Behavior toggles
- Display options
-
Where should settings be stored?
- Backend config (persistent, affects backend behavior)
- Frontend localStorage (UI-only preferences)
- Both (synced)
-
How are settings exposed?
- Add to appropriate Config struct section
- Create settings panel component
- Register with SettingsRegistry
-
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 methodsqueue.go- Queue (active playlist) with Player integrationstorage.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.RememberQueueis 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():
- If repeat mode is "one", restart current track
- If shuffle enabled, pick next from shuffle order
- Otherwise, increment currentIndex
- If at end of queue:
- If repeat mode is "all", go to index 0
- Otherwise, stop playback
- Call
q.player.LoadFile()andq.player.Play()
Logic for Previous():
- If current position > N seconds (configurable, default 3), restart current track
- Otherwise, go to previous track (respecting shuffle order)
- If at beginning, stay at index 0
Task 1.1.10: Implement shuffle functionality
func (q *Queue) SetShuffle(enabled bool)
When enabling shuffle:
- Store current order in
originalOrder - Create shuffled index order (Fisher-Yates shuffle)
- Keep current track at current position in shuffle order
When disabling shuffle:
- Restore
originalOrder - Find current track's position in original order
- 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:
RequestSetVolumeevent inbackend/events/events.go- Event handler in Player that calls
SetVolume() - Emit
VolumeChangedevent 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:
- Remove UNIQUE constraint (allow duplicates, rely on other fields)
- Create composite unique on (name, album_artist_credit_id, year)
- 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:
- Reset loaded pages
- Update query parameters
- 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
Task 5.1.2: Implement recording search
func (c *Client) SearchRecordings(query string) ([]Recording, error)
func (c *Client) GetRecording(mbid string) (*Recording, error)
Task 5.1.3: Implement release search
func (c *Client) SearchReleases(query string) ([]Release, error)
func (c *Client) GetRelease(mbid string) (*Release, error)
Task 5.1.4: Implement artist search
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
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
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 |