From 5205c9db0dabc56d524bf5a1d2cf9cfe1935987f Mon Sep 17 00:00:00 2001 From: Darrell van Swinderen Date: Wed, 23 Sep 2026 11:14:11 +0200 Subject: [PATCH] feat(streamdeck): the discard key shows its clip, publish copies the link Feedback from the first real Stream Deck test. **The discard key shows which clip it would throw away.** Its face is the newest clip's own thumbnail, dimmed, with a trash mark when a hold would discard it and a lock when GoodBit would keep it (a name, tags, notes, marks, a star or a public link, or discarding switched off). "Kept" after a five second hold was the first sign of any of that; in the case that prompted this, the newest clip was published. New route `GET /v1/latest/preview`, refreshed every five seconds, and the key only redraws when something changed. **The hold fires by itself at 1.2 s**, while the key is still down, and the key says "Hold" from the moment it is pressed, so nobody has to guess how long is long enough. **Publish copies the link.** GoodBit writes it to the clipboard the moment the upload lands (the key has no clipboard, and the URL only exists at the end); the key says "Uploading", asks `POST /v1/publish/status` every two seconds, then "Link copied". An already published clip has its link copied at once. **The stats key's count no longer sits on its icon**: the glyph is smaller and higher, and the title is bottom-aligned. **Settings stop showing the pipe name**, which nobody can act on. A row appears only when the switch is on and nothing is listening. The install toast is one line. Gates: `npm run check` 860; `streamdeck-check.mjs` exit 0, including the preview naming the newest clip with a thumbnail; `streamdeck validate` successful; the key faces rendered to PNG through resvg and looked at. Plugin 0.4.0: reinstall from Settings to get it. Co-Authored-By: Claude Opus 5.5 (1M context) --- scripts/streamdeck-check.mjs | 8 + src/main/services/streamdeck/actions.ts | 94 +++++++++-- src/main/services/streamdeck/server.ts | 4 + .../components/Settings/StreamDeckCard.vue | 22 +-- .../imgs/keys/stats.png | Bin 631 -> 436 bytes .../imgs/keys/stats@2x.png | Bin 1087 -> 746 bytes .../manifest.json | 10 +- streamdeck-plugin/scripts/render-icons.mjs | 20 ++- .../src/actions/discard-latest.ts | 149 ++++++++++++++---- .../src/actions/publish-latest.ts | 53 ++++++- streamdeck-plugin/src/glyphs.ts | 5 + 11 files changed, 292 insertions(+), 73 deletions(-) create mode 100644 streamdeck-plugin/src/glyphs.ts 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.
@dKm^g^rTz@j_Cag7TMj=Bq8#fhmBX5C&{EE@i+5t=3i5%o~+neUePxF zJE#2phf97J9iBO3-mE`#vu#9$k9l(+Q~;uRklNdEK_&Z5X_3=f1AUKQ`9p^2YreS*$E=yJuedcro$J zqvxmCr9z)FfBoO#_}9MWowj_&4fejO|KdC>Ab3n@#ofBjpV5mq%1nL_lna^4Z}P)P zuWDV*f`8V_|F6@mT9>on`SRl-^JjLh-Q%}?c39YTuxqOJ&f8{Yo}~T$+dI2btGQs; h*c%BL&jr>g(} literal 631 zcmeAS@N?(olHy`uVBq!ia0vp^9w5xY1|&n@Zgyv2V6yXcaSW-L^Y+F?EtWu;;~)34 zZ+KYt+)3|<9m8*)pd)b#a*i<`UNI9U#Y}Klh|vlB#B-WiXXk|Oo8l4t2Y$|-<=ayy z{%p>szjj~mr=>@m#-}fv`Bw79``-pXr!on0xo8M1bZ9|h1Tu#nKYr)+eN#EPyV){< zOjEs7ckkc5QN{Dl?)~1Euc)alUwb!icX^^KXUOWaHy$3`cU$Y+tzT_dUtiDDUZc?% z7C!Cofd>~hRGaSJWAShJPDb1yFb z&g!4<6P&u`G^{VYUF*9##O<%viul;yxo?{1-nGp(o4>})`f<4YwvGG(Vgl^KQC}Qf z9c6qnxLjQLr^GhzJH4Cj>C0c)6PTVl$nar%1(_dz@2&-g(UB)1>QNqnOpO0NTP*Er2-b`4TmdKI;Vst0B-RIng9R* diff --git a/streamdeck-plugin/io.github.darrellvs.goodbit.sdPlugin/imgs/keys/stats@2x.png b/streamdeck-plugin/io.github.darrellvs.goodbit.sdPlugin/imgs/keys/stats@2x.png index 7759ae1b60a59679ebe0a1a0f01129c39ddf5a5f..6f0de4581eac9f5e20c33d4190d4e41f39e38397 100644 GIT binary patch literal 746 zcmeAS@N?(olHy`uVBq!ia0vp^6F``Q4M;wBd$farfoY?si(^Q|oVT|OyF?r%+8%ym zuwL;}s-yA2JV$0n+h&eMc?%ReqD&M#!6tU!GgIKrg_Uh+h`?b@5ek$D_wf0x+ z>{+}S>Cc_Mzk55|UT~_(k>bC{&gSL6&;EaHUK(@N$3KzfZ(mKV3%~c9Uw-%Q@9)0v z{$?V5q5u8;UtfP%RKKg)zb!qPsp`*@%>8neUp_eQmy7(qvq8(4FXIoR_x$Zn4(-qE z@{b;437OTPrM#e$BSVd8RZqjSjfRYWU$g7j#&a|JYLG&9_i|?Z*?;bS&;RG1&sjre z7CJ2DTw%mCtGyvjX~C%;W~uv$$I7Ra&gWiX^tj=e{Dv=;+AJj*?>>j+_<&W$+tt51 zmy`c}_oupd@5{yYW8am(%`{u^P{`Wm_`!mMa`VJYr7t9KWR!m|+kWHLtNYKkTUorA ub~^m*G2;v?g=_vqhBUD;GKkeZF`wPA^njp+(+6N0X7F_Nb6Mw<&;$UZZw%%D literal 1087 zcmeAS@N?(olHy`uVBq!ia0vp^6F``Q4M;wBd$farfrZV}#WAE}&fD9z*&>cI$3K2# zeG+gxMo}#36O#|uMuDvX0X>?FGFB^eh;==1?)uTduPNeqA?%_^V4w$=DECH>8ph6u zj-yX`W8xal)jO8W$uWG^K7HrI-;KP*XX7^Be{-f__v~$XH~*YweC^^eK|nx-g@u#R z(TN0RP>RFbvfI4;Z%YmzcK&zySaeiWnfK}YG0QK1oPR1P#^Leff-SqaGD=F0oZVCL%>Cyqi^5a7Iy%1sCA+v@yqtXe-GP6#byL=Vm$%z> zIsF_&`1EuCtH0FKxAEQDo4#It>cSNbb^m_6`+qIxp;hVQY4TT(_4odKyh8qJP88?O zN7bKhiQ2Ea`Rl0I;e{2u=G1;YCSSk4P;GtuTCpz+b8nab*q^FlwV?Ir`n*R!JndJ- zTGX$za{9=c8|59D^6{4HzkTfd@}ce-&z=^WTWvbspY`$kS7WA6kGQ=YUUu=F*mmyY zmdknkzZSoB-)hklwmQ@|S<$+Gu71kG)|}eQFW3ACJbNzrIk#ZIvPTU?t`0Mm7Ti-_ z!0P~JXj?5<78}78EwlX1p`Z=iua#M@RI^-ZXt*R8z|7?3?!e2TASjnlShHx$o=W*p%%PU`*BG(+FpoqRI5imgyD)n6Y;e^Q$#$)>KZt zQ5NxWokpF&y?HbH;{R?8FMBiddBC57eQ$p~nmcQ6)?GQ7FN>u>CY;dA*weecJI`HX z-vY77Q)YJm?6@!X3Eci+ClnvR^y@#+^UGvrS2>j#>P`r#xtM!u<4X0E;Dx@O3oTr@ z&n)7-8Ilp4)X`vMYX3d&Z1$0z8?UeBm$Q1~o~og>;N|b~)&GCYxhebc(bCg(A#ID8 zHmyvmd$%^5m*zYSTHbny85}Sb4q9e026qy AegFUf diff --git a/streamdeck-plugin/io.github.darrellvs.goodbit.sdPlugin/manifest.json b/streamdeck-plugin/io.github.darrellvs.goodbit.sdPlugin/manifest.json index 509bcdf..fddacf8 100644 --- a/streamdeck-plugin/io.github.darrellvs.goodbit.sdPlugin/manifest.json +++ b/streamdeck-plugin/io.github.darrellvs.goodbit.sdPlugin/manifest.json @@ -2,7 +2,7 @@ "$schema": "https://schemas.elgato.com/streamdeck/plugins/manifest.json", "UUID": "io.github.darrellvs.goodbit", "Name": "GoodBit", - "Version": "0.3.0.0", + "Version": "0.4.0.0", "Author": "GoodBit", "Description": "Save the replay, then tag, publish or discard the clip you just saved, and see today's count, without leaving the game. Needs GoodBit running with the Stream Deck connection switched on in Settings, Connections.", "URL": "https://darrellvs.github.io/goodbit/", @@ -59,7 +59,7 @@ { "UUID": "io.github.darrellvs.goodbit.publish-latest", "Name": "Publish the last clip", - "Tooltip": "Uploads the clip you just saved to your publisher.", + "Tooltip": "Uploads the clip you just saved to your publisher, and copies its link once it is up.", "Icon": "imgs/actions/publish", "Controllers": [ "Keypad" @@ -74,7 +74,7 @@ { "UUID": "io.github.darrellvs.goodbit.discard-latest", "Name": "Discard the last clip (hold)", - "Tooltip": "Hold to send the clip you just saved to the Recycle Bin. Off in GoodBit until you allow it, and it keeps any clip you have tagged, named, starred, marked, noted or published.", + "Tooltip": "Shows the clip you just saved. Hold for a second to send it to the Recycle Bin. A lock means GoodBit keeps it: it has a name, tags, notes, marks, a star or a public link, or discarding is off in GoodBit.", "Icon": "imgs/actions/discard", "Controllers": [ "Keypad" @@ -97,8 +97,8 @@ "States": [ { "Image": "imgs/keys/stats", - "TitleAlignment": "middle", - "FontSize": 12 + "TitleAlignment": "bottom", + "FontSize": 11 } ] } diff --git a/streamdeck-plugin/scripts/render-icons.mjs b/streamdeck-plugin/scripts/render-icons.mjs index 3a8bfec..a6e7168 100644 --- a/streamdeck-plugin/scripts/render-icons.mjs +++ b/streamdeck-plugin/scripts/render-icons.mjs @@ -57,10 +57,11 @@ function png(svg, size, out) { } function keySvg({ icon, colour, small }) { - // On a 256 canvas: a 144 glyph centred, or an 88 one near the top for stats. - const box = small ? 88 : 144; + // On a 256 canvas: a 144 glyph centred, or for stats a 76 one near the top, + // leaving the lower half for the two lines of count the key writes there. + const box = small ? 76 : 144; const x = (256 - box) / 2; - const y = small ? 26 : x; + const y = small ? 22 : x; const scale = box / 256; return ` @@ -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;