From 65f20652fc5ae7f264c003965a3c7003afadc2de Mon Sep 17 00:00:00 2001
From: Adam Laycock
Date: Mon, 23 Mar 2026 20:23:03 +0000
Subject: [PATCH 1/3] feat: re-implement action to broadcast using sequence
builder
---
app/lib/sequence-builder.tsx | 46 +++++++++++++++++++
app/locales/en.ts | 5 +-
app/routes/actions.$action._index.tsx | 19 ++++----
app/routes/actions.$action.edit.tsx | 32 +++++--------
app/routes/actions.add.tsx | 18 +-------
app/routes/hook.$hook.tsx | 2 +-
.../migration.sql | 17 +++++++
prisma/schema.prisma | 1 +
8 files changed, 92 insertions(+), 48 deletions(-)
create mode 100644 prisma/migrations/20260323193952_add_data_to_action/migration.sql
diff --git a/app/lib/sequence-builder.tsx b/app/lib/sequence-builder.tsx
index bdd8f92..67dbc7b 100644
--- a/app/lib/sequence-builder.tsx
+++ b/app/lib/sequence-builder.tsx
@@ -130,3 +130,49 @@ export const SequenceBuilder = ({
)
}
+
+export const SequenceViewer = ({
+ queue,
+ sounds,
+ label
+}: {
+ queue: string[]
+ sounds: Audio[]
+ label: string
+}) => {
+ const {t} = useTranslation()
+
+ let duration = 0
+
+ return (
+
+
{label}
+
+ {queue.map((queuedId, i) => {
+ const sound = sounds.filter(({id}) => {
+ return id === queuedId
+ })[0]
+
+ duration += sound.duration
+
+ return (
+
+
{sound.name}
+
+ {getSecondsAsTime(sound.duration)}
+
+
+ )
+ })}
+
+ {t('broadcast.builder.totalDuration', {
+ duration: getSecondsAsTime(duration)
+ })}
+
+
+
+ )
+}
diff --git a/app/locales/en.ts b/app/locales/en.ts
index cbca71d..ba606d3 100644
--- a/app/locales/en.ts
+++ b/app/locales/en.ts
@@ -53,7 +53,9 @@ export const en = {
'An emoji to use as the action icon. Note that emoji render differently on the RPi screen.',
'actions.form.type.label': 'Type',
'actions.form.type.helper':
- 'Broadcast runs a broadcast to the supplied zone. Lockdown toggles a system wide lockdown.',
+ 'Broadcast runs a broadcast to the supplied zone, you can build the broadcaast once the action has been created. Lockdown toggles a system wide lockdown.',
+ 'actions.form.sequence.label': 'Sequence',
+ 'actions.form.sequence.helper': 'Build your broadcast sequence.',
'actions.form.sound.label': 'Sound',
'actions.form.sound.helper': 'Which sound should be used when broadcasting?',
'button.cancel': 'Cancel',
@@ -66,6 +68,7 @@ export const en = {
'actions.detail.sound': 'Sound:',
'actions.edit.metaTitle': 'Edit {{name}}',
'actions.edit.pageTitle': 'Edit {{name}}',
+ 'actions.detail.sequence.label': 'Sequence',
'backup.pageTitle': 'Backups',
'backup.create': 'Create Backup',
'broadcast.pageTitle': 'Broadcast',
diff --git a/app/routes/actions.$action._index.tsx b/app/routes/actions.$action._index.tsx
index 33c25f6..c267f76 100644
--- a/app/routes/actions.$action._index.tsx
+++ b/app/routes/actions.$action._index.tsx
@@ -12,6 +12,7 @@ import {Page, Actions} from '~/lib/ui'
import {useTranslation} from '~/lib/i18n'
import {translate} from '~/lib/i18n.shared'
import {getRootI18n} from '~/lib/i18n.meta'
+import {SequenceViewer} from '~/lib/sequence-builder'
export const meta: MetaFunction = ({matches}) => {
const {messages} = getRootI18n(matches)
@@ -28,15 +29,16 @@ export const loader = async ({request, params}: LoaderFunctionArgs) => {
const prisma = getPrisma()
const action = await prisma.action.findFirstOrThrow({
- where: {id: params.action},
- include: {audio: true}
+ where: {id: params.action}
})
- return {action}
+ const sounds = await prisma.audio.findMany({orderBy: {name: 'asc'}})
+
+ return {action, sounds}
}
const Action = () => {
- const {action} = useLoaderData()
+ const {action, sounds} = useLoaderData()
const navigate = useNavigate()
const {t} = useTranslation()
const typeLabels: Record = {
@@ -54,11 +56,12 @@ const Action = () => {
{t('actions.detail.type')}:{' '}
{typeLabels[action.action] ?? action.action}
-
- {t('actions.detail.sound')}{' '}
- {action.audio!.name}
-
+
= ({matches, data}) => {
const {messages} = getRootI18n(matches)
@@ -61,16 +62,16 @@ export const action = async ({params, request}: ActionFunctionArgs) => {
const name = formData.get('name') as string | undefined
const icon = formData.get('icon') as string | undefined
const action = formData.get('action') as string | undefined
- const sound = formData.get('sound') as string | undefined
+ const data = formData.get('data') as string | undefined
invariant(name)
invariant(icon)
invariant(action)
- invariant(sound)
+ invariant(data)
await prisma.action.update({
where: {id: params.action},
- data: {name, icon, action, audioId: sound}
+ data: {name, icon, action, data}
})
return redirect(`/actions/${params.action}`)
@@ -117,24 +118,13 @@ const AddAction = () => {
-
-
-
+
{
const name = formData.get('name') as string | undefined
const icon = formData.get('icon') as string | undefined
const action = formData.get('action') as string | undefined
- const sound = formData.get('sound') as string | undefined
invariant(name)
invariant(icon)
invariant(action)
- invariant(sound)
const newAction = await prisma.action.create({
- data: {name, icon, action, audioId: sound}
+ data: {name, icon, action}
})
void trigger(`New Action: ${name}`, 'newAction')
@@ -101,20 +99,6 @@ const AddAction = () => {
-
-
-
{
}
if (webhook.action.audioId) {
- await broadcast(zone, webhook.action.audioId)
+ await broadcast(zone, webhook.action.data)
}
break
case 'lockdown':
diff --git a/prisma/migrations/20260323193952_add_data_to_action/migration.sql b/prisma/migrations/20260323193952_add_data_to_action/migration.sql
new file mode 100644
index 0000000..6568213
--- /dev/null
+++ b/prisma/migrations/20260323193952_add_data_to_action/migration.sql
@@ -0,0 +1,17 @@
+-- RedefineTables
+PRAGMA defer_foreign_keys=ON;
+PRAGMA foreign_keys=OFF;
+CREATE TABLE "new_Action" (
+ "id" TEXT NOT NULL PRIMARY KEY,
+ "name" TEXT NOT NULL,
+ "icon" TEXT NOT NULL,
+ "action" TEXT NOT NULL,
+ "data" TEXT NOT NULL DEFAULT '',
+ "audioId" TEXT,
+ CONSTRAINT "Action_audioId_fkey" FOREIGN KEY ("audioId") REFERENCES "Audio" ("id") ON DELETE SET NULL ON UPDATE CASCADE
+);
+INSERT INTO "new_Action" ("action", "audioId", "icon", "id", "name") SELECT "action", "audioId", "icon", "id", "name" FROM "Action";
+DROP TABLE "Action";
+ALTER TABLE "new_Action" RENAME TO "Action";
+PRAGMA foreign_keys=ON;
+PRAGMA defer_foreign_keys=OFF;
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index fcfdc5a..b992724 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -106,6 +106,7 @@ model Action {
name String
icon String
action String
+ data String @default("")
audio Audio? @relation(fields: [audioId], references: [id])
audioId String?
From 14ad621d45e542bb74c560d5c8e6a6dc98a438ac Mon Sep 17 00:00:00 2001
From: AML - A Laycock
Date: Tue, 24 Mar 2026 09:03:56 +0000
Subject: [PATCH 2/3] feat: use db seed to take the single audio id and convert
it to a sequence
---
prisma/seed.js | 13 +++++++++++++
1 file changed, 13 insertions(+)
diff --git a/prisma/seed.js b/prisma/seed.js
index c44dba2..d20836d 100644
--- a/prisma/seed.js
+++ b/prisma/seed.js
@@ -74,6 +74,19 @@ const main = async () => {
await Promise.all(promises)
}
+
+ const actionsWithAudioIdAndNoData = await prisma.action.findMany({
+ where: {audioId: {not: ''}, data: ''}
+ })
+
+ await Promise.all(
+ actionsWithAudioIdAndNoData.map(action => {
+ return prisma.action.update({
+ where: {id: action.id},
+ data: {data: `["${action.audioId}"]`}
+ })
+ })
+ )
}
main()
From 86ea347d9195e434abae8ea405cd5b2f6823c31c Mon Sep 17 00:00:00 2001
From: AML - A Laycock
Date: Tue, 24 Mar 2026 10:58:52 +0000
Subject: [PATCH 3/3] docs: note that relation can be removed in future version
---
prisma/schema.prisma | 1 +
1 file changed, 1 insertion(+)
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index b992724..14b37e0 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -108,6 +108,7 @@ model Action {
action String
data String @default("")
+ // BREAKING CHANGE When we make the jump to 2.x this can be removed. Not Worth a full V2 bump just to take it out.
audio Audio? @relation(fields: [audioId], references: [id])
audioId String?