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:
@@ -4,6 +4,7 @@ package frontendutil
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"os"
|
||||||
|
|
||||||
"github.com/wailsapp/wails/v2/pkg/runtime"
|
"github.com/wailsapp/wails/v2/pkg/runtime"
|
||||||
)
|
)
|
||||||
@@ -90,3 +91,15 @@ func (fe *FrontendUtil) ImageFilePicker() (string, error) {
|
|||||||
|
|
||||||
return file, nil
|
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,
|
formatFileSize,
|
||||||
} from '@utils/format';
|
} from '@utils/format';
|
||||||
import { formatMilliseconds } from '@utils/time';
|
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/dialog/dialog.js';
|
||||||
import '@awesome.me/webawesome/dist/components/icon/icon.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 coverArt: CoverArtUrls | null = null;
|
||||||
@state() private editing = false;
|
@state() private editing = false;
|
||||||
@state() private editValues: Record<string, string> = {};
|
@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')
|
@query('wa-dialog')
|
||||||
private dialog!: HTMLElement & { open: boolean };
|
private dialog!: HTMLElement & { open: boolean };
|
||||||
@@ -64,6 +73,8 @@ export class TrackDetails extends LitElement {
|
|||||||
this.coverArt = coverArt ?? null;
|
this.coverArt = coverArt ?? null;
|
||||||
this.editing = false;
|
this.editing = false;
|
||||||
this.editValues = {};
|
this.editValues = {};
|
||||||
|
this.errorMessage = '';
|
||||||
|
this.cleanupPendingCoverArt();
|
||||||
|
|
||||||
this.updateComplete.then(() => {
|
this.updateComplete.then(() => {
|
||||||
if (this.dialog) this.dialog.open = true;
|
if (this.dialog) this.dialog.open = true;
|
||||||
@@ -75,6 +86,8 @@ export class TrackDetails extends LitElement {
|
|||||||
if (this.dialog) this.dialog.open = false;
|
if (this.dialog) this.dialog.open = false;
|
||||||
this.editing = false;
|
this.editing = false;
|
||||||
this.editValues = {};
|
this.editValues = {};
|
||||||
|
this.errorMessage = '';
|
||||||
|
this.cleanupPendingCoverArt();
|
||||||
}
|
}
|
||||||
|
|
||||||
// =================================================================
|
// =================================================================
|
||||||
@@ -314,6 +327,74 @@ export class TrackDetails extends LitElement {
|
|||||||
#ffe066
|
#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() {
|
private renderCoverArt() {
|
||||||
|
if (this.editing) {
|
||||||
|
return this.renderCoverArtEditable();
|
||||||
|
}
|
||||||
|
|
||||||
const src =
|
const src =
|
||||||
this.coverArt?.coverArtLarge ??
|
this.coverArt?.coverArtLarge ??
|
||||||
this.coverArt?.coverArtMedium ??
|
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) => {
|
private handleImageError = (e: Event) => {
|
||||||
const img = e.target as HTMLImageElement;
|
const img = e.target as HTMLImageElement;
|
||||||
const fallback = this.coverArt?.coverArtPath;
|
const fallback = this.coverArt?.coverArtPath;
|
||||||
@@ -608,17 +749,24 @@ export class TrackDetails extends LitElement {
|
|||||||
private renderActions() {
|
private renderActions() {
|
||||||
if (this.editing) {
|
if (this.editing) {
|
||||||
return html`
|
return html`
|
||||||
|
${this.errorMessage
|
||||||
|
? html`<div class="error-message">
|
||||||
|
${this.errorMessage}
|
||||||
|
</div>`
|
||||||
|
: nothing}
|
||||||
<button
|
<button
|
||||||
class="btn"
|
class="btn"
|
||||||
@click=${this.cancelEdit}
|
@click=${this.cancelEdit}
|
||||||
|
?disabled=${this.saving}
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
class="btn btn-primary"
|
class="btn btn-primary"
|
||||||
@click=${this.saveEdit}
|
@click=${this.saveEdit}
|
||||||
|
?disabled=${this.saving}
|
||||||
>
|
>
|
||||||
Save
|
${this.saving ? 'Saving…' : 'Save'}
|
||||||
</button>
|
</button>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
@@ -641,18 +789,218 @@ export class TrackDetails extends LitElement {
|
|||||||
private startEdit = () => {
|
private startEdit = () => {
|
||||||
this.editing = true;
|
this.editing = true;
|
||||||
this.editValues = {};
|
this.editValues = {};
|
||||||
|
this.errorMessage = '';
|
||||||
|
this.cleanupPendingCoverArt();
|
||||||
};
|
};
|
||||||
|
|
||||||
private cancelEdit = () => {
|
private cancelEdit = () => {
|
||||||
this.editing = false;
|
this.exitEditMode();
|
||||||
this.editValues = {};
|
|
||||||
};
|
};
|
||||||
|
|
||||||
private saveEdit = () => {
|
private saveEdit = async () => {
|
||||||
// TODO: implement tag writing when backend support is added.
|
if (!this.track || this.saving) return;
|
||||||
// For now, just exit edit mode.
|
|
||||||
|
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.editing = false;
|
||||||
this.editValues = {};
|
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(
|
private getEditValue(
|
||||||
|
|||||||
@@ -8,4 +8,6 @@ export function ImageFilePicker():Promise<string>;
|
|||||||
|
|
||||||
export function PlaylistFilePicker():Promise<Array<string>>;
|
export function PlaylistFilePicker():Promise<Array<string>>;
|
||||||
|
|
||||||
|
export function ReadFile(arg1:string):Promise<Array<number>>;
|
||||||
|
|
||||||
export function SetContext(arg1:context.Context):Promise<void>;
|
export function SetContext(arg1:context.Context):Promise<void>;
|
||||||
|
|||||||
@@ -14,6 +14,10 @@ export function PlaylistFilePicker() {
|
|||||||
return window['go']['frontendutil']['FrontendUtil']['PlaylistFilePicker']();
|
return window['go']['frontendutil']['FrontendUtil']['PlaylistFilePicker']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function ReadFile(arg1) {
|
||||||
|
return window['go']['frontendutil']['FrontendUtil']['ReadFile'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function SetContext(arg1) {
|
export function SetContext(arg1) {
|
||||||
return window['go']['frontendutil']['FrontendUtil']['SetContext'](arg1);
|
return window['go']['frontendutil']['FrontendUtil']['SetContext'](arg1);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user