docs(17): create phase plan for single track edit

This commit is contained in:
2026-03-17 18:35:28 -04:00
parent 9951a362ea
commit 4f7c800fb6
3 changed files with 923 additions and 1 deletions
+4 -1
View File
@@ -87,7 +87,10 @@ Plans:
2. The editor displays all 8 editable fields (title, artist, album, genre, year, track number, disc number, composer) pre-populated with the track's current values — empty fields show as empty, not "Unknown"
3. The editor displays the track's current cover art (or a placeholder if none) with a button to select a replacement image file from disk
4. Clicking "Save" writes the changes to the audio file, updates the database and search index, and refreshes all visible views (track list, album view, artist view, genre view, queue, now-playing bar) — the user sees the new metadata everywhere without restarting or rescanning
**Plans:** TBD
**Plans:** 2 plans
Plans:
- [ ] 17-01-PLAN.md — Backend wiring (WriteTrackTagsByPath, ImageFilePicker) + library store event handler + context menu fix
- [ ] 17-02-PLAN.md — Track details dialog save flow, cover art editing, error handling, human verification
### Phase 18: Batch Edit
**Goal:** Users can efficiently edit shared metadata across multiple tracks at once with clear visual feedback and safe defaults
@@ -0,0 +1,285 @@
---
phase: 17-single-track-edit
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
- backend/tagwriter/pipeline.go
- backend/frontendutil/frontendutil.go
- frontend/src/store/library-store.ts
- frontend/src/components/track-list/track-list.ts
- frontend/src/components/queue-panel/queue-panel.ts
- frontend/src/components/cover-grid/cover-grid.ts
- frontend/src/components/playlist-details/playlist-details.ts
autonomous: true
requirements:
- EDIT-01
- EDIT-04
must_haves:
truths:
- "WriteTrackTagsByPath accepts a file path string and TagChanges, resolves the track ID internally, and delegates to WriteTrackTags"
- "ImageFilePicker opens a native file dialog filtered to JPEG/PNG and returns the selected file path"
- "After a successful tag write, the library store invalidates all caches and re-fetches data so all views reflect the new metadata"
- "Right-clicking any single track in track-list, queue-panel, cover-grid, or playlist-details shows 'Track Details' in the context menu regardless of selection state"
artifacts:
- path: "backend/tagwriter/pipeline.go"
provides: "WriteTrackTagsByPath method on TagWriter"
contains: "func (tw *TagWriter) WriteTrackTagsByPath"
- path: "backend/frontendutil/frontendutil.go"
provides: "ImageFilePicker method for cover art selection"
contains: "func (fe *FrontendUtil) ImageFilePicker"
- path: "frontend/src/store/library-store.ts"
provides: "TrackMetadataChanged event handler calling invalidate()"
contains: "TrackMetadataChanged"
- path: "frontend/src/components/track-list/track-list.ts"
provides: "Track Details context menu item visible for any right-clicked track"
- path: "frontend/src/components/queue-panel/queue-panel.ts"
provides: "Track Details context menu item visible for any right-clicked track"
key_links:
- from: "frontend/src/store/library-store.ts"
to: "backend events"
via: "EventsOn(Events.TrackMetadataChanged)"
pattern: "EventsOn.*TrackMetadataChanged"
- from: "backend/tagwriter/pipeline.go"
to: "backend/database"
via: "GetAudioFileByPath query"
pattern: "GetAudioFileByPath"
---
<objective>
Wire the backend bridge methods and frontend plumbing needed for single-track tag editing.
Purpose: Phase 17 builds on the WriteTrackTags pipeline from Phase 16. The frontend identifies tracks by `FilePath` but WriteTrackTags requires `trackID int64`. This plan adds a path-based wrapper, a cover art file picker, the library store event handler that refreshes views after edits, and removes the selection-count gate on the "Track Details" context menu item.
Output: Backend methods ready for frontend consumption, library store reacts to tag write events, context menu accessible from any track context.
</objective>
<execution_context>
@/home/caleb/.config/opencode/get-shit-done/workflows/execute-plan.md
@/home/caleb/.config/opencode/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/17-single-track-edit/17-CONTEXT.md
<interfaces>
<!-- Key types and contracts the executor needs. -->
From backend/tagwriter/pipeline.go:
```go
type TagWriter struct {
logger *slog.Logger
db *database.DB
ctx context.Context
player PlayerStopper
library PipelineLocker
}
func (tw *TagWriter) WriteTrackTags(trackID int64, changes TagChanges) error
```
From backend/tagwriter/tagwriter.go:
```go
type TagChanges map[string]any
const (
FieldTitle = "title"
FieldArtist = "artist"
FieldAlbum = "album"
FieldAlbumArtist = "album_artist"
FieldGenre = "genre"
FieldYear = "year"
FieldTrackNumber = "track_number"
FieldDiscNumber = "disc_number"
FieldComposer = "composer"
FieldCoverArt = "cover_art"
)
```
From backend/database/sql/sqlcgen/audio_files.sql.go:
```go
func (q *Queries) GetAudioFileByPath(ctx context.Context, filePath string) (AudioFile, error)
```
From backend/frontendutil/frontendutil.go:
```go
type FrontendUtil struct {
ctx context.Context
}
func (fe *FrontendUtil) DirectoryPicker() (string, error)
func (fe *FrontendUtil) PlaylistFilePicker() ([]string, error)
```
From frontend/src/store/library-store.ts:
```typescript
class LibraryStore {
private invalidate(): void { ... }
// Currently listens for: LibraryScanComplete, LibraryRemoved, LibraryAdded, LibraryRenamed
// Does NOT listen for TrackMetadataChanged
}
```
From frontend/src/events.ts:
```typescript
export const Events = {
TrackMetadataChanged: "TrackMetadataChanged",
// ...
} as const;
```
Context menu pattern (track-list.ts line ~1966):
```typescript
${this.selection.selectionCount === 1
? html`<wa-dropdown-item @click=${() => this.onContextMenuAction('track-details')}>
<wa-icon slot="icon" name="circle-info"></wa-icon>
Track Details
</wa-dropdown-item>` : nothing}
```
queue-panel.ts uses the same pattern at line ~1553.
cover-grid.ts and playlist-details.ts conditionally show Track Details only for single track context.
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: Add WriteTrackTagsByPath and ImageFilePicker backend methods</name>
<files>backend/tagwriter/pipeline.go, backend/frontendutil/frontendutil.go</files>
<action>
**In `backend/tagwriter/pipeline.go`**, add a new method `WriteTrackTagsByPath` directly below the existing `WriteTrackTags` method:
```go
// WriteTrackTagsByPath resolves a file path to its audio_file.id and
// delegates to WriteTrackTags. This is the frontend-facing entry
// point since the frontend identifies tracks by FilePath.
func (tw *TagWriter) WriteTrackTagsByPath(filePath string, changes TagChanges) error {
ctx := context.Background()
audioFile, err := tw.db.Queries.GetAudioFileByPath(ctx, filePath)
if err != nil {
return fmt.Errorf("resolve track by path %q: %w", filePath, err)
}
return tw.WriteTrackTags(audioFile.ID, changes)
}
```
This uses the existing `GetAudioFileByPath` sqlc query (already generated) to look up the `audio_files.id` from `file_path`, then delegates to the existing `WriteTrackTags` pipeline.
**In `backend/frontendutil/frontendutil.go`**, add a new method `ImageFilePicker` below `PlaylistFilePicker`:
```go
// ImageFilePicker opens a file selection dialog filtered to image
// files (JPEG, PNG). Returns the selected file path, or empty
// string if the user cancelled.
func (fe *FrontendUtil) ImageFilePicker() (string, error) {
file, err := runtime.OpenFileDialog(
fe.ctx,
runtime.OpenDialogOptions{
Title: "Select Cover Art",
Filters: []runtime.FileFilter{
{
DisplayName: "Image Files (*.jpg, *.jpeg, *.png)",
Pattern: "*.jpg;*.jpeg;*.png",
},
},
},
)
if err != nil {
return "", fmt.Errorf("could not open file dialog: %w", err)
}
return file, nil
}
```
After adding both methods, run `make generate` to regenerate Wails TypeScript bindings (this will create the `WriteTrackTagsByPath` and `ImageFilePicker` bindings in `frontend/wailsjs/go/`).
Ensure both methods follow codebase conventions: doc comments ending with periods, error wrapping with `%w`, `fmt.Errorf` context.
</action>
<verify>
`go build -tags webkit2_41 ./...` compiles without errors.
`make generate` succeeds and creates new TypeScript bindings.
`rg "WriteTrackTagsByPath" frontend/wailsjs/go/tagwriter/` shows the generated binding.
`rg "ImageFilePicker" frontend/wailsjs/go/frontendutil/` shows the generated binding.
</verify>
<done>
WriteTrackTagsByPath method exists on TagWriter, resolves filePath→trackID via GetAudioFileByPath, delegates to WriteTrackTags.
ImageFilePicker method exists on FrontendUtil, opens native file dialog filtered to JPEG/PNG, returns selected path.
Both have TypeScript bindings generated.
</done>
</task>
<task type="auto">
<name>Task 2: Add TrackMetadataChanged handler and fix context menu conditions</name>
<files>frontend/src/store/library-store.ts, frontend/src/components/track-list/track-list.ts, frontend/src/components/queue-panel/queue-panel.ts, frontend/src/components/cover-grid/cover-grid.ts, frontend/src/components/playlist-details/playlist-details.ts</files>
<action>
**In `frontend/src/store/library-store.ts`**, add a `TrackMetadataChanged` event listener in the constructor, after the existing `LibraryRenamed` listener:
```typescript
EventsOn(Events.TrackMetadataChanged, () => {
this.invalidate();
});
```
This causes a full cache invalidation + re-fetch of all library data (tracks, albums, artists, genres) whenever any track's tags are written. Full reload is acceptable per the CONTEXT.md decision: "Full reload is acceptable because editing is a low-frequency operation."
**In `frontend/src/components/track-list/track-list.ts`**, find the context menu rendering where "Track Details" is conditionally shown (around line 1966). Change the condition from `this.selection.selectionCount === 1` to always show the item. The item should appear when right-clicking any track. Per CONTEXT.md: "'Track Details' should appear in the context menu when right-clicking any track, regardless of selection state."
Replace:
```typescript
${this.selection.selectionCount === 1
? html`<wa-dropdown-item @click=${() => this.onContextMenuAction('track-details')}>
```
With:
```typescript
${html`<wa-dropdown-item @click=${() => this.onContextMenuAction('track-details')}>
```
Remove the corresponding `: nothing}` closing.
When `track-details` action is triggered with multiple selections, use the first selected track (or the right-clicked track). Check how `onContextMenuAction` resolves the target — it should use the context menu target row's `FilePath`, not require exactly 1 selection.
**In `frontend/src/components/queue-panel/queue-panel.ts`**, apply the same fix: remove the `selectionCount === 1` condition around the "Track Details" context menu item (around line 1553). The queue always has a specific right-click target (the clicked track row), so Track Details should always be available.
**In `frontend/src/components/cover-grid/cover-grid.ts`**, find the Track Details context menu item condition (it checks `contextMenuTarget.kind === 'track' && selectedTracks.size === 1`). Change to only check `contextMenuTarget.kind === 'track'` — the dialog opens for the right-clicked track regardless of multi-selection.
**In `frontend/src/components/playlist-details/playlist-details.ts`**, apply the same fix: remove the `selectionCount === 1` condition for the "Track Details" menu item.
For all 4 components: when Track Details is activated with multiple tracks selected, the `openTrackDetails` method should open details for the first selected track (or the context-menu-target track). Review each component's `openTrackDetails` to ensure it works with the right-clicked track, not the full selection.
</action>
<verify>
`pnpm run typecheck` in frontend/ passes.
`rg "TrackMetadataChanged" frontend/src/store/library-store.ts` shows the new event handler.
`rg "selectionCount === 1" frontend/src/components/track-list/track-list.ts frontend/src/components/queue-panel/queue-panel.ts` returns no matches (condition removed).
</verify>
<done>
TrackMetadataChanged event handler added to LibraryStore — after a tag write, all caches are invalidated and views refresh.
"Track Details" context menu item appears when right-clicking any track in all 4 views (track-list, queue-panel, cover-grid, playlist-details) regardless of how many tracks are selected.
</done>
</task>
</tasks>
<verification>
- `go build -tags webkit2_41 ./...` compiles
- `pnpm run typecheck` (in frontend/) passes
- `make generate` succeeds
- WriteTrackTagsByPath binding exists in `frontend/wailsjs/go/tagwriter/`
- ImageFilePicker binding exists in `frontend/wailsjs/go/frontendutil/`
- LibraryStore listens for TrackMetadataChanged
- "Track Details" context menu item no longer gated on single selection
</verification>
<success_criteria>
Backend methods ready for Plan 02 to call from the track-details dialog. Library store will automatically refresh all views when tag writes complete. Context menu shows "Track Details" for any right-clicked track.
</success_criteria>
<output>
After completion, create `.planning/phases/17-single-track-edit/17-01-SUMMARY.md`
</output>
@@ -0,0 +1,634 @@
---
phase: 17-single-track-edit
plan: 02
type: execute
wave: 2
depends_on:
- 17-01
files_modified:
- frontend/src/components/track-details/track-details.ts
autonomous: false
requirements:
- EDIT-02
- EDIT-03
- EDIT-04
must_haves:
truths:
- "Clicking Save builds a TagChanges diff map from only the fields the user actually modified and calls WriteTrackTagsByPath"
- "While saving, the Save button is disabled and shows a saving indicator; Edit mode stays active on error with the error message displayed inline"
- "In edit mode, clicking the cover art image opens a native file picker filtered to JPEG/PNG; selected image previews instantly via object URL"
- "A remove button appears on the cover art in edit mode allowing the user to clear embedded art"
- "After successful save, the dialog switches to read-only view mode and re-fetches its track data to show updated values"
- "Empty fields show as empty in the editor, not 'Unknown'"
artifacts:
- path: "frontend/src/components/track-details/track-details.ts"
provides: "Complete save flow, cover art edit UI, error handling, saving state"
min_lines: 750
key_links:
- from: "frontend/src/components/track-details/track-details.ts"
to: "frontend/wailsjs/go/tagwriter/TagWriter"
via: "WriteTrackTagsByPath import and call in saveEdit"
pattern: "WriteTrackTagsByPath"
- from: "frontend/src/components/track-details/track-details.ts"
to: "frontend/wailsjs/go/frontendutil/FrontendUtil"
via: "ImageFilePicker import and call for cover art selection"
pattern: "ImageFilePicker"
---
<objective>
Wire the track-details dialog's edit mode to the real backend, add cover art editing, and implement error handling with saving state.
Purpose: This is the core user-facing work of Phase 17. The track-details dialog already has full edit mode scaffolding (inputs, editValues record, Edit/Save/Cancel buttons) but `saveEdit()` is a TODO stub. This plan implements the real save flow, adds cover art replacement/removal UI, and handles errors inline in the dialog.
Output: A fully functional single-track tag editor that writes changes to the audio file, updates the database, and refreshes views.
</objective>
<execution_context>
@/home/caleb/.config/opencode/get-shit-done/workflows/execute-plan.md
@/home/caleb/.config/opencode/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/17-single-track-edit/17-CONTEXT.md
@.planning/phases/17-single-track-edit/17-01-SUMMARY.md
<interfaces>
<!-- Contracts from Plan 01 (backend methods) and existing codebase. -->
From frontend/wailsjs/go/tagwriter/TagWriter (generated by Plan 01):
```typescript
export function WriteTrackTagsByPath(filePath: string, changes: Record<string, any>): Promise<void>;
```
From frontend/wailsjs/go/frontendutil/FrontendUtil (generated by Plan 01):
```typescript
export function ImageFilePicker(): Promise<string>;
```
From backend/tagwriter/tagwriter.go (field constants — use these as diff map keys):
```
title, artist, album, album_artist, genre, year,
track_number, disc_number, composer, cover_art
```
From frontend/src/components/track-details/track-details.ts (existing state):
```typescript
@state() private track: library.Track | null = null;
@state() private coverArt: CoverArtUrls | null = null;
@state() private editing = false;
@state() private editValues: Record<string, string> = {};
// Existing methods:
show(track: library.Track, coverArt?: CoverArtUrls): void
startEdit(): void // sets editing=true, clears editValues
cancelEdit(): void // sets editing=false, clears editValues
saveEdit(): void // TODO stub — exits edit mode
getEditValue(key, fallback): string
onEditInput(key, e): void
// Edit field keys used in editValues:
// Main: 'title', 'artist', 'album'
// Detail grid: 'genre', 'year', 'composer', 'trackNumber', 'discNumber'
```
From library.Track type (Go → TS):
```typescript
interface Track {
TrackName: string;
ArtistName: string;
TrackLength: string; // milliseconds as string
FilePath: string;
TrackNumber: number;
DiscNumber: number;
Album: string;
Genre: string[];
Year: number;
Composer: string;
FileType: string;
SampleRate: number;
BitDepth: number;
Channels: number;
Bitrate: number;
FileSize: number;
}
```
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: Implement saveEdit, cover art editing, error handling, and saving state</name>
<files>frontend/src/components/track-details/track-details.ts</files>
<action>
**Add new state properties** to the component class:
```typescript
@state() private saving = false;
@state() private errorMessage = '';
@state() private pendingCoverArt: { data: ArrayBuffer; previewUrl: string } | null = null;
@state() private clearCoverArt = false;
```
- `saving`: true while WriteTrackTagsByPath is in progress
- `errorMessage`: error string shown inline in the dialog when save fails
- `pendingCoverArt`: holds the selected cover art image (read from disk) and its object URL for instant preview
- `clearCoverArt`: true when user wants to remove existing embedded cover art
**Add new imports** at the top of the file:
```typescript
import { WriteTrackTagsByPath } from '@go/tagwriter/TagWriter';
import { ImageFilePicker } from '@go/frontendutil/FrontendUtil';
```
**Implement `saveEdit`** — replace the TODO stub:
```typescript
private saveEdit = async () => {
if (!this.track || this.saving) return;
this.saving = true;
this.errorMessage = '';
try {
const changes = this.buildChanges();
if (Object.keys(changes).length === 0) {
// No actual changes — just exit edit mode.
this.exitEditMode();
return;
}
await WriteTrackTagsByPath(this.track.FilePath, changes);
// Success — switch to read-only view, re-fetch track data.
// The TrackMetadataChanged event will trigger library store
// invalidation, which refreshes all other views. The dialog
// itself stays open in read-only mode so the user can verify.
this.exitEditMode();
// Note: The dialog's track data will be stale until the parent
// component re-opens it or we add a refresh mechanism.
// For now, closing edit mode with the old data is acceptable
// since the user can see updated data in the views behind.
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
this.errorMessage = msg;
} finally {
this.saving = false;
}
};
```
**Add `buildChanges` helper** — builds the TagChanges diff map from editValues, comparing against original track values. Only include fields that actually changed:
```typescript
private buildChanges(): Record<string, any> {
const t = this.track!;
const changes: Record<string, any> = {};
// Map frontend edit keys to backend field constants and original values.
const fieldMap: Array<{
editKey: string;
backendKey: string;
original: string;
transform?: (v: string) => any;
}> = [
{ editKey: 'title', backendKey: 'title', original: t.TrackName },
{ editKey: 'artist', backendKey: 'artist', original: t.ArtistName },
{ editKey: 'album', backendKey: 'album', original: t.Album },
{ editKey: 'genre', backendKey: 'genre', original: (t.Genre ?? []).join(', ') },
{
editKey: 'year',
backendKey: 'year',
original: t.Year ? String(t.Year) : '',
transform: (v) => v ? parseInt(v, 10) : 0,
},
{ editKey: 'composer', backendKey: 'composer', original: t.Composer ?? '' },
{
editKey: 'trackNumber',
backendKey: 'track_number',
original: t.TrackNumber ? String(t.TrackNumber) : '',
transform: (v) => v ? parseInt(v, 10) : 0,
},
{
editKey: 'discNumber',
backendKey: 'disc_number',
original: t.DiscNumber ? String(t.DiscNumber) : '',
transform: (v) => v ? parseInt(v, 10) : 0,
},
];
for (const { editKey, backendKey, original, transform } of fieldMap) {
if (editKey in this.editValues) {
const newVal = this.editValues[editKey]!;
if (newVal !== original) {
changes[backendKey] = transform ? transform(newVal) : newVal;
}
}
}
// Cover art changes.
if (this.pendingCoverArt) {
// Convert ArrayBuffer to number[] for JSON serialization
// (Wails will pass this as []byte on the Go side).
changes['cover_art'] = Array.from(
new Uint8Array(this.pendingCoverArt.data),
);
} else if (this.clearCoverArt) {
changes['cover_art'] = null;
}
return changes;
}
```
**Add `exitEditMode` helper:**
```typescript
private exitEditMode(): void {
this.editing = false;
this.editValues = {};
this.errorMessage = '';
this.cleanupPendingCoverArt();
}
```
**Add cover art cleanup helper:**
```typescript
private cleanupPendingCoverArt(): void {
if (this.pendingCoverArt?.previewUrl) {
URL.revokeObjectURL(this.pendingCoverArt.previewUrl);
}
this.pendingCoverArt = null;
this.clearCoverArt = false;
}
```
**Add `selectCoverArt` handler** — opens native file picker, reads file, creates preview:
```typescript
private selectCoverArt = async () => {
try {
const filePath = await ImageFilePicker();
if (!filePath) return; // User cancelled.
// Read the file as bytes via fetch from the filesystem.
// Wails serves local files via the asset handler, but we
// need the raw bytes. Use a Go helper or read via fetch.
// Actually, we need to read the file on the Go side and
// return the bytes. For now, store just the path and
// let the Go side read it during WriteTrackTags.
//
// Alternative approach: Read file in Go, return base64.
// But WriteTrackTags already handles reading cover_art
// as []byte from the changes map.
//
// Simplest approach: Read file via Go, return bytes.
// But we also need a preview. Two options:
// A) Read in Go, return base64, decode for preview
// B) Use Wails local file URL for preview, read in Go for save
//
// Going with approach B: preview via local file URL,
// save by reading file bytes in a new Go method.
// Actually — Wails doesn't serve arbitrary local files.
//
// Going with approach A: Add a ReadFileBytes Go method,
// or just read the file path in the cover art changes.
//
// Simplest: Pass the file PATH as cover_art in changes.
// The Go side detects string vs []byte and reads the file.
// BUT: TagChanges defines cover_art as []byte.
//
// Most practical approach for preview + save:
// Use a FileReader to read the file... but we don't have
// a File object (we have a path from a native dialog).
//
// DECISION: Add a ReadImageFile Go method to FrontendUtil
// that returns base64 string. Use for both preview and save.
// OR: Change the cover_art handling to accept a file path
// string and read it in Go.
//
// Actually, the simplest approach per CONTEXT.md:
// "Cover art bytes are sent to WriteTrackTags via the
// cover_art field ([]byte for set, nil/sentinel for clear)"
// So we need the bytes on the frontend. But we only have a
// file path. We need a Go helper to read the file.
// PRACTICAL SOLUTION: Store the file path. Add a small
// Go helper `ReadFile(path string) ([]byte, error)` on
// FrontendUtil that returns the raw bytes. Use the bytes
// for both preview (via Blob URL) and save (via changes map).
//
// This is the cleanest approach. Implement ReadFile below.
const bytes = await this.readCoverArtFile(filePath);
if (!bytes) return;
// Create preview URL from bytes.
const blob = new Blob([bytes]);
const previewUrl = URL.createObjectURL(blob);
this.cleanupPendingCoverArt();
this.pendingCoverArt = {
data: bytes.buffer,
previewUrl,
};
this.clearCoverArt = false;
} catch (err) {
console.error('Failed to select cover art:', err);
}
};
```
**IMPORTANT IMPLEMENTATION NOTE:** The above approach requires reading the selected image file's bytes on the Go side and returning them to the frontend. Add a `ReadFile` method to `FrontendUtil`:
In `backend/frontendutil/frontendutil.go`, add:
```go
// ReadFile reads a file from disk and returns its contents.
// Used by the frontend to read cover art image files selected
// via ImageFilePicker.
func (fe *FrontendUtil) ReadFile(path string) ([]byte, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("read file %q: %w", path, err)
}
return data, nil
}
```
Add `"os"` to the imports if not present. Then add the frontend wrapper:
```typescript
private async readCoverArtFile(filePath: string): Promise<Uint8Array | null> {
try {
// ReadFile returns number[] (Go []byte serialized as JSON array).
const { ReadFile } = await import('@go/frontendutil/FrontendUtil');
const bytes = await ReadFile(filePath);
return new Uint8Array(bytes);
} catch (err) {
console.error('Failed to read cover art file:', err);
return null;
}
}
```
After adding `ReadFile` to Go, run `make generate` to create the binding.
**Add `removeCoverArt` handler:**
```typescript
private removeCoverArt = () => {
this.cleanupPendingCoverArt();
this.clearCoverArt = true;
};
```
**Update `renderCoverArt`** — in edit mode, make the cover art clickable with an edit overlay and a remove button:
In edit mode:
- Wrap the cover art in a clickable container with a semi-transparent overlay showing an edit/pencil icon
- If `pendingCoverArt` is set, show its `previewUrl` instead of the original cover art
- If `clearCoverArt` is true, show the placeholder (music icon)
- Add a small "×" remove button positioned absolutely in the top-right corner of the cover art
Add CSS for:
- `.cover-art-edit` container with `cursor: pointer` and `position: relative`
- `.cover-art-overlay` — semi-transparent dark overlay with centered pencil icon, shown on hover
- `.cover-art-remove` — small × button in top-right corner, `position: absolute`
```typescript
private renderCoverArt() {
if (this.editing) {
return this.renderCoverArtEditable();
}
// ... existing read-only render
}
private renderCoverArtEditable() {
const showRemove = !this.clearCoverArt && (this.pendingCoverArt || this.coverArt);
// Determine which image to show.
let src: string | undefined;
if (this.clearCoverArt) {
src = undefined; // Show placeholder.
} else if (this.pendingCoverArt) {
src = this.pendingCoverArt.previewUrl;
} else {
src = this.coverArt?.coverArtLarge ??
this.coverArt?.coverArtMedium ??
this.coverArt?.coverArtPath;
}
return html`
<div class="cover-art cover-art-edit" @click=${this.selectCoverArt}>
${src
? html`<img src="${src}" alt="Album cover" @error=${this.handleImageError} />`
: html`<div class="cover-placeholder">
<wa-icon name="music"></wa-icon>
</div>`}
<div class="cover-art-overlay">
<wa-icon name="pen-to-square"></wa-icon>
</div>
${showRemove
? html`<button class="cover-art-remove"
@click=${(e: Event) => { e.stopPropagation(); this.removeCoverArt(); }}
title="Remove cover art">×</button>`
: nothing}
</div>
`;
}
```
**Add CSS for cover art edit mode:**
```css
.cover-art-edit {
cursor: pointer;
position: relative;
}
.cover-art-overlay {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
transition: opacity 0.15s ease;
border-radius: 6px;
}
.cover-art-edit:hover .cover-art-overlay {
opacity: 1;
}
.cover-art-overlay wa-icon {
color: #fff;
font-size: 32px;
}
.cover-art-remove {
position: absolute;
top: 4px;
right: 4px;
width: 24px;
height: 24px;
border-radius: 50%;
border: none;
background: rgba(0, 0, 0, 0.7);
color: #fff;
font-size: 14px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
transition: opacity 0.15s ease;
}
.cover-art-edit:hover .cover-art-remove {
opacity: 1;
}
.cover-art-remove:hover {
background: var(--yj-error, #e03131);
}
```
**Update `renderActions`** — add saving state and error display:
```typescript
private renderActions() {
if (this.editing) {
return html`
${this.errorMessage
? html`<div class="error-message">${this.errorMessage}</div>`
: nothing}
<button class="btn" @click=${this.cancelEdit} ?disabled=${this.saving}>
Cancel
</button>
<button class="btn btn-primary" @click=${this.saveEdit} ?disabled=${this.saving}>
${this.saving ? 'Saving…' : 'Save'}
</button>
`;
}
// ... existing Edit button
}
```
**Add CSS for error message:**
```css
.error-message {
flex: 1;
color: var(--yj-error, #e03131);
font-size: var(--yj-text-sm);
padding: 4px 0;
word-break: break-word;
}
```
**Update `cancelEdit`** to clean up cover art state:
```typescript
private cancelEdit = () => {
this.exitEditMode();
};
```
**Update `startEdit`** to clear error and cover art state:
```typescript
private startEdit = () => {
this.editing = true;
this.editValues = {};
this.errorMessage = '';
this.cleanupPendingCoverArt();
};
```
**Update the `show` method** to clean up any stale cover art preview:
In the `show` method, add `this.cleanupPendingCoverArt();` and `this.errorMessage = '';` alongside the existing state resets.
**Update the `close` method** to clean up:
In `close`, add `this.cleanupPendingCoverArt();` and `this.errorMessage = '';`.
</action>
<verify>
`pnpm run typecheck` in frontend/ passes.
`go build -tags webkit2_41 ./...` compiles (for the ReadFile addition).
`make generate` succeeds.
Manual visual test (checkpoint task below).
</verify>
<done>
saveEdit() builds a diff map of only changed fields and calls WriteTrackTagsByPath.
Save button shows "Saving…" and is disabled during save; Cancel is also disabled.
Errors display inline in the dialog action bar; edit mode stays active on error.
In edit mode, clicking cover art opens native file picker for JPEG/PNG.
Selected image previews instantly via object URL.
Remove button (×) appears on cover art hover in edit mode to clear embedded art.
After successful save, dialog returns to read-only view mode.
All edit state (editValues, pendingCoverArt, clearCoverArt, errorMessage) cleaned up on close/cancel.
</done>
</task>
<task type="checkpoint:human-verify" gate="blocking">
<name>Task 2: Verify complete single-track edit flow</name>
<files>frontend/src/components/track-details/track-details.ts</files>
<action>
Human verification of the complete edit flow built in Task 1.
What was built: Complete single-track tag editing flow — right-click → Track Details → Edit → modify fields and/or cover art → Save → see changes reflected everywhere.
</action>
<verify>
1. Run `make dev` to start the application
2. Right-click any track in the track list — verify "Track Details" appears in context menu
3. Click "Track Details" — verify dialog opens with all 8 editable fields pre-populated
4. Click "Edit" button — verify all fields become editable inputs, cover art shows edit overlay on hover
5. Change the track title to something recognizable (e.g., add " [EDITED]")
6. Click "Save" — verify:
- Save button shows "Saving…" briefly
- Dialog switches back to read-only mode showing the new title
- Track list behind the dialog updates to show the new title
- Album view, artist view, genre view all reflect the change
7. Close the dialog, re-open Track Details for the same track — verify edited title persists
8. Test cover art: Edit → click the cover art → select a JPEG/PNG — verify preview shows instantly → Save
9. Test cover art removal: Edit → hover cover art → click × — verify placeholder shown → Save
10. Test error case: try editing a track in an unsupported format (e.g., .wav or .ogg if any) — verify error shows inline
11. Test cancel: Edit → change fields → Cancel — verify no changes saved
12. Test multi-select: select multiple tracks → right-click — verify "Track Details" still appears
</verify>
<done>
All 12 verification steps pass. Single track editing works end-to-end: tag writes, cover art replacement/removal, error handling, view refresh, and context menu accessibility.
</done>
</task>
</tasks>
<verification>
- All 8 editable fields (title, artist, album, genre, year, track#, disc#, composer) work in edit mode
- Cover art can be replaced (JPEG/PNG file picker with instant preview) and removed (× button)
- Save builds a diff map of only changed fields — unchanged fields are not sent
- Error handling shows inline message, edit mode stays active
- Saving state disables buttons, shows "Saving…" indicator
- After save, dialog returns to read-only view
- TrackMetadataChanged event triggers full library store refresh
- All views (track list, album, artist, genre, queue, now-playing) update after save
</verification>
<success_criteria>
User can edit any track's metadata and cover art from within the app. Changes are written to the audio file, synchronized to the database and search index, and reflected in all views immediately without restarting or rescanning.
</success_criteria>
<output>
After completion, create `.planning/phases/17-single-track-edit/17-02-SUMMARY.md`
</output>