Files
yellowjacket/.planning/phases/17-single-track-edit/17-02-PLAN.md
T

635 lines
22 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
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>