diff --git a/backend/frontendutil/frontendutil.go b/backend/frontendutil/frontendutil.go index c53fee2..4ec6752 100644 --- a/backend/frontendutil/frontendutil.go +++ b/backend/frontendutil/frontendutil.go @@ -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 +} diff --git a/frontend/src/components/track-details/track-details.ts b/frontend/src/components/track-details/track-details.ts index 3ce1984..2711ab9 100644 --- a/frontend/src/components/track-details/track-details.ts +++ b/frontend/src/components/track-details/track-details.ts @@ -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 = {}; + @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` +
+ ${src + ? html`Album cover` + : html`
+ +
`} +
+ +
+ ${showRemove + ? html`` + : nothing} +
+ `; + } + 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`
+ ${this.errorMessage} +
` + : nothing} `; } @@ -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 { + const t = this.track!; + const changes: Record = {}; + + // 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 { + 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( diff --git a/frontend/wailsjs/go/frontendutil/FrontendUtil.d.ts b/frontend/wailsjs/go/frontendutil/FrontendUtil.d.ts index 9f23326..3c75085 100755 --- a/frontend/wailsjs/go/frontendutil/FrontendUtil.d.ts +++ b/frontend/wailsjs/go/frontendutil/FrontendUtil.d.ts @@ -8,4 +8,6 @@ export function ImageFilePicker():Promise; export function PlaylistFilePicker():Promise>; +export function ReadFile(arg1:string):Promise>; + export function SetContext(arg1:context.Context):Promise; diff --git a/frontend/wailsjs/go/frontendutil/FrontendUtil.js b/frontend/wailsjs/go/frontendutil/FrontendUtil.js index 2d51b3f..b0e52a4 100755 --- a/frontend/wailsjs/go/frontendutil/FrontendUtil.js +++ b/frontend/wailsjs/go/frontendutil/FrontendUtil.js @@ -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); }