Skip to content

Commit 6af8b8d

Browse files
committed
Editing/uploading cover art is now a thing
1 parent 3bb1a56 commit 6af8b8d

12 files changed

+331
-32
lines changed

src/MyWindow.ts

+62-7
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,24 @@
11
// This is for getting at "global" stuff from the window object
22
import { ISearchBox } from '@fluentui/react';
3-
import { FTON, MakeError, MakeLogger, Type } from '@freik/core-utils';
4-
import { IpcRenderer } from 'electron';
3+
import {
4+
AlbumKey,
5+
FTON,
6+
MakeError,
7+
MakeLogger,
8+
SongKey,
9+
Type,
10+
} from '@freik/core-utils';
11+
import { IpcRenderer, NativeImage } from 'electron';
512
import { IpcRendererEvent, OpenDialogSyncOptions } from 'electron/main';
13+
import { PathLike } from 'fs';
14+
import { FileHandle } from 'fs/promises';
615
import { HandleMessage } from './ipc';
716

8-
const log = MakeLogger('MyWindow');
17+
const log = MakeLogger('MyWindow', true);
918
const err = MakeError('MyWindow-err');
1019

20+
type ReadFile1 = (path: PathLike | FileHandle) => Promise<Buffer>;
21+
1122
/*
1223
* "Window" stuff goes here
1324
*/
@@ -19,6 +30,8 @@ interface MyWindow extends Window {
1930
initApp?: () => void;
2031
ipcSet?: boolean;
2132
searchBox?: ISearchBox | null;
33+
clipboard: Electron.Clipboard | undefined;
34+
readFile: ReadFile1; // | ReadFile2 | ReadFile3;
2235
}
2336

2437
declare let window: MyWindow;
@@ -87,15 +100,15 @@ export function UnsubscribeMediaMatcher(
87100
mediaQuery?.removeEventListener('change', handler);
88101
}
89102

