feat(17-02): implement save flow, cover art editing, and error handling

- Wire saveEdit() to WriteTrackTagsByPath with diff-only TagChanges map
- Add cover art selection via ImageFilePicker with instant blob preview
- Add cover art removal (× button) with clearCoverArt state
- Show saving indicator and disable buttons during save
- Display errors inline in action bar; edit mode stays active on error
- Add ReadFile Go method on FrontendUtil to read cover art bytes
- Add Wails binding for ReadFile
- Clean up all edit state (editValues, pendingCoverArt, errorMessage) on close/cancel
This commit is contained in:
2026-03-17 21:20:01 -04:00
parent b776f3acb9
commit 265a9ea8ce
4 changed files with 373 additions and 6 deletions
+13
View File
@@ -4,6 +4,7 @@ package frontendutil
import (
"context"
"fmt"
"os"
"github.com/wailsapp/wails/v2/pkg/runtime"
)
@@ -90,3 +91,15 @@ func (fe *FrontendUtil) ImageFilePicker() (string, error) {
return file, nil
}
// 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
}
@@ -13,6 +13,8 @@ import {
formatFileSize,
} from '@utils/format';
import { formatMilliseconds } from '@utils/time';
import { WriteTrackTagsByPath } from '@go/tagwriter/TagWriter';
import { ImageFilePicker, ReadFile } from '@go/frontendutil/FrontendUtil';
import '@awesome.me/webawesome/dist/components/dialog/dialog.js';
import '@awesome.me/webawesome/dist/components/icon/icon.js';
@@ -47,6 +49,13 @@ export class TrackDetails extends LitElement {
@state() private coverArt: CoverArtUrls | null = null;
@state() private editing = false;
@state() private editValues: Record<string, string> = {};
@state() private saving = false;
@state() private errorMessage = '';
@state() private pendingCoverArt: {
data: ArrayBuffer;
previewUrl: string;
} | null = null;
@state() private clearCoverArt = false;
@query('wa-dialog')
private dialog!: HTMLElement & { open: boolean };
@@ -64,6 +73,8 @@ export class TrackDetails extends LitElement {
this.coverArt = coverArt ?? null;
this.editing = false;
this.editValues = {};
this.errorMessage = '';
this.cleanupPendingCoverArt();
this.updateComplete.then(() => {
if (this.dialog) this.dialog.open = true;
@@ -75,6 +86,8 @@ export class TrackDetails extends LitElement {
if (this.dialog) this.dialog.open = false;
this.editing = false;
this.editValues = {};
this.errorMessage = '';
this.cleanupPendingCoverArt();
}
// =================================================================
@@ -314,6 +327,74 @@ export class TrackDetails extends LitElement {
#ffe066
);
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Cover art edit mode */
.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);
}
/* Error message */
.error-message {
flex: 1;
color: var(--yj-error, #e03131);
font-size: var(--yj-text-sm);
padding: 4px 0;
word-break: break-word;
}
`];
// =================================================================
@@ -358,6 +439,10 @@ export class TrackDetails extends LitElement {
}
private renderCoverArt() {
if (this.editing) {
return this.renderCoverArtEditable();
}
const src =
this.coverArt?.coverArtLarge ??
this.coverArt?.coverArtMedium ??
@@ -386,6 +471,62 @@ export class TrackDetails extends LitElement {
`;
}
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>
`;
}
private handleImageError = (e: Event) => {
const img = e.target as HTMLImageElement;
const fallback = this.coverArt?.coverArtPath;
@@ -608,17 +749,24 @@ export class TrackDetails extends LitElement {
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}
>
Save
${this.saving ? 'Saving…' : 'Save'}
</button>
`;
}
@@ -641,18 +789,218 @@ export class TrackDetails extends LitElement {
private startEdit = () => {
this.editing = true;
this.editValues = {};
this.errorMessage = '';
this.cleanupPendingCoverArt();
};
private cancelEdit = () => {
this.editing = false;
this.editValues = {};
this.exitEditMode();
};
private saveEdit = () => {
// TODO: implement tag writing when backend support is added.
// For now, just exit edit mode.
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. The
// TrackMetadataChanged event triggers library store
// invalidation, which refreshes all other views.
this.exitEditMode();
} catch (err: unknown) {
const msg =
err instanceof Error
? err.message
: String(err);
this.errorMessage = msg;
} finally {
this.saving = false;
}
};
private buildChanges(): Record<string, unknown> {
const t = this.track!;
const changes: Record<string, unknown> = {};
// Map frontend edit keys to backend field constants
// and original values.
const fieldMap: Array<{
editKey: string;
backendKey: string;
original: string;
transform?: (v: string) => unknown;
}> = [
{
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 passes as []byte on Go side).
changes['cover_art'] = Array.from(
new Uint8Array(this.pendingCoverArt.data),
);
} else if (this.clearCoverArt) {
changes['cover_art'] = null;
}
return changes;
}
private exitEditMode(): void {
this.editing = false;
this.editValues = {};
this.errorMessage = '';
this.cleanupPendingCoverArt();
}
private cleanupPendingCoverArt(): void {
if (this.pendingCoverArt?.previewUrl) {
URL.revokeObjectURL(
this.pendingCoverArt.previewUrl,
);
}
this.pendingCoverArt = null;
this.clearCoverArt = false;
}
private selectCoverArt = async () => {
try {
const filePath = await ImageFilePicker();
if (!filePath) return; // User cancelled.
const bytes =
await this.readCoverArtFile(filePath);
if (!bytes) return;
// Create preview URL from bytes.
const buffer = (bytes.buffer as ArrayBuffer).slice(
bytes.byteOffset,
bytes.byteOffset + bytes.byteLength,
);
const blob = new Blob([buffer]);
const previewUrl =
URL.createObjectURL(blob);
this.cleanupPendingCoverArt();
this.pendingCoverArt = {
data: buffer,
previewUrl,
};
this.clearCoverArt = false;
} catch (err) {
console.error(
'Failed to select cover art:',
err,
);
}
};
private async readCoverArtFile(
filePath: string,
): Promise<Uint8Array | null> {
try {
// ReadFile returns number[] (Go []byte serialized
// as JSON array).
const bytes = await ReadFile(filePath);
return new Uint8Array(bytes);
} catch (err) {
console.error(
'Failed to read cover art file:',
err,
);
return null;
}
}
private removeCoverArt = () => {
this.cleanupPendingCoverArt();
this.clearCoverArt = true;
};
private getEditValue(
+2
View File
@@ -8,4 +8,6 @@ export function ImageFilePicker():Promise<string>;
export function PlaylistFilePicker():Promise<Array<string>>;
export function ReadFile(arg1:string):Promise<Array<number>>;
export function SetContext(arg1:context.Context):Promise<void>;
@@ -14,6 +14,10 @@ export function PlaylistFilePicker() {
return window['go']['frontendutil']['FrontendUtil']['PlaylistFilePicker']();
}
export function ReadFile(arg1) {
return window['go']['frontendutil']['FrontendUtil']['ReadFile'](arg1);
}
export function SetContext(arg1) {
return window['go']['frontendutil']['FrontendUtil']['SetContext'](arg1);
}