From bdf63f2f8c5493a531733fade31e2796225b8d71 Mon Sep 17 00:00:00 2001 From: AML - A Laycock Date: Tue, 21 Oct 2025 12:57:01 +0100 Subject: [PATCH 1/4] feat: audio sequences on schedule, see #2 --- app/lib/sequence-builder.tsx | 102 ++++++++++++++++++ app/locales/en.ts | 10 +- app/routes/schedule.$schedule.tsx | 48 +++------ app/routes/schedule._index.tsx | 8 +- app/routes/schedule.add.tsx | 54 +++------- .../migration.sql | 21 ++++ prisma/schema.prisma | 2 + prisma/seed.js | 29 +++++ 8 files changed, 187 insertions(+), 87 deletions(-) create mode 100644 app/lib/sequence-builder.tsx create mode 100644 prisma/migrations/20251021104121_add_audio_sequence_to_schedule/migration.sql diff --git a/app/lib/sequence-builder.tsx b/app/lib/sequence-builder.tsx new file mode 100644 index 0000000..246c90c --- /dev/null +++ b/app/lib/sequence-builder.tsx @@ -0,0 +1,102 @@ +import {useState} from 'react' +import {type Audio} from '@prisma/client' + +import {getSecondsAsTime, INPUT_CLASSES} from './utils' +import {HelperText} from './ui' +import {useTranslation} from './i18n' + +export const SequenceBuilder = ({ + sounds, + initialQueue, + name, + label, + helperText +}: { + sounds: Audio[] + initialQueue: string[] + name: string + label: string + helperText: string +}) => { + const [queue, setQueue] = useState(initialQueue) + const [selected, setSelected] = useState(sounds[0].id) + const {t} = useTranslation() + + let duration = 0 + + return ( +
+ {label} +
+ {helperText} +
+ +
+ + +
+
+ {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 f7a50b4..404df81 100644 --- a/app/locales/en.ts +++ b/app/locales/en.ts @@ -212,8 +212,7 @@ export const en = { 'schedule.defaultOption': 'Default', 'schedule.table.time': 'Time', 'schedule.table.zone': 'Zone', - 'schedule.table.sound': 'Sound', - 'schedule.table.count': 'Count', + 'schedule.table.sequenceLength': 'Sequence Length', 'schedule.addButton': 'Add entry', 'schedule.add.pageTitle': 'Add schedule entry', 'schedule.form.time.label': 'Time', @@ -223,10 +222,9 @@ export const en = { 'schedule.form.day.helper': 'Day type that this schedule entry applies to.', 'schedule.form.zone.label': 'Zone', 'schedule.form.zone.helper': 'Zone where this schedule entry applies.', - 'schedule.form.sound.label': 'Sound', - 'schedule.form.sound.helper': 'Sound to play for this schedule entry.', - 'schedule.form.count.label': 'Repeat count', - 'schedule.form.count.helper': 'How many times to play the sound.', + 'schedule.form.sequence.label': 'Sequence', + 'schedule.form.sequence.helper': + 'The sequence of sounds to play at this schedule. You can add the same sound multiple times.', 'schedule.add.submit': 'Add entry', 'schedule.error.noDays': 'At least one day must be selected', 'schedule.edit.metaTitle': 'Edit {{time}}', diff --git a/app/routes/schedule.$schedule.tsx b/app/routes/schedule.$schedule.tsx index 4ccee60..fde7458 100644 --- a/app/routes/schedule.$schedule.tsx +++ b/app/routes/schedule.$schedule.tsx @@ -15,6 +15,7 @@ import {useTranslation} from '~/lib/i18n' import {translate} from '~/lib/i18n.shared' import {getRootI18n} from '~/lib/i18n.meta' import {initTranslations} from '~/lib/i18n.server' +import {SequenceBuilder} from '~/lib/sequence-builder' export const meta: MetaFunction = ({data, matches}) => { const {messages} = getRootI18n(matches) @@ -94,14 +95,12 @@ export const action: ActionFunction = async ({request, params}) => { const time = formData.get('time') as string | undefined const zone = formData.get('zone') as string | undefined const day = formData.get('dayType') as string | undefined - const sound = formData.get('sound') as string | undefined - const count = formData.get('count') as string | undefined + const sequence = formData.get('sequence') as string | undefined invariant(time) invariant(zone) invariant(day) - invariant(sound) - invariant(count) + invariant(sequence) await prisma.schedule.update({ where: {id: params.schedule}, @@ -110,8 +109,9 @@ export const action: ActionFunction = async ({request, params}) => { time, zoneId: zone, dayTypeId: day === '_' ? undefined : day, - audioId: sound, - count: parseInt(count) + audioId: JSON.parse(sequence)[0], + count: 0, + audioSequence: sequence } }) @@ -199,35 +199,13 @@ const EditSchedule = () => { })} - - - - - - + { {t('calendar.weekdays.saturday')} {t('calendar.weekdays.sunday')} {t('schedule.table.zone')} - {t('schedule.table.sound')} - {t('schedule.table.count')} + {t('schedule.table.sequenceLength')} @@ -86,7 +85,7 @@ const Schedule = () => { .filter(({dayTypeId}) => { return dayTypeId === (day === '_' ? null : day) }) - .map(({id, time, weekDays, zone, audio, count}) => { + .map(({id, time, weekDays, zone, audioSequence}) => { const days = weekDays.split(',') return ( @@ -117,9 +116,8 @@ const Schedule = () => { {zone.name} - {audio.name} + {(JSON.parse(audioSequence) as string[]).length} - {count}
diff --git a/app/routes/schedule.add.tsx b/app/routes/schedule.add.tsx index 085d5a2..9e51531 100644 --- a/app/routes/schedule.add.tsx +++ b/app/routes/schedule.add.tsx @@ -16,6 +16,7 @@ import {useTranslation} from '~/lib/i18n' import {translate} from '~/lib/i18n.shared' import {getRootI18n} from '~/lib/i18n.meta' import {initTranslations} from '~/lib/i18n.server' +import {SequenceBuilder} from '~/lib/sequence-builder' export const meta: MetaFunction = ({matches}) => { const {messages} = getRootI18n(matches) @@ -90,14 +91,12 @@ export const action: ActionFunction = async ({request}) => { const time = formData.get('time') as string | undefined const zone = formData.get('zone') as string | undefined const day = formData.get('dayType') as string | undefined - const sound = formData.get('sound') as string | undefined - const count = formData.get('count') as string | undefined + const sequence = formData.get('sequence') as string | undefined invariant(time) invariant(zone) invariant(day) - invariant(sound) - invariant(count) + invariant(sequence) await prisma.schedule.create({ data: { @@ -105,8 +104,9 @@ export const action: ActionFunction = async ({request}) => { time, zoneId: zone, dayTypeId: day === '_' ? undefined : day, - audioId: sound, - count: parseInt(count) + audioId: JSON.parse(sequence)[0], + count: 0, + audioSequence: sequence } }) @@ -192,41 +192,13 @@ const AddSchedule = () => { })} - - - - - { - setCount(e.target.value) - }} - /> - + { update: {value: sound.id} }) } + + const schedulesWithNoSequence = await prisma.schedule.findMany({ + where: {audioSequence: ''} + }) + + if (schedulesWithNoSequence.length > 0) { + console.log('Schedules need migrating to the audio sequence system.') + + const promises = schedulesWithNoSequence.map(({id, audioId, count}) => { + return new Promise(async resolve => { + const sequence = [] + + let i = 0 + while (i < count) { + sequence.push(audioId) + i++ + } + + await prisma.schedule.update({ + where: {id}, + data: {audioSequence: JSON.stringify(sequence)} + }) + + resolve() + }) + }) + + await Promise.all(promises) + } } main() From c63b30a5b250758187e1583af46ef2d3b62baf57 Mon Sep 17 00:00:00 2001 From: AML - A Laycock Date: Tue, 21 Oct 2025 13:23:10 +0100 Subject: [PATCH 2/4] feat: update sounder api to handle sequences instead --- app/lib/constants.ts | 2 +- app/routes/sounder-api.get-schedule.tsx | 5 ++--- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/app/lib/constants.ts b/app/lib/constants.ts index f91f6b5..764d47b 100644 --- a/app/lib/constants.ts +++ b/app/lib/constants.ts @@ -4,7 +4,7 @@ export const RequiredVersions = { controller: VERSION, tts: '2.0.0', piper: '1.3.0', - sounder: '2.0.0' + sounder: '2.1.0' } export const DOCS_URL = `https://openschoolbell.co.uk` diff --git a/app/routes/sounder-api.get-schedule.tsx b/app/routes/sounder-api.get-schedule.tsx index 5863791..0b3a4c5 100644 --- a/app/routes/sounder-api.get-schedule.tsx +++ b/app/routes/sounder-api.get-schedule.tsx @@ -31,13 +31,12 @@ export const action = async ({request}: ActionFunctionArgs) => { } }) - const data = schedules.map(({time, dayTypeId, weekDays, audioId, count}) => { + const data = schedules.map(({time, dayTypeId, weekDays, audioSequence}) => { return { time, day: dayTypeId ? dayTypeId : 'null', weekDays, - soundId: audioId, - count + sequence: audioSequence } }) From e99eac2894576802a4336b8fdfb7c2235fdd1124 Mon Sep 17 00:00:00 2001 From: AML - A Laycock Date: Tue, 21 Oct 2025 13:27:07 +0100 Subject: [PATCH 3/4] fix: locale is a partial --- app/lib/i18n.server.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/app/lib/i18n.server.ts b/app/lib/i18n.server.ts index ac66330..2795391 100644 --- a/app/lib/i18n.server.ts +++ b/app/lib/i18n.server.ts @@ -46,7 +46,10 @@ const getMessages = (locale: SupportedLocale): Messages => { const messages: Messages = {} ;(Object.keys(locales[FALLBACK_LOCALE]) as MessageKey[]).forEach(key => { - messages[key] = locales[locale][key] ?? locales[FALLBACK_LOCALE][key] + messages[key] = + (locales[locale] as Partial<(typeof locales)[typeof FALLBACK_LOCALE]>)[ + key + ] ?? locales[FALLBACK_LOCALE][key] }) return messages From 78abd117a906e316e6bf8471c19d802f967bde9c Mon Sep 17 00:00:00 2001 From: AML - A Laycock Date: Tue, 21 Oct 2025 14:19:48 +0100 Subject: [PATCH 4/4] feat: add copy/paste to sequence builder, closes #2 --- app/lib/sequence-builder.tsx | 32 +++++++++++++++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/app/lib/sequence-builder.tsx b/app/lib/sequence-builder.tsx index 246c90c..bdd8f92 100644 --- a/app/lib/sequence-builder.tsx +++ b/app/lib/sequence-builder.tsx @@ -1,6 +1,9 @@ -import {useState} from 'react' +import {useState, useEffect} from 'react' import {type Audio} from '@prisma/client' +import CopyIcon from '@heroicons/react/24/outline/DocumentDuplicateIcon' +import PasteIcon from '@heroicons/react/24/outline/ClipboardDocumentIcon' + import {getSecondsAsTime, INPUT_CLASSES} from './utils' import {HelperText} from './ui' import {useTranslation} from './i18n' @@ -56,6 +59,33 @@ export const SequenceBuilder = ({ > Add Sound +
+ + +
{queue.map((queuedId, i) => {