diff --git a/scripts/streamdeck-check.mjs b/scripts/streamdeck-check.mjs index 4092a95..8fd9026 100644 --- a/scripts/streamdeck-check.mjs +++ b/scripts/streamdeck-check.mjs @@ -169,6 +169,14 @@ try { console.log(` ${JSON.stringify(stats.body)}`); ok('stats name the newest clip', stats.body?.latest?.title === 'newest.mp4', stats.body?.latest?.title); + // The discard key shows the clip it would act on before anybody presses. + const preview = await call('/v1/latest/preview'); + ok( + 'the discard key can show which clip it would throw away', + preview.body?.title === 'newest.mp4' && String(preview.body?.thumbnail ?? '').startsWith('data:image/jpeg'), + `${preview.body?.title}, allowed ${preview.body?.allowed}, discardable ${preview.body?.discardable}`, + ); + const discardOff = await call('/v1/latest/discard', { method: 'POST', body: { confirm: true } }); ok('discard is refused while it is switched off', discardOff.status === 403, String(discardOff.status)); diff --git a/src/main/services/streamdeck/actions.ts b/src/main/services/streamdeck/actions.ts index bf2a79f..d55b174 100644 --- a/src/main/services/streamdeck/actions.ts +++ b/src/main/services/streamdeck/actions.ts @@ -1,4 +1,6 @@ import fs from 'node:fs/promises'; +import { clipboard, nativeImage } from 'electron'; +import { videoService } from '../videoService.js'; import { AppDataSource } from '../../data-source.js'; import { Clip } from '../../entity/Clip.js'; import { GoodBit } from '../../entity/GoodBit.js'; @@ -141,12 +143,26 @@ export async function tagLatest(input: { tag?: unknown }): Promise { export async function publishLatest(): Promise { const found = await latestOrRefusal(); if (!(found instanceof Clip)) return found; - if (found.published) return ok({ id: found.id, title: title(found), already: true }); + if (found.published) { + // Already up: the link is what somebody pressing Publish wants, so it is copied now. + if (found.publishedUrl) clipboard.writeText(found.publishedUrl); + return ok({ id: found.id, title: title(found), already: true, url: found.publishedUrl ?? null }); + } if (!loadSettings().publisherBaseUrl) return refused(409, 'No publisher is set up'); - void new PublishClipAction().execute({ id: found.id }).catch((error: unknown) => { - console.error('[streamdeck] publish failed:', error instanceof Error ? error.message : error); - }); + /* + * The link goes on the clipboard once the upload lands, from here rather + * than from the plugin: a key has no clipboard, and the URL only exists at + * the end. The key asks `/v1/publish/status` meanwhile, so it can say when. + */ + void new PublishClipAction() + .execute({ id: found.id }) + .then(({ clip }) => { + if (clip.publishedUrl) clipboard.writeText(clip.publishedUrl); + }) + .catch((error: unknown) => { + console.error('[streamdeck] publish failed:', error instanceof Error ? error.message : error); + }); return { status: 202, body: { id: found.id, title: title(found), publishing: true } }; } @@ -168,17 +184,7 @@ export async function discardLatest(input: { confirm?: unknown }): Promise { const status = result.reason === 'nothing-landed' ? 504 : result.reason === 'helper' ? 500 : 409; return refused(status, result.message, { reason: result.reason }); } + +/** Whether a key may throw this clip away. The one rule, used by the press and by the preview. */ +async function discardVerdict(clip: Clip): Promise> { + const markCount = await AppDataSource.getRepository(GoodBit).count({ where: { clipId: clip.id } }); + return discardableFromAKey({ + displayName: clip.displayName, + notes: clip.notes, + starred: clip.starred, + published: clip.published, + tagCount: clip.tags?.length ?? 0, + markCount, + }); +} + +/** + * The clip a discard would act on, before anybody presses anything. + * + * "Kept" after a five second hold was the first time somebody learned the + * newest clip was protected, which is the wrong moment. So the key shows the + * clip itself, its own thumbnail, and whether it would be kept, and the press + * only confirms what is already on the key. The picture is 144 pixels tall, + * which is the key's own height at 2x; the cached thumbnail is 1280 wide. + */ +export async function latestPreview(): Promise { + if (await stillSaving()) return ok({ saving: true }); + const clip = await latestClip(); + if (!clip) return ok({ none: true }); + + let thumbnail: string | null = null; + try { + const file = await videoService.ensureThumbnail(clip); + const image = nativeImage.createFromPath(file); + if (!image.isEmpty()) { + thumbnail = `data:image/jpeg;base64,${image.resize({ height: 144 }).toJPEG(80).toString('base64')}`; + } + } catch { + // No picture is a key with a plain face, not a failed key. + } + + const verdict = await discardVerdict(clip); + return ok({ + id: clip.id, + title: title(clip), + thumbnail, + allowed: loadSettings().streamDeckAllowDiscard === true, + discardable: verdict.ok, + reason: verdict.ok ? null : verdict.reason, + }); +} + +/** Whether a publish started from a key has landed, and its link. */ +export async function publishStatus(input: { id?: unknown }): Promise { + const id = Number(input.id); + if (!Number.isInteger(id)) return refused(400, 'Which clip?'); + const clip = await AppDataSource.getRepository(Clip).findOneBy({ id }); + if (!clip) return refused(404, 'That clip is gone'); + return ok({ published: clip.published === true, url: clip.published ? clip.publishedUrl ?? null : null }); +} diff --git a/src/main/services/streamdeck/server.ts b/src/main/services/streamdeck/server.ts index 9753191..06f1bc2 100644 --- a/src/main/services/streamdeck/server.ts +++ b/src/main/services/streamdeck/server.ts @@ -23,7 +23,9 @@ import { loadSettings, saveSettings } from '../../settings.js'; import { discardLatest, health, + latestPreview, publishLatest, + publishStatus, saveReplayFromKey, stats, tagLatest, @@ -53,6 +55,8 @@ const ROUTES: Record = { 'POST /v1/latest/publish': () => publishLatest(), 'POST /v1/latest/discard': (body) => discardLatest(body), 'POST /v1/replay/save': () => saveReplayFromKey(), + 'GET /v1/latest/preview': () => latestPreview(), + 'POST /v1/publish/status': (body) => publishStatus(body), }; async function readBody(req: IncomingMessage): Promise> { diff --git a/src/renderer/src/components/Settings/StreamDeckCard.vue b/src/renderer/src/components/Settings/StreamDeckCard.vue index e9cb574..1e0f883 100644 --- a/src/renderer/src/components/Settings/StreamDeckCard.vue +++ b/src/renderer/src/components/Settings/StreamDeckCard.vue @@ -70,7 +70,7 @@ async function installPlugin(): Promise { toast.error(result.error); return; } - toast.success('The Stream Deck app asks to install it. Say yes and the keys connect by themselves.'); + toast.success('Confirm the install in the Stream Deck app.'); for (let i = 0; i < 60 && !state.value?.pluginInstalled; i++) { await new Promise((resolve) => setTimeout(resolve, 2000)); await load(); @@ -144,14 +144,18 @@ async function toggleDiscard(allow: boolean): Promise { -
- - - {{ state.running ? 'Listening, on this computer only' : 'Not listening' }} - - - {{ state.pipe }} - + +
+ + Not listening. Another GoodBit may already be connected to the Stream Deck.
@@ -92,4 +93,17 @@ const markSvg = `(); + private readonly holds = new Map>(); + private readonly polls = new Map>(); + private readonly shown = new Map(); + + override async onWillAppear(ev: WillAppearEvent): Promise { + const key = ev.action; + if (!key.isKey()) return; + await this.refresh(key); + this.polls.set(key.id, setInterval(() => void this.refresh(key), POLL_MS)); + } + + override onWillDisappear(ev: WillDisappearEvent): void { + clearInterval(this.polls.get(ev.action.id)); + this.polls.delete(ev.action.id); + this.shown.delete(ev.action.id); + } override async onKeyDown(ev: KeyDownEvent): Promise { - this.pressedAt.set(ev.action.id, Date.now()); + const key = ev.action; + await key.setTitle('Hold'); + this.holds.set( + key.id, + setTimeout(() => { + this.holds.delete(key.id); + void this.discard(key); + }, HOLD_MS), + ); } override async onKeyUp(ev: KeyUpEvent): Promise { - const started = this.pressedAt.get(ev.action.id); - this.pressedAt.delete(ev.action.id); - - if (!started || Date.now() - started < HOLD_MS) { - await ev.action.setTitle('Hold'); - setTimeout(() => void ev.action.setTitle(''), 1500); - return; - } + const timer = this.holds.get(ev.action.id); + if (!timer) return; // It already fired. + clearTimeout(timer); + this.holds.delete(ev.action.id); + await ev.action.setTitle('Hold'); + setTimeout(() => void ev.action.setTitle(''), 1500); + } - // `confirm` is the long press, said to the server in words. GoodBit refuses - // a discard without it, so a plugin that forgot the hold still could not - // delete anything. + private async discard(key: Key): Promise { + await key.setTitle(''); + // `confirm` is the long press, said to the server in words. const reply = await goodbit('/v1/latest/discard', { method: 'POST', body: { confirm: true } }); if (succeeded(reply)) { - await ev.action.showOk(); - return; + await key.showOk(); + } else { + await key.setTitle(typeof reply.body.reason === 'string' ? 'Kept' : reply.status === 403 ? 'Off' : 'No'); + await key.showAlert(); + setTimeout(() => void key.setTitle(''), 2000); } + this.shown.delete(key.id); + await this.refresh(key); + } - const reason = typeof reply.body.reason === 'string' ? reply.body.reason : null; - await ev.action.setTitle(reason ? 'Kept' : 'No'); - await ev.action.showAlert(); - setTimeout(() => void ev.action.setTitle(''), 2000); + /** Put the newest clip on the key, redrawing only when something changed. */ + private async refresh(key: Key): Promise { + const reply = await goodbit('/v1/latest/preview'); + const preview = (succeeded(reply) ? reply.body : {}) as Preview; + const image = face(preview); + if (this.shown.get(key.id) === image) return; + this.shown.set(key.id, image); + await key.setImage(image || undefined); } } + +/** + * The key's face as an SVG data URL, or '' for the plain icon from the manifest. + * + * 144 square. The thumbnail fills it, darkened so the mark reads over any + * frame, and the mark sits in the middle: trash in the danger colour, or a + * lock in the light one for a clip that would be kept. + */ +function face(preview: Preview): string { + if (!preview.thumbnail) return ''; + const kept = preview.allowed === false || preview.discardable === false; + const glyph = kept ? GLYPHS.lock : GLYPHS.trash; + const colour = kept ? '#f2efea' : '#d45757'; + const svg = ` + + + + ${glyph} + `; + return `data:image/svg+xml;base64,${Buffer.from(svg).toString('base64')}`; +} diff --git a/streamdeck-plugin/src/actions/publish-latest.ts b/streamdeck-plugin/src/actions/publish-latest.ts index 71a9c48..fb130ab 100644 --- a/streamdeck-plugin/src/actions/publish-latest.ts +++ b/streamdeck-plugin/src/actions/publish-latest.ts @@ -1,21 +1,62 @@ import { action, type KeyDownEvent, SingletonAction } from '@elgato/streamdeck'; import { goodbit, succeeded } from '../goodbit.js'; +/** How often to ask whether the upload has landed, and for how long. */ +const POLL_MS = 2000; +const GIVE_UP_MS = 10 * 60_000; + /** - * Publish the clip you just saved. + * Publish the clip you just saved, and put its link on the clipboard. * * GoodBit answers at once and uploads behind the answer, because an upload is - * minutes of a home uplink and a key cannot wait that long. A tick here means - * "started", not "done"; GoodBit's own publish card says when it is up. + * minutes of a home uplink and a key cannot wait that long. So the key says + * "Uploading" and asks every two seconds whether it has landed; GoodBit copies + * the link the moment it has, and the key says so. A clip already published + * has its link copied straight away. */ @action({ UUID: 'io.github.darrellvs.goodbit.publish-latest' }) export class PublishLatest extends SingletonAction { override async onKeyDown(ev: KeyDownEvent): Promise { + const key = ev.action; const reply = await goodbit('/v1/latest/publish', { method: 'POST' }); - if (succeeded(reply)) { - await ev.action.showOk(); + + if (!succeeded(reply)) { + await key.setTitle(reply.status === 409 && reply.body.saving ? 'Saving\nfirst' : ''); + await key.showAlert(); + setTimeout(() => void key.setTitle(''), 3000); + return; + } + + if (reply.body.already) { + await done(key, Boolean(reply.body.url)); return; } - await ev.action.showAlert(); + + await key.setTitle('Uploading'); + const id = reply.body.id; + const deadline = Date.now() + GIVE_UP_MS; + const tick = async (): Promise => { + const status = await goodbit('/v1/publish/status', { method: 'POST', body: { id } }); + if (succeeded(status) && status.body.published) { + await done(key, Boolean(status.body.url)); + return; + } + if (Date.now() > deadline) { + await key.setTitle(''); + await key.showAlert(); + return; + } + setTimeout(() => void tick(), POLL_MS); + }; + setTimeout(() => void tick(), POLL_MS); } } + +async function done( + key: { setTitle(title?: string): Promise; showOk(): Promise }, + copied: boolean, +): Promise { + await key.setTitle(copied ? 'Link\ncopied' : 'Published'); + await key.showOk(); + setTimeout(() => void key.setTitle(''), 3000); +} diff --git a/streamdeck-plugin/src/glyphs.ts b/streamdeck-plugin/src/glyphs.ts new file mode 100644 index 0000000..1b24678 --- /dev/null +++ b/streamdeck-plugin/src/glyphs.ts @@ -0,0 +1,5 @@ +// Generated by scripts/render-icons.mjs from Phosphor (MIT). Do not edit. +export const GLYPHS = { + "trash": "", + "lock": "" +} as const;