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
21 changes: 21 additions & 0 deletions client-v3/e2e/tests/13-live-show.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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');
Expand All @@ -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.
Expand Down
22 changes: 22 additions & 0 deletions client-v3/e2e/tests/14-user-settings.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
58 changes: 58 additions & 0 deletions client-v3/src/components/show/live/CurrentCueFooter.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
<template>
<BRow id="current-cue-footer" class="current-cue-footer">
<BCol class="d-flex flex-wrap align-items-center gap-2">
<b>Current Cues:</b>
<BButtonGroup v-if="currentCues.length > 0" class="flex-wrap">
<BButton
v-for="cue in currentCues"
:key="cue.id"
class="cue-button"
:style="{
backgroundColor: cueBackgroundColour(cue),
color: contrastColor(cueBackgroundColour(cue)),
}"
>
{{ cueLabel(cue) }}
</BButton>
</BButtonGroup>
<span v-else class="text-muted">No cues called yet</span>
</BCol>
</BRow>
</template>

<script setup lang="ts">
import { computed } from 'vue';
import { useCueDisplay } from '@/composables/useCueDisplay';
import { useScriptStore } from '@/stores/script';
import { useShowStore } from '@/stores/show';
import type { Cue } from '@/types/api/cues';

const props = defineProps<{
currentPage: number;
currentLineOnPage: number;
}>();

const scriptStore = useScriptStore();
const showStore = useShowStore();
const { cueLabel, cueBackgroundColour, contrastColor } = useCueDisplay();

const currentCues = computed<Cue[]>(() => {
const lastPerType = scriptStore.lastCuePerTypeAt(props.currentPage, props.currentLineOnPage);
return showStore.cueTypes
.map((cueType) => lastPerType[cueType.id])
.filter((cue): cue is Cue => cue != null);
});
</script>

<style scoped>
.current-cue-footer {
border-top: 0.1rem solid #3498db;
padding-top: 0.3rem;
padding-bottom: 0.3rem;
margin: 0;
}

.cue-button {
padding: 0.2rem;
}
</style>
32 changes: 31 additions & 1 deletion client-v3/src/components/show/live/ScriptViewPane.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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': [];
}>();

Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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();
}
}
Expand Down Expand Up @@ -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<boolean> {
Expand Down Expand Up @@ -909,6 +928,7 @@ onMounted(async () => {
]);

computeContentSize();
observeFooterResize();

const loadedCompiledScript = await loadCompiledScript();

Expand Down Expand Up @@ -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) {
Expand Down
15 changes: 15 additions & 0 deletions client-v3/src/components/user/settings/UserSettingsConfig.vue
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,19 @@
/>
</BFormGroup>

<BFormGroup
label-cols="4"
label="Show current cue footer in Live view"
label-for="show-current-cue-footer-input"
>
<BFormCheckbox
id="show-current-cue-footer-input"
v-model="state.show_current_cue_footer"
name="show-current-cue-footer-input"
switch
/>
</BFormGroup>

<BFormGroup label-cols="4" label="Preferred UI Version" label-for="preferred-ui-input">
<BFormSelect
id="preferred-ui-input"
Expand Down Expand Up @@ -160,6 +173,7 @@ const defaultState = (): UserSettings => ({
character_mru_sort: false,
character_combined_dropdown: false,
preferred_ui: null,
show_current_cue_footer: true,
});

const state = ref<UserSettings>(defaultState());
Expand Down Expand Up @@ -200,6 +214,7 @@ const rules = computed(() => ({
character_mru_sort: {},
character_combined_dropdown: {},
preferred_ui: {},
show_current_cue_footer: {},
}));

const v$ = useVuelidate(rules, state);
Expand Down
99 changes: 99 additions & 0 deletions client-v3/src/stores/script.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
40 changes: 40 additions & 0 deletions client-v3/src/stores/script.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,46 @@ export const useScriptStore = defineStore('script', {
});
return { individual, groups, merged };
},
lineOrderIndex(state): Map<number, { page: number; index: number }> {
const index = new Map<number, { page: number; index: number }>();
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<number, Cue> {
const entries = this.orderedCueEntries;
return (page: number, lineIndex: number): Record<number, Cue> => {
const result: Record<number, Cue> = {};
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: {
Expand Down
Loading
Loading