Skip to content

Commit fd3d2db

Browse files
Tim020claude
andauthored
Add current cue footer to live show view (#1291)
* Add current cue footer to live show view Shows the last cue passed for every cue type in a footer below the script pane, so the current cueing position stays visible without needing the script's cue column in frame. Toggleable per user from User Settings. Fixes the live script pane's JS-computed height so it accounts for the new footer instead of claiming the full viewport and pushing it off-screen. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * Port current cue footer to legacy Vue 2 client for feature parity Mirrors the client-v3 implementation: LINE_ORDER_INDEX/ORDERED_CUE_ENTRIES/ LAST_CUE_PER_TYPE_AT Vuex getters, a CurrentCueFooter.vue component reusing cueDisplayMixin, the same computeContentSize()/ResizeObserver fix in ScriptViewPane.vue, the same follower currentLineOnPage gap fix, and the toggle added to the existing User Settings form. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
1 parent b5c1cd4 commit fd3d2db

19 files changed

Lines changed: 628 additions & 2 deletions

File tree

client-v3/e2e/tests/13-live-show.spec.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,12 @@ test('leader is NOT in following mode', async () => {
7979
await expect(container).not.toHaveAttribute('data-following', 'true');
8080
});
8181

82+
test('current cue footer is visible by default', async () => {
83+
// toBeVisible() alone would pass even if the footer were pushed below the fold by
84+
// the script pane's computed height — toBeInViewport() catches that layout regression.
85+
await expect(leaderPage.locator('.current-cue-footer')).toBeInViewport();
86+
});
87+
8288
// ── Follower connects ─────────────────────────────────────────────────────
8389

8490
test('follower navigates to /live after leader', async () => {
@@ -146,6 +152,13 @@ test('can add an individual cue from the live view', async () => {
146152
});
147153
});
148154

155+
test('current cue footer shows the last cue seen for its type', async () => {
156+
await expect(leaderPage.locator('.current-cue-footer .cue-button')).toHaveCount(1, {
157+
timeout: 5_000,
158+
});
159+
await expect(leaderPage.locator('.current-cue-footer .cue-button').first()).toContainText('101');
160+
});
161+
149162
test('can add a cue group from the live view', async () => {
150163
await leaderPage.locator('.add-cue-btn').first().click();
151164
await waitForModal(leaderPage, 'Add Cue');
@@ -167,6 +180,14 @@ test('can add a cue group from the live view', async () => {
167180
await expect(leaderPage.locator('.cue-group-btn').first()).toContainText('LX 200 - LX 202');
168181
});
169182

183+
test('current cue footer still shows exactly one badge for the cue type after grouped cues are added', async () => {
184+
// Grouped cues are tracked individually — the footer collapses to the single most
185+
// recently passed cue of that type, whichever one that ends up being.
186+
await expect(leaderPage.locator('.current-cue-footer .cue-button')).toHaveCount(1, {
187+
timeout: 5_000,
188+
});
189+
});
190+
170191
test('cue group buttons in live view are non-interactive display-only buttons', async () => {
171192
// Group buttons in live view should NOT open an edit modal — editing is blocked server-side
172193
// during live sessions. Verify clicking doesn't open any modal.

client-v3/e2e/tests/14-user-settings.spec.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,28 @@ test('changing a toggle enables the Submit button', async () => {
6363
}
6464
});
6565

66+
test('current cue footer setting defaults on and persists after being toggled off', async () => {
67+
const checkbox = page.locator('#show-current-cue-footer-input');
68+
await expect(checkbox).toBeChecked();
69+
70+
await checkbox.click();
71+
await expect(checkbox).not.toBeChecked();
72+
await page.click('button:has-text("Submit")');
73+
await expect(page.locator('button:has-text("Submit")')).toBeDisabled({ timeout: 5_000 });
74+
75+
await page.reload();
76+
await waitForAppReady(page);
77+
await page.click('.nav-link:has-text("Settings"), button[role="tab"]:has-text("Settings")');
78+
await expect(page.locator('#show-current-cue-footer-input')).not.toBeChecked({
79+
timeout: 5_000,
80+
});
81+
82+
// Restore default state so it doesn't affect any later tests
83+
await page.locator('#show-current-cue-footer-input').click();
84+
await page.click('button:has-text("Submit")');
85+
await expect(page.locator('#show-current-cue-footer-input')).toBeChecked({ timeout: 5_000 });
86+
});
87+
6688
// ── Stage Direction Styles ────────────────────────────────────────────────
6789

6890
test('creates a stage direction style for override testing', async () => {
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
<template>
2+
<BRow id="current-cue-footer" class="current-cue-footer">
3+
<BCol class="d-flex flex-wrap align-items-center gap-2">
4+
<b>Current Cues:</b>
5+
<BButtonGroup v-if="currentCues.length > 0" class="flex-wrap">
6+
<BButton
7+
v-for="cue in currentCues"
8+
:key="cue.id"
9+
class="cue-button"
10+
:style="{
11+
backgroundColor: cueBackgroundColour(cue),
12+
color: contrastColor(cueBackgroundColour(cue)),
13+
}"
14+
>
15+
{{ cueLabel(cue) }}
16+
</BButton>
17+
</BButtonGroup>
18+
<span v-else class="text-muted">No cues called yet</span>
19+
</BCol>
20+
</BRow>
21+
</template>
22+
23+
<script setup lang="ts">
24+
import { computed } from 'vue';
25+
import { useCueDisplay } from '@/composables/useCueDisplay';
26+
import { useScriptStore } from '@/stores/script';
27+
import { useShowStore } from '@/stores/show';
28+
import type { Cue } from '@/types/api/cues';
29+
30+
const props = defineProps<{
31+
currentPage: number;
32+
currentLineOnPage: number;
33+
}>();
34+
35+
const scriptStore = useScriptStore();
36+
const showStore = useShowStore();
37+
const { cueLabel, cueBackgroundColour, contrastColor } = useCueDisplay();
38+
39+
const currentCues = computed<Cue[]>(() => {
40+
const lastPerType = scriptStore.lastCuePerTypeAt(props.currentPage, props.currentLineOnPage);
41+
return showStore.cueTypes
42+
.map((cueType) => lastPerType[cueType.id])
43+
.filter((cue): cue is Cue => cue != null);
44+
});
45+
</script>
46+
47+
<style scoped>
48+
.current-cue-footer {
49+
border-top: 0.1rem solid #3498db;
50+
padding-top: 0.3rem;
51+
padding-bottom: 0.3rem;
52+
margin: 0;
53+
}
54+
55+
.cue-button {
56+
padding: 0.2rem;
57+
}
58+
</style>

client-v3/src/components/show/live/ScriptViewPane.vue

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -263,10 +263,12 @@ const props = defineProps<{
263263
intervalActive: boolean;
264264
scriptMode: number;
265265
stageManagerMode: boolean;
266+
showCurrentCueFooter: boolean;
266267
}>();
267268
268269
const emit = defineEmits<{
269270
'page-change': [page: number];
271+
'current-line-change': [lineOnPage: number];
270272
'script-loaded': [];
271273
}>();
272274
@@ -479,6 +481,7 @@ function navigateTo(targetPage: number, targetLineOnPage: number, preventScroll
479481
currentPage.value = targetPage;
480482
currentLineOnPage.value = targetLineOnPage;
481483
emit('page-change', targetPage);
484+
emit('current-line-change', targetLineOnPage);
482485
483486
const targetElementId = `page_${targetPage}_line_${targetLineOnPage}`;
484487
const targetElement = document.getElementById(targetElementId);
@@ -660,7 +663,9 @@ function handleFollowDataChange(): void {
660663
scrollToElement(contextElement ?? currentLineElement);
661664
662665
currentPage.value = page;
666+
currentLineOnPage.value = line;
663667
emit('page-change', page);
668+
emit('current-line-change', line);
664669
computeScriptBoundaries();
665670
}
666671
}
@@ -713,12 +718,26 @@ function computeScriptBoundaries(): void {
713718
function computeContentSize(): void {
714719
const scriptContainer = document.getElementById('script-container');
715720
if (!scriptContainer) return;
721+
const footer = document.getElementById('current-cue-footer');
722+
const footerHeight = footer ? footer.getBoundingClientRect().height : 0;
716723
const startPos = scriptContainer.getBoundingClientRect().top;
717-
const boxHeight = document.documentElement.clientHeight - startPos;
724+
const boxHeight = document.documentElement.clientHeight - startPos - footerHeight;
718725
scriptContainer.style.height = `${boxHeight - 10}px`;
719726
computeScriptBoundaries();
720727
}
721728
729+
let footerResizeObserver: ResizeObserver | null = null;
730+
731+
function observeFooterResize(): void {
732+
footerResizeObserver?.disconnect();
733+
footerResizeObserver = null;
734+
const footer = document.getElementById('current-cue-footer');
735+
if (footer) {
736+
footerResizeObserver = new ResizeObserver(() => debounceContentSize());
737+
footerResizeObserver.observe(footer);
738+
}
739+
}
740+
722741
// --- Script loading ---
723742
724743
async function loadCompiledScript(): Promise<boolean> {
@@ -909,6 +928,7 @@ onMounted(async () => {
909928
]);
910929
911930
computeContentSize();
931+
observeFooterResize();
912932
913933
const loadedCompiledScript = await loadCompiledScript();
914934
@@ -945,7 +965,17 @@ onMounted(async () => {
945965
emit('script-loaded');
946966
});
947967
968+
watch(
969+
() => props.showCurrentCueFooter,
970+
async () => {
971+
await nextTick();
972+
computeContentSize();
973+
observeFooterResize();
974+
}
975+
);
976+
948977
onUnmounted(() => {
978+
footerResizeObserver?.disconnect();
949979
window.removeEventListener('keydown', handleKeyPress);
950980
const scriptContainer = document.getElementById('script-container');
951981
if (scriptContainer) {

client-v3/src/components/user/settings/UserSettingsConfig.vue

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,19 @@
107107
/>
108108
</BFormGroup>
109109

110+
<BFormGroup
111+
label-cols="4"
112+
label="Show current cue footer in Live view"
113+
label-for="show-current-cue-footer-input"
114+
>
115+
<BFormCheckbox
116+
id="show-current-cue-footer-input"
117+
v-model="state.show_current_cue_footer"
118+
name="show-current-cue-footer-input"
119+
switch
120+
/>
121+
</BFormGroup>
122+
110123
<BFormGroup label-cols="4" label="Preferred UI Version" label-for="preferred-ui-input">
111124
<BFormSelect
112125
id="preferred-ui-input"
@@ -160,6 +173,7 @@ const defaultState = (): UserSettings => ({
160173
character_mru_sort: false,
161174
character_combined_dropdown: false,
162175
preferred_ui: null,
176+
show_current_cue_footer: true,
163177
});
164178
165179
const state = ref<UserSettings>(defaultState());
@@ -200,6 +214,7 @@ const rules = computed(() => ({
200214
character_mru_sort: {},
201215
character_combined_dropdown: {},
202216
preferred_ui: {},
217+
show_current_cue_footer: {},
203218
}));
204219
205220
const v$ = useVuelidate(rules, state);
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import { describe, it, expect, beforeEach } from 'vitest';
2+
import { createPinia, setActivePinia } from 'pinia';
3+
import type { ScriptLine } from '@/types/api/script';
4+
import type { Cue } from '@/types/api/cues';
5+
import { useScriptStore } from './script';
6+
7+
function makeLine(id: number): ScriptLine {
8+
return {
9+
id,
10+
act_id: 1,
11+
scene_id: 1,
12+
page: 1,
13+
line_type: 1,
14+
stage_direction_style_id: null,
15+
line_parts: [],
16+
};
17+
}
18+
19+
function makeCue(id: number, cueTypeId: number, linePosition: number | null = 0): Cue {
20+
return {
21+
id,
22+
cue_type_id: cueTypeId,
23+
ident: String(id),
24+
group_id: null,
25+
sort_order: null,
26+
line_position: linePosition,
27+
};
28+
}
29+
30+
describe('script store cue tracking getters', () => {
31+
beforeEach(() => {
32+
setActivePinia(createPinia());
33+
});
34+
35+
it('builds a line order index across pages', () => {
36+
const store = useScriptStore();
37+
store.script = {
38+
'1': [makeLine(10), makeLine(11)],
39+
'2': [makeLine(20)],
40+
};
41+
42+
const index = store.lineOrderIndex;
43+
44+
expect(index.get(10)).toEqual({ page: 1, index: 0 });
45+
expect(index.get(11)).toEqual({ page: 1, index: 1 });
46+
expect(index.get(20)).toEqual({ page: 2, index: 0 });
47+
});
48+
49+
it('returns the last cue per cue type at or before the given position', () => {
50+
const store = useScriptStore();
51+
store.script = {
52+
'1': [makeLine(10), makeLine(11)],
53+
'2': [makeLine(20), makeLine(21)],
54+
};
55+
store.cues = {
56+
'10': [makeCue(1, 100), makeCue(2, 200)],
57+
'11': [makeCue(3, 100)],
58+
'20': [makeCue(4, 200)],
59+
'21': [makeCue(5, 100)],
60+
};
61+
62+
// Position at page 1, line index 1 (line 11): cue type 100 -> cue 3, type 200 -> cue 2
63+
const atLine11 = store.lastCuePerTypeAt(1, 1);
64+
expect(atLine11[100].id).toBe(3);
65+
expect(atLine11[200].id).toBe(2);
66+
67+
// Position at page 2, line index 0 (line 20): type 100 still cue 3 (last passed), type 200 -> cue 4
68+
const atLine20 = store.lastCuePerTypeAt(2, 0);
69+
expect(atLine20[100].id).toBe(3);
70+
expect(atLine20[200].id).toBe(4);
71+
72+
// Position at page 2, line index 1 (line 21): type 100 -> cue 5 (overtakes cue 3)
73+
const atLine21 = store.lastCuePerTypeAt(2, 1);
74+
expect(atLine21[100].id).toBe(5);
75+
expect(atLine21[200].id).toBe(4);
76+
});
77+
78+
it('ignores cues on lines not yet in the line order index', () => {
79+
const store = useScriptStore();
80+
store.script = { '1': [makeLine(10)] };
81+
store.cues = { '999': [makeCue(1, 100)] };
82+
83+
const result = store.lastCuePerTypeAt(1, 0);
84+
85+
expect(result).toEqual({});
86+
});
87+
88+
it('breaks ties on the same line using line_position', () => {
89+
const store = useScriptStore();
90+
store.script = { '1': [makeLine(10)] };
91+
store.cues = {
92+
'10': [makeCue(1, 100, 0), makeCue(2, 100, 5)],
93+
};
94+
95+
const result = store.lastCuePerTypeAt(1, 0);
96+
97+
expect(result[100].id).toBe(2);
98+
});
99+
});

client-v3/src/stores/script.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,46 @@ export const useScriptStore = defineStore('script', {
9393
});
9494
return { individual, groups, merged };
9595
},
96+
lineOrderIndex(state): Map<number, { page: number; index: number }> {
97+
const index = new Map<number, { page: number; index: number }>();
98+
for (const [pageStr, lines] of Object.entries(state.script)) {
99+
const page = Number(pageStr);
100+
lines.forEach((line, lineIndex) => {
101+
if (line.id != null) index.set(line.id, { page, index: lineIndex });
102+
});
103+
}
104+
return index;
105+
},
106+
orderedCueEntries(state): { cue: Cue; page: number; index: number }[] {
107+
const order = this.lineOrderIndex;
108+
const entries: { cue: Cue; page: number; index: number }[] = [];
109+
for (const [lineIdStr, cuesForLine] of Object.entries(state.cues)) {
110+
const position = order.get(Number(lineIdStr));
111+
if (!position) continue;
112+
for (const cue of cuesForLine) {
113+
entries.push({ cue, page: position.page, index: position.index });
114+
}
115+
}
116+
entries.sort((a, b) => {
117+
if (a.page !== b.page) return a.page - b.page;
118+
if (a.index !== b.index) return a.index - b.index;
119+
return (a.cue.line_position ?? 0) - (b.cue.line_position ?? 0);
120+
});
121+
return entries;
122+
},
123+
lastCuePerTypeAt(): (page: number, lineIndex: number) => Record<number, Cue> {
124+
const entries = this.orderedCueEntries;
125+
return (page: number, lineIndex: number): Record<number, Cue> => {
126+
const result: Record<number, Cue> = {};
127+
for (const entry of entries) {
128+
if (entry.page > page || (entry.page === page && entry.index > lineIndex)) break;
129+
if (entry.cue.cue_type_id != null) {
130+
result[entry.cue.cue_type_id] = entry.cue;
131+
}
132+
}
133+
return result;
134+
};
135+
},
96136
},
97137

98138
actions: {

0 commit comments

Comments
 (0)