diff --git a/client-v3/e2e/tests/13-live-show.spec.ts b/client-v3/e2e/tests/13-live-show.spec.ts index c793e165..12d05590 100644 --- a/client-v3/e2e/tests/13-live-show.spec.ts +++ b/client-v3/e2e/tests/13-live-show.spec.ts @@ -79,6 +79,12 @@ test('leader is NOT in following mode', async () => { await expect(container).not.toHaveAttribute('data-following', 'true'); }); +test('current cue footer is visible by default', async () => { + // toBeVisible() alone would pass even if the footer were pushed below the fold by + // the script pane's computed height — toBeInViewport() catches that layout regression. + await expect(leaderPage.locator('.current-cue-footer')).toBeInViewport(); +}); + // ── Follower connects ───────────────────────────────────────────────────── test('follower navigates to /live after leader', async () => { @@ -146,6 +152,13 @@ test('can add an individual cue from the live view', async () => { }); }); +test('current cue footer shows the last cue seen for its type', async () => { + await expect(leaderPage.locator('.current-cue-footer .cue-button')).toHaveCount(1, { + timeout: 5_000, + }); + await expect(leaderPage.locator('.current-cue-footer .cue-button').first()).toContainText('101'); +}); + test('can add a cue group from the live view', async () => { await leaderPage.locator('.add-cue-btn').first().click(); await waitForModal(leaderPage, 'Add Cue'); @@ -167,6 +180,14 @@ test('can add a cue group from the live view', async () => { await expect(leaderPage.locator('.cue-group-btn').first()).toContainText('LX 200 - LX 202'); }); +test('current cue footer still shows exactly one badge for the cue type after grouped cues are added', async () => { + // Grouped cues are tracked individually — the footer collapses to the single most + // recently passed cue of that type, whichever one that ends up being. + await expect(leaderPage.locator('.current-cue-footer .cue-button')).toHaveCount(1, { + timeout: 5_000, + }); +}); + test('cue group buttons in live view are non-interactive display-only buttons', async () => { // Group buttons in live view should NOT open an edit modal — editing is blocked server-side // during live sessions. Verify clicking doesn't open any modal. diff --git a/client-v3/e2e/tests/14-user-settings.spec.ts b/client-v3/e2e/tests/14-user-settings.spec.ts index 9eb2fabf..a3c52f0f 100644 --- a/client-v3/e2e/tests/14-user-settings.spec.ts +++ b/client-v3/e2e/tests/14-user-settings.spec.ts @@ -63,6 +63,28 @@ test('changing a toggle enables the Submit button', async () => { } }); +test('current cue footer setting defaults on and persists after being toggled off', async () => { + const checkbox = page.locator('#show-current-cue-footer-input'); + await expect(checkbox).toBeChecked(); + + await checkbox.click(); + await expect(checkbox).not.toBeChecked(); + await page.click('button:has-text("Submit")'); + await expect(page.locator('button:has-text("Submit")')).toBeDisabled({ timeout: 5_000 }); + + await page.reload(); + await waitForAppReady(page); + await page.click('.nav-link:has-text("Settings"), button[role="tab"]:has-text("Settings")'); + await expect(page.locator('#show-current-cue-footer-input')).not.toBeChecked({ + timeout: 5_000, + }); + + // Restore default state so it doesn't affect any later tests + await page.locator('#show-current-cue-footer-input').click(); + await page.click('button:has-text("Submit")'); + await expect(page.locator('#show-current-cue-footer-input')).toBeChecked({ timeout: 5_000 }); +}); + // ── Stage Direction Styles ──────────────────────────────────────────────── test('creates a stage direction style for override testing', async () => { diff --git a/client-v3/src/components/show/live/CurrentCueFooter.vue b/client-v3/src/components/show/live/CurrentCueFooter.vue new file mode 100644 index 00000000..335dd653 --- /dev/null +++ b/client-v3/src/components/show/live/CurrentCueFooter.vue @@ -0,0 +1,58 @@ + + + + + diff --git a/client-v3/src/components/show/live/ScriptViewPane.vue b/client-v3/src/components/show/live/ScriptViewPane.vue index f2c8dce0..85e8ed06 100644 --- a/client-v3/src/components/show/live/ScriptViewPane.vue +++ b/client-v3/src/components/show/live/ScriptViewPane.vue @@ -263,10 +263,12 @@ const props = defineProps<{ intervalActive: boolean; scriptMode: number; stageManagerMode: boolean; + showCurrentCueFooter: boolean; }>(); const emit = defineEmits<{ 'page-change': [page: number]; + 'current-line-change': [lineOnPage: number]; 'script-loaded': []; }>(); @@ -479,6 +481,7 @@ function navigateTo(targetPage: number, targetLineOnPage: number, preventScroll currentPage.value = targetPage; currentLineOnPage.value = targetLineOnPage; emit('page-change', targetPage); + emit('current-line-change', targetLineOnPage); const targetElementId = `page_${targetPage}_line_${targetLineOnPage}`; const targetElement = document.getElementById(targetElementId); @@ -660,7 +663,9 @@ function handleFollowDataChange(): void { scrollToElement(contextElement ?? currentLineElement); currentPage.value = page; + currentLineOnPage.value = line; emit('page-change', page); + emit('current-line-change', line); computeScriptBoundaries(); } } @@ -713,12 +718,26 @@ function computeScriptBoundaries(): void { function computeContentSize(): void { const scriptContainer = document.getElementById('script-container'); if (!scriptContainer) return; + const footer = document.getElementById('current-cue-footer'); + const footerHeight = footer ? footer.getBoundingClientRect().height : 0; const startPos = scriptContainer.getBoundingClientRect().top; - const boxHeight = document.documentElement.clientHeight - startPos; + const boxHeight = document.documentElement.clientHeight - startPos - footerHeight; scriptContainer.style.height = `${boxHeight - 10}px`; computeScriptBoundaries(); } +let footerResizeObserver: ResizeObserver | null = null; + +function observeFooterResize(): void { + footerResizeObserver?.disconnect(); + footerResizeObserver = null; + const footer = document.getElementById('current-cue-footer'); + if (footer) { + footerResizeObserver = new ResizeObserver(() => debounceContentSize()); + footerResizeObserver.observe(footer); + } +} + // --- Script loading --- async function loadCompiledScript(): Promise { @@ -909,6 +928,7 @@ onMounted(async () => { ]); computeContentSize(); + observeFooterResize(); const loadedCompiledScript = await loadCompiledScript(); @@ -945,7 +965,17 @@ onMounted(async () => { emit('script-loaded'); }); +watch( + () => props.showCurrentCueFooter, + async () => { + await nextTick(); + computeContentSize(); + observeFooterResize(); + } +); + onUnmounted(() => { + footerResizeObserver?.disconnect(); window.removeEventListener('keydown', handleKeyPress); const scriptContainer = document.getElementById('script-container'); if (scriptContainer) { diff --git a/client-v3/src/components/user/settings/UserSettingsConfig.vue b/client-v3/src/components/user/settings/UserSettingsConfig.vue index 49fbe4aa..7e017056 100644 --- a/client-v3/src/components/user/settings/UserSettingsConfig.vue +++ b/client-v3/src/components/user/settings/UserSettingsConfig.vue @@ -107,6 +107,19 @@ /> + + + + ({ character_mru_sort: false, character_combined_dropdown: false, preferred_ui: null, + show_current_cue_footer: true, }); const state = ref(defaultState()); @@ -200,6 +214,7 @@ const rules = computed(() => ({ character_mru_sort: {}, character_combined_dropdown: {}, preferred_ui: {}, + show_current_cue_footer: {}, })); const v$ = useVuelidate(rules, state); diff --git a/client-v3/src/stores/script.test.ts b/client-v3/src/stores/script.test.ts new file mode 100644 index 00000000..a6545b4d --- /dev/null +++ b/client-v3/src/stores/script.test.ts @@ -0,0 +1,99 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { createPinia, setActivePinia } from 'pinia'; +import type { ScriptLine } from '@/types/api/script'; +import type { Cue } from '@/types/api/cues'; +import { useScriptStore } from './script'; + +function makeLine(id: number): ScriptLine { + return { + id, + act_id: 1, + scene_id: 1, + page: 1, + line_type: 1, + stage_direction_style_id: null, + line_parts: [], + }; +} + +function makeCue(id: number, cueTypeId: number, linePosition: number | null = 0): Cue { + return { + id, + cue_type_id: cueTypeId, + ident: String(id), + group_id: null, + sort_order: null, + line_position: linePosition, + }; +} + +describe('script store cue tracking getters', () => { + beforeEach(() => { + setActivePinia(createPinia()); + }); + + it('builds a line order index across pages', () => { + const store = useScriptStore(); + store.script = { + '1': [makeLine(10), makeLine(11)], + '2': [makeLine(20)], + }; + + const index = store.lineOrderIndex; + + expect(index.get(10)).toEqual({ page: 1, index: 0 }); + expect(index.get(11)).toEqual({ page: 1, index: 1 }); + expect(index.get(20)).toEqual({ page: 2, index: 0 }); + }); + + it('returns the last cue per cue type at or before the given position', () => { + const store = useScriptStore(); + store.script = { + '1': [makeLine(10), makeLine(11)], + '2': [makeLine(20), makeLine(21)], + }; + store.cues = { + '10': [makeCue(1, 100), makeCue(2, 200)], + '11': [makeCue(3, 100)], + '20': [makeCue(4, 200)], + '21': [makeCue(5, 100)], + }; + + // Position at page 1, line index 1 (line 11): cue type 100 -> cue 3, type 200 -> cue 2 + const atLine11 = store.lastCuePerTypeAt(1, 1); + expect(atLine11[100].id).toBe(3); + expect(atLine11[200].id).toBe(2); + + // Position at page 2, line index 0 (line 20): type 100 still cue 3 (last passed), type 200 -> cue 4 + const atLine20 = store.lastCuePerTypeAt(2, 0); + expect(atLine20[100].id).toBe(3); + expect(atLine20[200].id).toBe(4); + + // Position at page 2, line index 1 (line 21): type 100 -> cue 5 (overtakes cue 3) + const atLine21 = store.lastCuePerTypeAt(2, 1); + expect(atLine21[100].id).toBe(5); + expect(atLine21[200].id).toBe(4); + }); + + it('ignores cues on lines not yet in the line order index', () => { + const store = useScriptStore(); + store.script = { '1': [makeLine(10)] }; + store.cues = { '999': [makeCue(1, 100)] }; + + const result = store.lastCuePerTypeAt(1, 0); + + expect(result).toEqual({}); + }); + + it('breaks ties on the same line using line_position', () => { + const store = useScriptStore(); + store.script = { '1': [makeLine(10)] }; + store.cues = { + '10': [makeCue(1, 100, 0), makeCue(2, 100, 5)], + }; + + const result = store.lastCuePerTypeAt(1, 0); + + expect(result[100].id).toBe(2); + }); +}); diff --git a/client-v3/src/stores/script.ts b/client-v3/src/stores/script.ts index 11cc42d3..538f1e94 100644 --- a/client-v3/src/stores/script.ts +++ b/client-v3/src/stores/script.ts @@ -93,6 +93,46 @@ export const useScriptStore = defineStore('script', { }); return { individual, groups, merged }; }, + lineOrderIndex(state): Map { + const index = new Map(); + for (const [pageStr, lines] of Object.entries(state.script)) { + const page = Number(pageStr); + lines.forEach((line, lineIndex) => { + if (line.id != null) index.set(line.id, { page, index: lineIndex }); + }); + } + return index; + }, + orderedCueEntries(state): { cue: Cue; page: number; index: number }[] { + const order = this.lineOrderIndex; + const entries: { cue: Cue; page: number; index: number }[] = []; + for (const [lineIdStr, cuesForLine] of Object.entries(state.cues)) { + const position = order.get(Number(lineIdStr)); + if (!position) continue; + for (const cue of cuesForLine) { + entries.push({ cue, page: position.page, index: position.index }); + } + } + entries.sort((a, b) => { + if (a.page !== b.page) return a.page - b.page; + if (a.index !== b.index) return a.index - b.index; + return (a.cue.line_position ?? 0) - (b.cue.line_position ?? 0); + }); + return entries; + }, + lastCuePerTypeAt(): (page: number, lineIndex: number) => Record { + const entries = this.orderedCueEntries; + return (page: number, lineIndex: number): Record => { + const result: Record = {}; + for (const entry of entries) { + if (entry.page > page || (entry.page === page && entry.index > lineIndex)) break; + if (entry.cue.cue_type_id != null) { + result[entry.cue.cue_type_id] = entry.cue; + } + } + return result; + }; + }, }, actions: { diff --git a/client-v3/src/types/api/user.ts b/client-v3/src/types/api/user.ts index e6fa8dfb..f2ec6dcc 100644 --- a/client-v3/src/types/api/user.ts +++ b/client-v3/src/types/api/user.ts @@ -27,4 +27,5 @@ export interface UserSettings { table_page_sizes: Record | null; default_sd_text_colour: string | null; default_sd_background_colour: string | null; + show_current_cue_footer: boolean; } diff --git a/client-v3/src/views/show/ShowLiveView.vue b/client-v3/src/views/show/ShowLiveView.vue index e183ac4a..b9d80364 100644 --- a/client-v3/src/views/show/ShowLiveView.vue +++ b/client-v3/src/views/show/ShowLiveView.vue @@ -59,7 +59,9 @@ :interval-active="showStore.currentInterval != null" :script-mode="systemStore.currentShow?.script_mode ?? 1" :stage-manager-mode="showStore.stageManagerMode" + :show-current-cue-footer="userStore.userSettings?.show_current_cue_footer ?? false" @page-change="currentPage = $event" + @current-line-change="currentLineOnPage = $event" /> @@ -67,6 +69,11 @@ + @@ -78,20 +85,24 @@ import 'splitpanes/dist/splitpanes.css'; import { formatTimerParts, msToTimerParts, msToTimerString } from '@/js/utils'; import { useShowStore } from '@/stores/show'; import { useSystemStore } from '@/stores/system'; +import { useUserStore } from '@/stores/user'; import { useWebSocketStore } from '@/stores/websocket'; import { useWebSocket } from '@/composables/useWebSocket'; import { toast } from '@/js/toast'; import ScriptViewPane from '@/components/show/live/ScriptViewPane.vue'; import StageManagerPane from '@/components/show/live/StageManagerPane.vue'; +import CurrentCueFooter from '@/components/show/live/CurrentCueFooter.vue'; const showStore = useShowStore(); const systemStore = useSystemStore(); +const userStore = useUserStore(); const wsStore = useWebSocketStore(); const { sendObj } = useWebSocket(); // Session header state const loadedSessionData = ref(false); const currentPage = ref(1); +const currentLineOnPage = ref(0); // Elapsed time const elapsedTime = ref(0); diff --git a/client/src/store/modules/script.test.ts b/client/src/store/modules/script.test.ts new file mode 100644 index 00000000..7830d54a --- /dev/null +++ b/client/src/store/modules/script.test.ts @@ -0,0 +1,105 @@ +import { describe, it, expect } from 'vitest'; +import Vue from 'vue'; +import Vuex from 'vuex'; +import type { ScriptLine } from '@/types/api/script'; +import type { Cue } from '@/types/api/cues'; +import scriptModule from './script'; + +Vue.use(Vuex); + +function makeLine(id: number): ScriptLine { + return { + id, + act_id: 1, + scene_id: 1, + page: 1, + line_type: 1, + stage_direction_style_id: null, + line_parts: [], + }; +} + +function makeCue(id: number, cueTypeId: number, linePosition: number | null = 0): Cue { + return { + id, + cue_type_id: cueTypeId, + ident: String(id), + group_id: null, + sort_order: null, + line_position: linePosition, + }; +} + +function makeStore() { + return new Vuex.Store({ + modules: { + script: { ...scriptModule, namespaced: true }, + }, + }); +} + +describe('script module cue tracking getters', () => { + it('builds a line order index across pages', () => { + const store = makeStore(); + store.replaceState({ + script: { script: { '1': [makeLine(10), makeLine(11)], '2': [makeLine(20)] } }, + } as any); + + const index = store.getters['script/LINE_ORDER_INDEX']; + + expect(index.get(10)).toEqual({ page: 1, index: 0 }); + expect(index.get(11)).toEqual({ page: 1, index: 1 }); + expect(index.get(20)).toEqual({ page: 2, index: 0 }); + }); + + it('returns the last cue per cue type at or before the given position', () => { + const store = makeStore(); + store.replaceState({ + script: { + script: { '1': [makeLine(10), makeLine(11)], '2': [makeLine(20), makeLine(21)] }, + cues: { + '10': [makeCue(1, 100), makeCue(2, 200)], + '11': [makeCue(3, 100)], + '20': [makeCue(4, 200)], + '21': [makeCue(5, 100)], + }, + }, + } as any); + + const atLine11 = store.getters['script/LAST_CUE_PER_TYPE_AT'](1, 1); + expect(atLine11[100].id).toBe(3); + expect(atLine11[200].id).toBe(2); + + const atLine20 = store.getters['script/LAST_CUE_PER_TYPE_AT'](2, 0); + expect(atLine20[100].id).toBe(3); + expect(atLine20[200].id).toBe(4); + + const atLine21 = store.getters['script/LAST_CUE_PER_TYPE_AT'](2, 1); + expect(atLine21[100].id).toBe(5); + expect(atLine21[200].id).toBe(4); + }); + + it('ignores cues on lines not yet in the line order index', () => { + const store = makeStore(); + store.replaceState({ + script: { + script: { '1': [makeLine(10)] }, + cues: { '999': [makeCue(1, 100)] }, + }, + } as any); + + expect(store.getters['script/LAST_CUE_PER_TYPE_AT'](1, 0)).toEqual({}); + }); + + it('breaks ties on the same line using line_position', () => { + const store = makeStore(); + store.replaceState({ + script: { + script: { '1': [makeLine(10)] }, + cues: { '10': [makeCue(1, 100, 0), makeCue(2, 100, 5)] }, + }, + } as any); + + expect(store.getters['script/LAST_CUE_PER_TYPE_AT'](1, 0)[100].id).toBe(2); + }); +}); diff --git a/client/src/store/modules/script.ts b/client/src/store/modules/script.ts index 97cef764..e075bc43 100644 --- a/client/src/store/modules/script.ts +++ b/client/src/store/modules/script.ts @@ -486,6 +486,53 @@ const module: Module = { }); return { individual, groups, merged }; }, + LINE_ORDER_INDEX(state: ScriptState): Map { + const index = new Map(); + for (const [pageStr, lines] of Object.entries(state.script)) { + const page = Number(pageStr); + lines.forEach((line, lineIndex) => { + if (line.id != null) index.set(line.id, { page, index: lineIndex }); + }); + } + return index; + }, + ORDERED_CUE_ENTRIES( + state: ScriptState, + getters: any + ): { cue: Cue; page: number; index: number }[] { + const order = getters.LINE_ORDER_INDEX as Map; + const entries: { cue: Cue; page: number; index: number }[] = []; + for (const [lineIdStr, cuesForLine] of Object.entries(state.cues)) { + const position = order.get(Number(lineIdStr)); + if (!position) continue; + for (const cue of cuesForLine) { + entries.push({ cue, page: position.page, index: position.index }); + } + } + entries.sort((a, b) => { + if (a.page !== b.page) return a.page - b.page; + if (a.index !== b.index) return a.index - b.index; + return (a.cue.line_position ?? 0) - (b.cue.line_position ?? 0); + }); + return entries; + }, + LAST_CUE_PER_TYPE_AT: + (_state: ScriptState, getters: any) => + (page: number, lineIndex: number): Record => { + const entries = getters.ORDERED_CUE_ENTRIES as { + cue: Cue; + page: number; + index: number; + }[]; + const result: Record = {}; + for (const entry of entries) { + if (entry.page > page || (entry.page === page && entry.index > lineIndex)) break; + if (entry.cue.cue_type_id != null) { + result[entry.cue.cue_type_id] = entry.cue; + } + } + return result; + }, SCRIPT_CUTS(state: ScriptState) { return state.cuts; }, diff --git a/client/src/types/api/user.ts b/client/src/types/api/user.ts index 0f83bee8..1dad284f 100644 --- a/client/src/types/api/user.ts +++ b/client/src/types/api/user.ts @@ -26,4 +26,5 @@ export interface UserSettings { table_page_sizes: Record | null; default_sd_text_colour: string | null; default_sd_background_colour: string | null; + show_current_cue_footer: boolean; } diff --git a/client/src/views/show/ShowLiveView.vue b/client/src/views/show/ShowLiveView.vue index fc8ca79b..55eebd27 100644 --- a/client/src/views/show/ShowLiveView.vue +++ b/client/src/views/show/ShowLiveView.vue @@ -66,7 +66,9 @@ :interval-active="CURRENT_SHOW_INTERVAL != null" :script-mode="CURRENT_SHOW?.script_mode || 1" :stage-manager-mode="stageManagerMode" + :show-current-cue-footer="!!USER_SETTINGS.show_current_cue_footer" @page-change="currentPage = $event" + @current-line-change="currentLineOnPage = $event" /> @@ -74,6 +76,12 @@ + @@ -84,12 +92,14 @@ import { mapGetters, mapActions } from 'vuex'; import { formatTimerParts, msToTimerParts, msToTimerString } from '@/js/utils'; import ScriptViewPane from '@/vue_components/show/live/ScriptViewPane.vue'; import StageManagerPane from '@/vue_components/show/live/StageManagerPane.vue'; +import CurrentCueFooter from '@/vue_components/show/live/CurrentCueFooter.vue'; export default defineComponent({ name: 'ShowLiveView', components: { ScriptViewPane, StageManagerPane, + CurrentCueFooter, }, data() { return { @@ -98,6 +108,7 @@ export default defineComponent({ startTime: null as Date | null, loadedSessionData: false, currentPage: 1, + currentLineOnPage: 0, intervalTimer: null as ReturnType | null, intervalStartDate: null as Date | null, isIntervalLong: false, @@ -158,6 +169,8 @@ export default defineComponent({ 'INTERNAL_UUID', 'SESSION_FOLLOW_DATA', 'CURRENT_SHOW_INTERVAL', + 'USER_SETTINGS', + 'CUE_TYPES', ]), ...mapGetters({ stageManagerMode: 'STAGE_MANAGER_MODE', diff --git a/client/src/vue_components/show/live/CurrentCueFooter.vue b/client/src/vue_components/show/live/CurrentCueFooter.vue new file mode 100644 index 00000000..4356b80b --- /dev/null +++ b/client/src/vue_components/show/live/CurrentCueFooter.vue @@ -0,0 +1,72 @@ + + + + + diff --git a/client/src/vue_components/show/live/ScriptViewPane.vue b/client/src/vue_components/show/live/ScriptViewPane.vue index 9a13efba..cc3d78e3 100644 --- a/client/src/vue_components/show/live/ScriptViewPane.vue +++ b/client/src/vue_components/show/live/ScriptViewPane.vue @@ -250,6 +250,10 @@ export default defineComponent({ type: Boolean, required: true, }, + showCurrentCueFooter: { + type: Boolean, + required: true, + }, }, data() { return { @@ -398,7 +402,9 @@ export default defineComponent({ this.scrollToElement(currentLineElement); } this.currentPage = page; + this.currentLineOnPage = line; this.$emit('page-change', page); + this.$emit('current-line-change', line); this.computeScriptBoundaries(); } } @@ -410,6 +416,12 @@ export default defineComponent({ } }); }, + showCurrentCueFooter(): void { + this.$nextTick(() => { + this.computeContentSize(); + this.observeFooterResize(); + }); + }, }, async mounted(): Promise { // Load all independent data in parallel @@ -437,6 +449,7 @@ export default defineComponent({ ]); this.computeContentSize(); + this.observeFooterResize(); const loadedCompiledScript = await this.loadCompiledScript(); @@ -495,6 +508,7 @@ export default defineComponent({ this.$emit('script-loaded'); }, destroyed(): void { + (this as any).footerResizeObserver?.disconnect(); this.removeNavigation(); window.removeEventListener('resize', this.debounceContentSize); }, @@ -529,6 +543,7 @@ export default defineComponent({ this.currentPage = targetPage; this.currentLineOnPage = targetLineOnPage; this.$emit('page-change', targetPage); + this.$emit('current-line-change', targetLineOnPage); const targetElementId = `page_${targetPage}_line_${targetLineOnPage}`; const targetElement = document.getElementById(targetElementId); @@ -902,11 +917,22 @@ export default defineComponent({ }, computeContentSize() { const scriptContainer = $('#script-container'); + const footer = $('#current-cue-footer'); + const footerHeight = footer.length > 0 ? (footer.outerHeight() ?? 0) : 0; const startPos = scriptContainer.offset().top; - const boxHeight = document.documentElement.clientHeight - startPos; + const boxHeight = document.documentElement.clientHeight - startPos - footerHeight; scriptContainer.height(boxHeight - 10); this.computeScriptBoundaries(); }, + observeFooterResize() { + (this as any).footerResizeObserver?.disconnect(); + (this as any).footerResizeObserver = null; + const footer = document.getElementById('current-cue-footer'); + if (footer) { + (this as any).footerResizeObserver = new ResizeObserver(() => this.debounceContentSize()); + (this as any).footerResizeObserver.observe(footer); + } + }, // Line/cue helpers getPreviousLineForIndex(pageIndex: number, lineIndex: number): any { diff --git a/client/src/vue_components/user/settings/Settings.vue b/client/src/vue_components/user/settings/Settings.vue index 29c513d3..6f2571e8 100644 --- a/client/src/vue_components/user/settings/Settings.vue +++ b/client/src/vue_components/user/settings/Settings.vue @@ -102,6 +102,18 @@ :switch="true" /> + + + val === null || val === 'old' || val === 'new' }, + show_current_cue_footer: {}, }, }, mounted(): void { diff --git a/docs/pages/live_show.md b/docs/pages/live_show.md index 73ade4ac..147db5bf 100644 --- a/docs/pages/live_show.md +++ b/docs/pages/live_show.md @@ -51,6 +51,12 @@ All other connected clients (whether logged in or not) will automatically follow #### Manual Mode If the leader's client becomes disconnected, all other clients become "orphaned" and switch to manual mode, where they can control their own script position independently. When a client logged in as the leader user reconnects, it automatically resumes leadership, and all orphaned clients return to follower mode. +### Current Cue Footer + +A "Current Cues" footer bar at the bottom of the script area shows the last cue that has been passed for every cue type, updating automatically as the script scrolls. This gives a quick "where are we" reference without needing to keep the script's cue column in view. Cues are displayed using the same colours and labels as they appear in the script itself. + +The footer is shown by default, but it can be turned off independently by each user from the **Settings** tab of the User Settings page (**Show current cue footer in Live view**). The preference is saved to your account and persists across sessions and devices. + ### Act Intervals If your show has intervals configured between acts, an interval screen will automatically appear between the acts during the live show. The interval will only display if there is script content in both acts surrounding the interval. diff --git a/server/alembic_config/versions/e96bdd11ca42_add_show_current_cue_footer_user_setting.py b/server/alembic_config/versions/e96bdd11ca42_add_show_current_cue_footer_user_setting.py new file mode 100644 index 00000000..07e6b7f7 --- /dev/null +++ b/server/alembic_config/versions/e96bdd11ca42_add_show_current_cue_footer_user_setting.py @@ -0,0 +1,44 @@ +"""Add show_current_cue_footer user setting + +Revision ID: e96bdd11ca42 +Revises: f1974e4e57d0 +Create Date: 2026-07-09 12:28:26.922816 + +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + + +# revision identifiers, used by Alembic. +revision: str = "e96bdd11ca42" +down_revision: Union[str, None] = "f1974e4e57d0" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # Add column as nullable first so existing rows can be backfilled + with op.batch_alter_table("user_settings", schema=None) as batch_op: + batch_op.add_column( + sa.Column("show_current_cue_footer", sa.Boolean(), nullable=True) + ) + + # Backfill existing rows to True (feature defaults to on) + op.execute( + "UPDATE user_settings SET show_current_cue_footer = 1 " + "WHERE show_current_cue_footer IS NULL" + ) + + # Make column non-nullable + with op.batch_alter_table("user_settings", schema=None) as batch_op: + batch_op.alter_column( + "show_current_cue_footer", existing_type=sa.Boolean(), nullable=False + ) + + +def downgrade() -> None: + with op.batch_alter_table("user_settings", schema=None) as batch_op: + batch_op.drop_column("show_current_cue_footer") diff --git a/server/models/user.py b/server/models/user.py index 17606b71..f77a65b1 100644 --- a/server/models/user.py +++ b/server/models/user.py @@ -102,6 +102,7 @@ class UserSettings(db.Model): table_page_sizes: Mapped[dict | None] = mapped_column(JSON, default=None) default_sd_text_colour: Mapped[str | None] = mapped_column(default=None) default_sd_background_colour: Mapped[str | None] = mapped_column(default=None) + show_current_cue_footer: Mapped[bool] = mapped_column(default=True) __table_args__ = ( CheckConstraint(