Skip to content

Commit b860025

Browse files
committed
feat: persist cine playback controls
1 parent c95fd5a commit b860025

4 files changed

Lines changed: 267 additions & 28 deletions

File tree

src/components/PlayControls.vue

Lines changed: 72 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,12 @@ import { useIntervalFn } from '@vueuse/core';
44
import { Maybe } from '@/src/types';
55
import { useSliceConfig } from '@/src/composables/useSliceConfig';
66
import { getCineImage } from '@/src/core/cine/isCineImage';
7+
import {
8+
clampCineFps,
9+
MAX_CINE_FPS,
10+
MIN_CINE_FPS,
11+
useCinePlaybackStore,
12+
} from '@/src/store/view-configs/cine-playback';
713
814
type Props = {
915
viewId: string;
@@ -15,28 +21,48 @@ const { imageId, viewId } = toRefs(props);
1521
1622
const cine = computed(() => getCineImage(imageId.value));
1723
const { slice, range } = useSliceConfig(viewId, imageId);
24+
const playbackStore = useCinePlaybackStore();
1825
19-
const playing = ref(false);
20-
const fps = ref(0);
2126
const fpsInput = ref('');
2227
23-
const MIN_FPS = 1;
24-
const MAX_FPS = 120;
28+
const playbackConfig = computed(() =>
29+
playbackStore.getConfig(
30+
viewId.value,
31+
imageId.value,
32+
cine.value?.header.frameTimeMs
33+
)
34+
);
2535
26-
function setFps(value: number) {
27-
fps.value = value;
28-
fpsInput.value = value > 0 ? String(value) : '';
36+
function updatePlayback(
37+
patch: Parameters<typeof playbackStore.updateConfig>[2]
38+
) {
39+
if (!viewId.value || !imageId.value) return;
40+
playbackStore.updateConfig(
41+
viewId.value,
42+
imageId.value,
43+
patch,
44+
cine.value?.header.frameTimeMs
45+
);
2946
}
3047
48+
const playing = computed({
49+
get: () => playbackConfig.value.playing,
50+
set: (value: boolean) => {
51+
updatePlayback({ playing: value });
52+
},
53+
});
54+
55+
const fps = computed({
56+
get: () => playbackConfig.value.fps,
57+
set: (value: number) => {
58+
updatePlayback({ fps: value });
59+
},
60+
});
61+
3162
watch(
32-
cine,
33-
(image) => {
34-
if (!image) {
35-
setFps(0);
36-
return;
37-
}
38-
const frameTime = image.header.frameTimeMs;
39-
setFps(frameTime && frameTime > 0 ? Math.round(1000 / frameTime) : 24);
63+
fps,
64+
(value) => {
65+
fpsInput.value = String(value);
4066
},
4167
{ immediate: true }
4268
);
@@ -56,24 +82,39 @@ const { pause, resume } = useIntervalFn(
5682
{ immediate: false, immediateCallback: false }
5783
);
5884
59-
watch(playing, (isPlaying) => {
60-
if (isPlaying) resume();
61-
else pause();
62-
});
85+
watch(
86+
playing,
87+
(isPlaying) => {
88+
if (isPlaying) resume();
89+
else pause();
90+
},
91+
{ immediate: true }
92+
);
6393
6494
// Pause when the cine clip changes (or is removed).
65-
watch(imageId, () => {
66-
playing.value = false;
95+
watch(imageId, (nextImageId, previousImageId) => {
96+
if (previousImageId && previousImageId !== nextImageId && viewId.value) {
97+
playbackStore.updateConfig(
98+
viewId.value,
99+
previousImageId,
100+
{
101+
playing: false,
102+
},
103+
getCineImage(previousImageId)?.header.frameTimeMs
104+
);
105+
}
106+
107+
if (nextImageId && previousImageId !== nextImageId && viewId.value) {
108+
updatePlayback({ playing: false });
109+
}
67110
});
68111
69112
function togglePlay() {
70113
playing.value = !playing.value;
71114
}
72115
73116
function clampFpsValue(value: string | number) {
74-
const parsed = Number(value);
75-
if (!Number.isFinite(parsed)) return null;
76-
return Math.min(MAX_FPS, Math.max(MIN_FPS, Math.round(parsed)));
117+
return clampCineFps(value);
77118
}
78119
79120
function clampFpsInput(event: Event) {
@@ -83,13 +124,16 @@ function clampFpsInput(event: Event) {
83124
84125
const clamped = clampFpsValue(fpsInput.value);
85126
if (clamped == null) return;
86-
setFps(clamped);
127+
fps.value = clamped;
128+
fpsInput.value = String(clamped);
87129
input.value = fpsInput.value;
88130
}
89131
90132
function commitFpsInput() {
91133
const clamped = clampFpsValue(fpsInput.value);
92-
setFps(clamped ?? (fps.value > 0 ? fps.value : MIN_FPS));
134+
const value = clamped ?? fps.value;
135+
fps.value = value;
136+
fpsInput.value = String(value);
93137
}
94138
</script>
95139

@@ -108,8 +152,8 @@ function commitFpsInput() {
108152
<input
109153
:value="fpsInput"
110154
type="number"
111-
:min="MIN_FPS"
112-
:max="MAX_FPS"
155+
:min="MIN_CINE_FPS"
156+
:max="MAX_CINE_FPS"
113157
class="fps-input"
114158
title="Frames per second"
115159
@input="clampFpsInput"
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
import { mount } from '@vue/test-utils';
2+
import { createPinia, setActivePinia } from 'pinia';
3+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
4+
import { nextTick, ref } from 'vue';
5+
import PlayControls from '@/src/components/PlayControls.vue';
6+
7+
vi.mock('@/src/core/cine/isCineImage', () => ({
8+
getCineImage: (imageId: string | null) => {
9+
const frameTimesByImageId: Record<string, number> = {
10+
'image-1': 50,
11+
'image-2': 25,
12+
};
13+
14+
return imageId
15+
? {
16+
header: {
17+
frameTimeMs: frameTimesByImageId[imageId] ?? 40,
18+
},
19+
}
20+
: null;
21+
},
22+
}));
23+
24+
vi.mock('@/src/composables/useSliceConfig', () => ({
25+
useSliceConfig: () => ({
26+
slice: ref(0),
27+
range: ref([0, 2]),
28+
}),
29+
}));
30+
31+
describe('PlayControls', () => {
32+
beforeEach(() => {
33+
setActivePinia(createPinia());
34+
vi.useFakeTimers();
35+
});
36+
37+
afterEach(() => {
38+
vi.useRealTimers();
39+
});
40+
41+
it('preserves playback state when the controls remount', async () => {
42+
const props = {
43+
viewId: 'view-1',
44+
imageId: 'image-1',
45+
};
46+
const mountControls = () =>
47+
mount(PlayControls, {
48+
props,
49+
global: {
50+
stubs: {
51+
'v-icon': true,
52+
},
53+
},
54+
});
55+
56+
const wrapper = mountControls();
57+
await wrapper.get('button').trigger('click');
58+
await wrapper.get('input').setValue('17');
59+
60+
expect(wrapper.get('button').attributes('aria-pressed')).toBe('true');
61+
expect((wrapper.get('input').element as HTMLInputElement).value).toBe('17');
62+
63+
wrapper.unmount();
64+
65+
const remounted = mountControls();
66+
await nextTick();
67+
68+
expect(remounted.get('button').attributes('aria-pressed')).toBe('true');
69+
expect((remounted.get('input').element as HTMLInputElement).value).toBe(
70+
'17'
71+
);
72+
73+
remounted.unmount();
74+
});
75+
76+
it('preserves frame-time-derived FPS defaults when switching images', async () => {
77+
const wrapper = mount(PlayControls, {
78+
props: {
79+
viewId: 'view-1',
80+
imageId: 'image-1',
81+
},
82+
global: {
83+
stubs: {
84+
'v-icon': true,
85+
},
86+
},
87+
});
88+
89+
expect((wrapper.get('input').element as HTMLInputElement).value).toBe('20');
90+
91+
await wrapper.setProps({ imageId: 'image-2' });
92+
93+
expect((wrapper.get('input').element as HTMLInputElement).value).toBe('40');
94+
95+
await wrapper.setProps({ imageId: 'image-1' });
96+
97+
expect((wrapper.get('input').element as HTMLInputElement).value).toBe('20');
98+
99+
wrapper.unmount();
100+
});
101+
});

src/store/view-configs.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { useWindowingStore } from './view-configs/windowing';
55
import useLayerColoringStore from './view-configs/layers';
66
import useViewCameraStore from './view-configs/camera';
77
import useVolumeColoringStore from './view-configs/volume-coloring';
8+
import useCinePlaybackStore from './view-configs/cine-playback';
89
import { useViewStore } from './views';
910
import { StateFile, ViewConfig } from '../io/state-file/schema';
1011

@@ -18,6 +19,7 @@ export const useViewConfigStore = defineStore('viewConfig', () => {
1819
const layerColoringStore = useLayerColoringStore();
1920
const viewCameraStore = useViewCameraStore();
2021
const volumeColoringStore = useVolumeColoringStore();
22+
const cinePlaybackStore = useCinePlaybackStore();
2123
const viewStore = useViewStore();
2224

2325
const removeView = (viewID: string) => {
@@ -26,6 +28,7 @@ export const useViewConfigStore = defineStore('viewConfig', () => {
2628
layerColoringStore.removeView(viewID);
2729
viewCameraStore.removeView(viewID);
2830
volumeColoringStore.removeView(viewID);
31+
cinePlaybackStore.removeView(viewID);
2932
};
3033

3134
const removeData = (dataID: string, viewID?: string) => {
@@ -34,6 +37,7 @@ export const useViewConfigStore = defineStore('viewConfig', () => {
3437
layerColoringStore.removeData(dataID, viewID);
3538
viewCameraStore.removeData(dataID, viewID);
3639
volumeColoringStore.removeData(dataID, viewID);
40+
cinePlaybackStore.removeData(dataID, viewID);
3741
};
3842

3943
const serialize = (stateFile: StateFile) => {
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import { defineStore } from 'pinia';
2+
import { reactive } from 'vue';
3+
import { Maybe } from '@/src/types';
4+
import { clampValue } from '@/src/utils';
5+
import {
6+
DoubleKeyRecord,
7+
deleteSecondKey,
8+
getDoubleKeyRecord,
9+
patchDoubleKeyRecord,
10+
} from '@/src/utils/doubleKeyRecord';
11+
12+
export const MIN_CINE_FPS = 1;
13+
export const MAX_CINE_FPS = 120;
14+
export const DEFAULT_CINE_FPS = 24;
15+
16+
export interface CinePlaybackConfig {
17+
playing: boolean;
18+
fps: number;
19+
}
20+
21+
export function clampCineFps(value: string | number) {
22+
const parsed = Number(value);
23+
if (!Number.isFinite(parsed)) return null;
24+
return clampValue(Math.round(parsed), MIN_CINE_FPS, MAX_CINE_FPS);
25+
}
26+
27+
export function defaultCinePlaybackConfig(
28+
frameTimeMs?: Maybe<number>
29+
): CinePlaybackConfig {
30+
const fps =
31+
frameTimeMs && frameTimeMs > 0
32+
? Math.round(1000 / frameTimeMs)
33+
: DEFAULT_CINE_FPS;
34+
35+
return {
36+
playing: false,
37+
fps: clampValue(fps, MIN_CINE_FPS, MAX_CINE_FPS),
38+
};
39+
}
40+
41+
export const useCinePlaybackStore = defineStore('cinePlayback', () => {
42+
const configs = reactive<DoubleKeyRecord<CinePlaybackConfig>>({});
43+
44+
const getConfig = (
45+
viewID: Maybe<string>,
46+
dataID: Maybe<string>,
47+
frameTimeMs?: Maybe<number>
48+
) => ({
49+
...defaultCinePlaybackConfig(frameTimeMs),
50+
...getDoubleKeyRecord(configs, viewID, dataID),
51+
});
52+
53+
const updateConfig = (
54+
viewID: string,
55+
dataID: string,
56+
patch: Partial<CinePlaybackConfig>,
57+
frameTimeMs?: Maybe<number>
58+
) => {
59+
const current = getConfig(viewID, dataID, frameTimeMs);
60+
const fps = patch.fps === undefined ? current.fps : clampCineFps(patch.fps);
61+
62+
patchDoubleKeyRecord(configs, viewID, dataID, {
63+
...current,
64+
...patch,
65+
fps: fps ?? current.fps,
66+
});
67+
};
68+
69+
const removeView = (viewID: string) => {
70+
delete configs[viewID];
71+
};
72+
73+
const removeData = (dataID: string, viewID?: string) => {
74+
if (viewID) {
75+
delete configs[viewID]?.[dataID];
76+
} else {
77+
deleteSecondKey(configs, dataID);
78+
}
79+
};
80+
81+
return {
82+
configs,
83+
getConfig,
84+
updateConfig,
85+
removeView,
86+
removeData,
87+
};
88+
});
89+
90+
export default useCinePlaybackStore;

0 commit comments

Comments
 (0)