90-
export async function InvokeMain(
103+
export async function InvokeMain<T>(
91104
channel: string,
92-
key?: string,
105+
key?: T,
93106
): Promise<string | void> {
94107
let result;
95108
if (key) {
96-
log(`Invoking main("${channel}", "${key}")`);
109+
log(`Invoking main("${channel}", "...")`);
97110
result = (await window.ipc!.invoke(channel, key)) as string;
98-
log(`Invoke main ("${channel}" "${key}") returned:`);
111+
log(`Invoke main ("${channel}" "...") returned:`);
99112
} else {
100113
log(`Invoking main("${channel}")`);
101114
result = (await window.ipc!.invoke(channel)) as string;
@@ -104,3 +117,45 @@ export async function InvokeMain(
104117
log(result);
105118
return result;
106119
}
120+
121+
export function ImageFromClipboard(): NativeImage | undefined {
122+
return window.clipboard?.readImage();
123+
}
124+
125+
export async function UploadImageForSong(
126+
songKey: SongKey,
127+
nativeImage: NativeImage,
128+
): Promise<void> {
129+
// Have to turn a nativeImage into something that can be cloned
130+
const buffer = nativeImage.toJPEG(90);
131+
await InvokeMain('upload-image', { songKey, nativeImage: buffer });
132+
}
133+
134+
export async function UploadImageForAlbum(
135+
albumKey: AlbumKey,
136+
nativeImage: NativeImage,
137+
): Promise<void> {
138+
// Have to turn a nativeImage into something that can be cloned
139+
const buffer = nativeImage.toJPEG(90);
140+
await InvokeMain('upload-image', { albumKey, nativeImage: buffer });
141+
}
142+
143+
export async function UploadFileForSong(
144+
songKey: SongKey,
145+
path: string,
146+
): Promise<void> {
147+
// Have to turn a nativeImage into something that can be cloned
148+
const buffer = await window.readFile(path);
149+
log(typeof buffer);
150+
await InvokeMain('upload-image', { songKey, nativeImage: buffer });
151+
}
152+
153+
export async function UploadFileForAlbum(
154+
albumKey: AlbumKey,
155+
path: string,
156+
): Promise<void> {
157+
// Have to turn a nativeImage into something that can be cloned
158+
const buffer = await window.readFile(path);
159+
log(typeof buffer);
160+
await InvokeMain('upload-image', { albumKey, nativeImage: buffer });
161+
}

src/Recoil/Local.ts

+19-2
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1-
import { SongKey } from '@freik/core-utils';
2-
import { atom, selector } from 'recoil';
1+
import { AlbumKey, SongKey } from '@freik/core-utils';
2+
import { atom, atomFamily, selector, selectorFamily } from 'recoil';
3+
import { RandomInt } from '../Tools';
34
import { repeatState } from './ReadWrite';
45

56
export type PlaylistName = string;
@@ -100,3 +101,19 @@ export const isMiniplayerState = atom<boolean>({
100101
key: 'isMiniplayer',
101102
default: false,
102103
});
104+
105+
/* This stuff is to make it so that pic URL's will refresh, when we update
106+
* the "picCacheAvoider" for a particularly album cover
107+
* It's efficacy is not guaranteed, but it's a best effort, i guess
108+
*/
109+
export const picCacheAvoiderState = atomFamily<number, AlbumKey>({
110+
key: 'picCacheAvoider',
111+
default: RandomInt(0xfffff),
112+
});
113+
114+
export const albumCoverUrlState = selectorFamily<string, AlbumKey>({
115+
key: 'albuCoverUrl',
116+
get: (key: AlbumKey) => ({ get }) => {
117+
return `pic://album/${key}#${get(picCacheAvoiderState(key))}`;
118+
},
119+
});

src/Tools.ts

+6
Original file line numberDiff line numberDiff line change
@@ -220,6 +220,12 @@ export function isPlaylist(playlist?: string): boolean {
220220
return Type.isString(playlist) && playlist.length > 0;
221221
}
222222

223+
export function RandomInt(max: number): number {
224+
const values = new Uint32Array(4);
225+
window.crypto.getRandomValues(values);
226+
return values[0] % max;
227+
}
228+
223229
export function ShuffleArray<T>(array: T[]): T[] {
224230
const values = new Uint32Array(array.length);
225231
window.crypto.getRandomValues(values);

src/UI/DetailPanel/MetadataEditor.tsx

+79-1
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,18 @@ import {
2121
} from '@freik/core-utils';
2222
import { useId } from '@uifabric/react-hooks';
2323
import { useEffect, useState } from 'react';
24+
import { CallbackInterface, useRecoilCallback, useRecoilValue } from 'recoil';
2425
import { SetMediaInfo } from '../../ipc';
26+
import {
27+
ImageFromClipboard,
28+
ShowOpenDialog,
29+
UploadFileForAlbum,
30+
UploadFileForSong,
31+
UploadImageForAlbum,
32+
UploadImageForSong,
33+
} from '../../MyWindow';
34+
import { albumCoverUrlState, picCacheAvoiderState } from '../../Recoil/Local';
35+
import { getAlbumKeyForSongKeyState } from '../../Recoil/ReadOnly';
2536
import { onRejected } from '../../Tools';
2637

2738
const log = MakeLogger('MetadataEditor', true);
@@ -148,6 +159,67 @@ export function MetadataEditor(props: MetadataProps): JSX.Element {
148159
}
149160
};
150161

162+
const uploadImage = async (
163+
cbInterface: CallbackInterface,
164+
uploadSong: (sk: SongKey) => Promise<void>,
165+
uploadAlbum: (ak: AlbumKey) => Promise<void>,
166+
) => {
167+
// Easy: one song:
168+
if (props.forSong !== undefined) {
169+
await uploadSong(props.forSong);
170+
const albumKey = await cbInterface.snapshot.getPromise(
171+
getAlbumKeyForSongKeyState(props.forSong),
172+
);
173+
setTimeout(
174+
() => cbInterface.set(picCacheAvoiderState(albumKey), (p) => p + 1),
175+
250,
176+
);
177+
} else {
178+
// Messy: Multiple songs
179+
const albumsSet: Set<AlbumKey> = new Set();
180+
for (const song of props.forSongs!) {
181+
const albumKey = await cbInterface.snapshot.getPromise(
182+
getAlbumKeyForSongKeyState(song),
183+
);
184+
if (albumsSet.has(albumKey)) {
185+
continue;
186+
}
187+
albumsSet.add(albumKey);
188+
await uploadAlbum(albumKey);
189+
// This bonks the URL so it will be reloaded after we've uploaded the image
190+
setTimeout(
191+
() => cbInterface.set(picCacheAvoiderState(albumKey), (p) => p + 1),
192+
250,
193+
);
194+
}
195+
}
196+
};
197+
198+
const onImageFromClipboard = useRecoilCallback((cbInterface) => async () => {
199+
const img = ImageFromClipboard();
200+
if (img !== undefined) {
201+
await uploadImage(
202+
cbInterface,
203+
async (sk: SongKey) => await UploadImageForSong(sk, img),
204+
async (ak: AlbumKey) => await UploadImageForAlbum(ak, img),
205+
);
206+
}
207+
});
208+
const onSelectFile = useRecoilCallback((cbInterface) => async () => {
209+
const selected = ShowOpenDialog({
210+
title: 'Select Cover Art image',
211+
properties: ['openFile'],
212+
filters: [{ name: 'Images', extensions: ['jpg', 'jpeg', 'png'] }],
213+
});
214+
if (selected !== undefined) {
215+
await uploadImage(
216+
cbInterface,
217+
async (sk: SongKey) => await UploadFileForSong(sk, selected[0]),
218+
async (ak: AlbumKey) => await UploadFileForAlbum(ak, selected[0]),
219+
);
220+
}
221+
});
222+
const coverUrl = useRecoilValue(albumCoverUrlState(props.albumId || '___'));
151223
// Nothing selected: EMPTY!
152224
if (!isSingle && !isMultiple) {
153225
return <Text>Not Single and not Multiple (This is a bug!)</Text>;
@@ -267,10 +339,16 @@ export function MetadataEditor(props: MetadataProps): JSX.Element {
267339
</Stack>
268340
<Image
269341
alt="Album Cover"
270-
src={`pic://album/${props.albumId ? props.albumId : '____'}`}
342+
src={coverUrl}
271343
imageFit={ImageFit.centerContain}
272344
height={350}
273345
/>
346+
<br />
347+
<Stack horizontal horizontalAlign="center">
348+
<DefaultButton text="Choose File..." onClick={onSelectFile} />
349+
&nbsp;
350+
<DefaultButton text="From Clipboard" onClick={onImageFromClipboard} />
351+
</Stack>
274352
</>
275353
);
276354
}

src/UI/SongPlaying.tsx

+7-7
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,11 @@ import { MakeLogger } from '@freik/core-utils';
33
import { SyntheticEvent } from 'react';
44
import { useRecoilCallback, useRecoilState, useRecoilValue } from 'recoil';
55
import { MaybePlayNext } from '../Recoil/api';
6-
import { currentSongKeyState, songListState } from '../Recoil/Local';
6+
import {
7+
albumCoverUrlState,
8+
currentSongKeyState,
9+
songListState,
10+
} from '../Recoil/Local';
711
import {
812
MediaTime,
913
mediaTimePercentState,
@@ -32,14 +36,10 @@ export function GetAudioElem(): HTMLMediaElement | void {
3236
function CoverArt(): JSX.Element {
3337
const songKey = useRecoilValue(currentSongKeyState);
3438
const albumKey = useRecoilValue(getAlbumKeyForSongKeyState(songKey));
35-
39+
const picurl = useRecoilValue(albumCoverUrlState(albumKey));
3640
return (
3741
<span id="song-cover-art">
38-
<img
39-
id="img-current-cover-art"
40-
src={`pic://album/${albumKey}`}
41-
alt="album cover"
42-
/>
42+
<img id="img-current-cover-art" src={picurl} alt="album cover" />
4343
</span>
4444
);
4545
}

src/UI/Views/Albums.tsx

+3-1
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import {
2020
useRecoilValue,
2121
} from 'recoil';
2222
import { AddSongs } from '../../Recoil/api';
23+
import { albumCoverUrlState } from '../../Recoil/Local';
2324
import {
2425
allAlbumsState,
2526
allArtistsState,
@@ -51,6 +52,7 @@ export function AlbumHeaderDisplay(props: { album: Album }): JSX.Element {
5152
const onRightClick = useRecoilCallback((cbInterface) =>
5253
SongListDetailContextMenuClick(cbInterface, props.album.songs),
5354
);
55+
const picurl = useRecoilValue(albumCoverUrlState(props.album.key));
5456
return (
5557
<Stack
5658
horizontal
@@ -60,7 +62,7 @@ export function AlbumHeaderDisplay(props: { album: Album }): JSX.Element {
6062
style={{ padding: '2px 0px', cursor: 'pointer' }}
6163
>
6264
<Image
63-
src={`pic://album/${props.album.key}`}
65+
src={picurl}
6466
height={50}
6567
width={50}
6668
imageFit={ImageFit.centerContain}

src/__mocks__/MyWindow.ts

+8
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,18 @@
11
// This is for getting at "global" stuff from the window object
22
import { MakeError, MakeLogger, Type } from '@freik/core-utils';
3+
import crypto from 'crypto';
34
import { OpenDialogSyncOptions } from 'electron/main';
45

56
const log = MakeLogger('MyWindow-mock');
67
const err = MakeError('MyWindow-mock-err');
78

9+
// This fixes window.crypto for the Jest environment
10+
Object.defineProperty(global, 'crypto', {
11+
value: {
12+
getRandomValues: (arr: any[]) => crypto.randomBytes(arr.length),
13+
},
14+
});
15+
816
export function ShowOpenDialog(
917
options: OpenDialogSyncOptions,
1018
): string[] | undefined {

static/main/Communication.ts

+30
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { FTON, FTONData, MakeError, MakeLogger } from '@freik/core-utils';
22
import { ipcMain, shell } from 'electron';
33
import { IpcMainInvokeEvent } from 'electron/main';
4+
import { isAlbumCoverData, SaveNativeImageForAlbum } from './cover-art';
45
import { FlushImageCache } from './ImageCache';
56
import { setMediaInfoForSong } from './metadata';
67
import {
@@ -24,6 +25,7 @@ const log = MakeLogger('Communication');
2425
const err = MakeError('Communication-err');
2526

2627
type Handler<T> = (arg?: string) => Promise<T | void>;
28+
type TypeHandler<T> = (arg: T) => Promise<string | void>;
2729

2830
/**
2931
* Read a value from persistence by name, returning it's unprocessed contents
@@ -122,6 +124,31 @@ export function register(key: string, handleIt: Handler<string>): void {
122124
},
123125
);
124126
}
127+
/**
128+
* Registers with `ipcMain.handle` a function that takes a mandatory parameter
129+
* and returns *string* data untouched. It also requires a checker to ensure the
130+
* data is properly typed
131+
* @param {string} key - The id to register a listener for
132+
* @param {TypeHandler<T>} handler - the function that handles the data
133+
* @param {(v:any)=>v is T} checker - a Type Check function for type T
134+
* @returns void
135+
*/
136+
export function registerKey<T>(
137+
key: string,
138+
handler: TypeHandler<T>,
139+
checker: (v: any) => v is T,
140+
): void {
141+
ipcMain.handle(
142+
key,
143+
async (event: IpcMainInvokeEvent, arg: any): Promise<string | void> => {
144+
if (checker(arg)) {
145+
return await handler(arg);
146+
} else {
147+
err(`Invalid argument type to ${key} handler`);
148+
}
149+
},
150+
);
151+
}
125152

126153
/**
127154
* Show a file in the shell
@@ -172,4 +199,7 @@ export function CommsSetup(): void {
172199
// because they don't just read/write to disk.
173200
register('read-from-storage', readFromStorage);
174201
register('write-to-storage', writeToStorage);
202+
203+
// Unflattened data handlers (everything should switch to these eventually
204+
registerKey('upload-image', SaveNativeImageForAlbum, isAlbumCoverData);
175205
}

0 commit comments

Comments
 (0)