Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions scripts/streamdeck-check.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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));

Expand Down
94 changes: 79 additions & 15 deletions src/main/services/streamdeck/actions.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -141,12 +143,26 @@ export async function tagLatest(input: { tag?: unknown }): Promise<KeyResult> {
export async function publishLatest(): Promise<KeyResult> {
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 } };
}

Expand All @@ -168,17 +184,7 @@ export async function discardLatest(input: { confirm?: unknown }): Promise<KeyRe
const found = await latestOrRefusal();
if (!(found instanceof Clip)) return found;

const markCount = await AppDataSource.getRepository(GoodBit).count({
where: { clipId: found.id },
});
const verdict = discardableFromAKey({
displayName: found.displayName,
notes: found.notes,
starred: found.starred,
published: found.published,
tagCount: found.tags?.length ?? 0,
markCount,
});
const verdict = await discardVerdict(found);
if (!verdict.ok) {
return refused(409, `Kept: that clip is ${verdict.reason}`, { reason: verdict.reason });
}
Expand All @@ -203,3 +209,61 @@ export async function saveReplayFromKey(): Promise<KeyResult> {
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<ReturnType<typeof discardableFromAKey>> {
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<KeyResult> {
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<KeyResult> {
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 });
}
4 changes: 4 additions & 0 deletions src/main/services/streamdeck/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,9 @@ import { loadSettings, saveSettings } from '../../settings.js';
import {
discardLatest,
health,
latestPreview,
publishLatest,
publishStatus,
saveReplayFromKey,
stats,
tagLatest,
Expand Down Expand Up @@ -53,6 +55,8 @@ const ROUTES: Record<string, Handler> = {
'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<Record<string, unknown>> {
Expand Down
22 changes: 13 additions & 9 deletions src/renderer/src/components/Settings/StreamDeckCard.vue
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ async function installPlugin(): Promise<void> {
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();
Expand Down Expand Up @@ -144,14 +144,18 @@ async function toggleDiscard(allow: boolean): Promise<void> {
</BaseButton>
</div>

<div class="flex items-center gap-2 py-3 border-b border-border text-sm">
<span :class="statusDotVariants({ tone: state.running ? 'ok' : 'waiting' })" />
<span class="text-foreground">
{{ state.running ? 'Listening, on this computer only' : 'Not listening' }}
</span>
<code class="ml-auto font-mono text-xs text-muted-400 truncate" :title="state.pipe">
{{ state.pipe }}
</code>
<!--
Only when something is wrong. A pipe name is nothing anybody can act on,
and "Listening" is what the switch above already says; what is worth a
row is the one case the switch is on and nothing is: another GoodBit
on this profile already holds the connection.
-->
<div
v-if="!state.running"
class="flex items-center gap-2 py-3 border-b border-border text-sm"
>
<span :class="statusDotVariants({ tone: 'blocker' })" />
<span class="text-foreground">Not listening. Another GoodBit may already be connected to the Stream Deck.</span>
</div>

<SettingToggle
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
Expand Up @@ -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/",
Expand Down Expand Up @@ -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"
Expand All @@ -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"
Expand All @@ -97,8 +97,8 @@
"States": [
{
"Image": "imgs/keys/stats",
"TitleAlignment": "middle",
"FontSize": 12
"TitleAlignment": "bottom",
"FontSize": 11
}
]
}
Expand Down
20 changes: 17 additions & 3 deletions streamdeck-plugin/scripts/render-icons.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256">
<rect width="256" height="256" fill="${GROUND}"/>
Expand Down Expand Up @@ -92,4 +93,17 @@ const markSvg = `<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www
png(markSvg, 256, 'plugin.png');
png(markSvg, 512, 'plugin@2x.png');

/*
* The glyphs a key draws at run time, over a clip's own thumbnail: the discard
* key shows what it would throw away, with a trash mark, or a lock when the
* clip would be kept. Written as path data so the plugin needs no SVG files.
*/
const glyphs = Object.fromEntries(['trash', 'lock'].map((name) => [name, glyph(name)]));
writeFileSync(
join(root, 'src', 'glyphs.ts'),
`// Generated by scripts/render-icons.mjs from Phosphor (MIT). Do not edit.
export const GLYPHS = ${JSON.stringify(glyphs, null, 2)} as const;
`,
);

console.log('rendered into', imgs);
Loading
Loading