Skip to content

Commit 7ba53c2

Browse files
committed
Add viewer ID tracking for embedded content progress aggregation
ContentViewer now generates unique viewer IDs and appends them to all interaction events, allowing parent components to track which embedded viewer emitted each event. SafeHtml5RendererIndex uses this to aggregate progress from multiple embedded viewers (e.g., videos in HTML5 content), combining scroll-based progress with embedded viewer progress using dynamic weighting.
1 parent 6411796 commit 7ba53c2

7 files changed

Lines changed: 337 additions & 20 deletions

File tree

kolibri/plugins/safe_html5_viewer/frontend/views/SafeHtml5RendererIndex.vue

Lines changed: 99 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,14 @@
1414
role="region"
1515
:aria-label="$tr('articleContent')"
1616
>
17-
<SafeHTML :html="html" />
17+
<SafeHTML
18+
:html="html"
19+
@startTracking="handleViewerStartTracking"
20+
@stopTracking="handleViewerStopTracking"
21+
@updateProgress="handleViewerUpdateProgress"
22+
@addProgress="handleViewerAddProgress"
23+
@finished="handleViewerFinished"
24+
/>
1825
</div>
1926
</div>
2027

@@ -52,12 +59,50 @@
5259
html: null,
5360
scrollBasedProgress: 0,
5461
debouncedHandleScroll: null,
62+
// Track embedded viewers for progress aggregation
63+
// Using object instead of Map for Vue 2.7 reactivity
64+
// Structure: { viewerId: { progress: number, complete: boolean } }
65+
embeddedViewers: {},
66+
// Guard to prevent emitting 'finished' multiple times
67+
hasEmittedFinished: false,
5568
};
5669
},
5770
computed: {
5871
entry() {
5972
return (this.options && this.options.entry) || 'index.html';
6073
},
74+
// Count of registered embedded viewers
75+
viewerCount() {
76+
return Object.keys(this.embeddedViewers).length;
77+
},
78+
// Aggregated progress using dynamic weighting
79+
// If no embedded viewers: progress = scrollBasedProgress
80+
// If viewers exist: progress = (scrollBasedProgress + avgViewerProgress) / 2
81+
aggregatedProgress() {
82+
if (this.viewerCount === 0) {
83+
return this.scrollBasedProgress;
84+
}
85+
86+
let totalViewerProgress = 0;
87+
for (const viewer of Object.values(this.embeddedViewers)) {
88+
totalViewerProgress += viewer.progress;
89+
}
90+
const avgViewerProgress = totalViewerProgress / this.viewerCount;
91+
92+
// Dynamic weighting: 50% scroll, 50% viewers
93+
return (this.scrollBasedProgress + avgViewerProgress) / 2;
94+
},
95+
// Whether all sources are complete
96+
allSourcesComplete() {
97+
// All viewers must be complete
98+
for (const viewer of Object.values(this.embeddedViewers)) {
99+
if (!viewer.complete) {
100+
return false;
101+
}
102+
}
103+
104+
return true;
105+
},
61106
},
62107
async created() {
63108
const storageUrl = this.defaultFile.storage_url;
@@ -100,18 +145,67 @@
100145
}
101146
});
102147
},
148+
149+
// Handle startTracking from embedded viewers
150+
handleViewerStartTracking(viewerId) {
151+
if (viewerId && !this.embeddedViewers[viewerId]) {
152+
this.$set(this.embeddedViewers, viewerId, { progress: 0, complete: false });
153+
}
154+
},
155+
156+
// Handle stopTracking from embedded viewers
157+
handleViewerStopTracking(viewerId) {
158+
if (viewerId) {
159+
this.$delete(this.embeddedViewers, viewerId);
160+
}
161+
},
162+
163+
// Handle updateProgress from embedded viewers
164+
handleViewerUpdateProgress(progress, viewerId) {
165+
if (viewerId && this.embeddedViewers[viewerId]) {
166+
this.$set(this.embeddedViewers, viewerId, {
167+
...this.embeddedViewers[viewerId],
168+
progress: Math.min(1, Math.max(0, progress)),
169+
});
170+
}
171+
},
172+
173+
// Handle addProgress from embedded viewers
174+
handleViewerAddProgress(delta, viewerId) {
175+
if (viewerId) {
176+
const viewer = this.embeddedViewers[viewerId];
177+
if (viewer) {
178+
const newProgress = Math.min(1, Math.max(0, viewer.progress + delta));
179+
this.$set(this.embeddedViewers, viewerId, {
180+
...viewer,
181+
progress: newProgress,
182+
});
183+
}
184+
}
185+
},
186+
187+
// Handle finished from embedded viewers
188+
handleViewerFinished(viewerId) {
189+
if (viewerId && this.embeddedViewers[viewerId]) {
190+
this.$set(this.embeddedViewers, viewerId, {
191+
progress: 1,
192+
complete: true,
193+
});
194+
}
195+
},
196+
103197
recordProgress() {
104198
let progress;
105199
if (this.forceDurationBasedProgress) {
106200
progress = this.durationBasedProgress;
107201
} else {
108-
// Use scroll events to track progress
109-
progress = this.scrollBasedProgress;
202+
// Use aggregated progress from scroll + embedded viewers
203+
progress = this.aggregatedProgress;
110204
}
111205
this.$emit('updateProgress', progress);
112-
113-
if (progress >= 1) {
206+
if (progress >= 1 && this.allSourcesComplete && !this.hasEmittedFinished) {
114207
this.$emit('finished');
208+
this.hasEmittedFinished = true;
115209
}
116210
this.pollProgress();
117211
},

kolibri/plugins/safe_html5_viewer/frontend/views/__tests__/SafeHtml5RendererIndex.spec.js

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,22 @@
11
import { render, screen, waitFor } from '@testing-library/vue';
22
import SafeHtml5RendererIndex from '../SafeHtml5RendererIndex.vue';
33

4+
// Mock kolibri to prevent initialization side effects
5+
jest.mock('kolibri', () => ({
6+
canHandleElement: jest.fn(() => false),
7+
}));
8+
9+
// Mock useContentViewer composable to provide the required context
10+
jest.mock('kolibri/composables/useContentViewer', () => {
11+
const { ref, computed } = require('vue');
12+
return jest.fn(() => ({
13+
defaultFile: ref({ storage_url: 'mock://test.html' }),
14+
options: ref({}),
15+
forceDurationBasedProgress: computed(() => false),
16+
durationBasedProgress: computed(() => 0),
17+
}));
18+
});
19+
420
jest.mock('kolibri-common/components/SafeHTML/style.scss', () => ({}));
521
jest.mock('kolibri-zip', () => {
622
return jest.fn().mockImplementation(() => ({
@@ -20,13 +36,9 @@ jest.mock('kolibri-zip', () => {
2036
}));
2137
});
2238

23-
const DUMMY_HTML5_URL = 'mock://test.html';
2439
const renderComponent = (dataOverrides = {}) => {
2540
return render(SafeHtml5RendererIndex, {
26-
data: () => ({
27-
defaultFile: { storage_url: DUMMY_HTML5_URL },
28-
...dataOverrides,
29-
}),
41+
data: () => dataOverrides,
3042
});
3143
};
3244

Lines changed: 210 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,210 @@
1+
import { render, screen, waitFor } from '@testing-library/vue';
2+
import SafeHtml5RendererIndex from '../SafeHtml5RendererIndex.vue';
3+
4+
let mockEmitFromSafeHTML;
5+
6+
jest.mock('kolibri', () => ({
7+
canHandleElement: jest.fn(() => false),
8+
}));
9+
10+
jest.mock('kolibri/composables/useContentViewer', () => {
11+
const { ref, computed } = require('vue');
12+
return jest.fn(() => ({
13+
defaultFile: ref({ storage_url: 'mock://test.html' }),
14+
options: ref({}),
15+
forceDurationBasedProgress: computed(() => false),
16+
durationBasedProgress: computed(() => 0),
17+
}));
18+
});
19+
20+
// Mock SafeHTML to render html content and provide controllable event emission
21+
// for testing how the parent aggregates progress from embedded viewers.
22+
jest.mock('kolibri-common/components/SafeHTML', () => ({
23+
createSafeHTML: () => ({
24+
name: 'SafeHTML',
25+
props: { html: String },
26+
created() {
27+
mockEmitFromSafeHTML = (event, ...args) => this.$emit(event, ...args);
28+
},
29+
render(h) {
30+
return h('div', { domProps: { innerHTML: this.html || '' } });
31+
},
32+
}),
33+
}));
34+
35+
jest.mock('kolibri-common/components/SafeHTML/style.scss', () => ({}));
36+
jest.mock('kolibri-zip', () => {
37+
return jest.fn().mockImplementation(() => ({
38+
file: jest.fn().mockResolvedValue({
39+
toString: () => '<h1>Mocked HTML content</h1>',
40+
}),
41+
}));
42+
});
43+
44+
const renderComponent = () => {
45+
return render(SafeHtml5RendererIndex);
46+
};
47+
48+
describe('SafeHtml5RendererIndex progress aggregation', () => {
49+
beforeEach(() => {
50+
jest.useFakeTimers();
51+
mockEmitFromSafeHTML = null;
52+
});
53+
54+
afterEach(() => {
55+
jest.useRealTimers();
56+
});
57+
58+
async function renderAndLoad() {
59+
const result = renderComponent();
60+
await waitFor(() => {
61+
expect(screen.getByLabelText('Article content')).toBeInTheDocument();
62+
});
63+
return result;
64+
}
65+
66+
function getLastEmittedProgress(emitted) {
67+
jest.advanceTimersByTime(5000);
68+
const events = emitted().updateProgress;
69+
return events[events.length - 1][0];
70+
}
71+
72+
// In jsdom, scrollHeight and clientHeight are both 0, so maxScroll = 0
73+
// and handleScroll sets scrollBasedProgress = 1 (content considered fully read).
74+
function simulateFullScroll() {
75+
const wrapper = document.querySelector('[data-testid="safe-html-wrapper"]');
76+
wrapper.dispatchEvent(new Event('scroll'));
77+
jest.advanceTimersByTime(150); // flush debounce
78+
}
79+
80+
it('reports scroll-based progress when no embedded viewers exist', async () => {
81+
const { emitted } = await renderAndLoad();
82+
expect(getLastEmittedProgress(emitted)).toBe(0);
83+
});
84+
85+
it('averages scroll and viewer progress with one viewer', async () => {
86+
const { emitted } = await renderAndLoad();
87+
mockEmitFromSafeHTML('startTracking', 'viewer-1');
88+
mockEmitFromSafeHTML('updateProgress', 0.5, 'viewer-1');
89+
// scroll=0, viewer=0.5, aggregated=(0+0.5)/2=0.25
90+
expect(getLastEmittedProgress(emitted)).toBe(0.25);
91+
});
92+
93+
it('averages scroll and average viewer progress with multiple viewers', async () => {
94+
const { emitted } = await renderAndLoad();
95+
mockEmitFromSafeHTML('startTracking', 'viewer-1');
96+
mockEmitFromSafeHTML('startTracking', 'viewer-2');
97+
mockEmitFromSafeHTML('updateProgress', 0.25, 'viewer-1');
98+
mockEmitFromSafeHTML('updateProgress', 0.75, 'viewer-2');
99+
// scroll=0, viewer avg=(0.25+0.75)/2=0.5, aggregated=(0+0.5)/2=0.25
100+
expect(getLastEmittedProgress(emitted)).toBe(0.25);
101+
});
102+
103+
it('accumulates delta progress via addProgress', async () => {
104+
const { emitted } = await renderAndLoad();
105+
mockEmitFromSafeHTML('startTracking', 'viewer-1');
106+
mockEmitFromSafeHTML('addProgress', 0.25, 'viewer-1');
107+
mockEmitFromSafeHTML('addProgress', 0.25, 'viewer-1');
108+
// viewer=0.5, scroll=0, aggregated=(0+0.5)/2=0.25
109+
expect(getLastEmittedProgress(emitted)).toBe(0.25);
110+
});
111+
112+
it('clamps negative progress to 0', async () => {
113+
const { emitted } = await renderAndLoad();
114+
mockEmitFromSafeHTML('startTracking', 'viewer-1');
115+
mockEmitFromSafeHTML('updateProgress', -0.5, 'viewer-1');
116+
// clamped to 0, aggregated=(0+0)/2=0
117+
expect(getLastEmittedProgress(emitted)).toBe(0);
118+
});
119+
120+
it('clamps progress above 1 to 1', async () => {
121+
const { emitted } = await renderAndLoad();
122+
mockEmitFromSafeHTML('startTracking', 'viewer-1');
123+
mockEmitFromSafeHTML('updateProgress', 1.5, 'viewer-1');
124+
// clamped to 1, aggregated=(0+1)/2=0.5
125+
expect(getLastEmittedProgress(emitted)).toBe(0.5);
126+
});
127+
128+
it('reverts to scroll-only progress after viewer unregisters', async () => {
129+
const { emitted } = await renderAndLoad();
130+
mockEmitFromSafeHTML('startTracking', 'viewer-1');
131+
mockEmitFromSafeHTML('updateProgress', 0.8, 'viewer-1');
132+
mockEmitFromSafeHTML('stopTracking', 'viewer-1');
133+
// no viewers left, progress=scrollBasedProgress=0
134+
expect(getLastEmittedProgress(emitted)).toBe(0);
135+
});
136+
137+
it('does not re-register an already registered viewer', async () => {
138+
const { emitted } = await renderAndLoad();
139+
mockEmitFromSafeHTML('startTracking', 'viewer-1');
140+
mockEmitFromSafeHTML('updateProgress', 0.5, 'viewer-1');
141+
// Re-registering should not reset progress
142+
mockEmitFromSafeHTML('startTracking', 'viewer-1');
143+
// Still one viewer at 0.5, aggregated=(0+0.5)/2=0.25
144+
expect(getLastEmittedProgress(emitted)).toBe(0.25);
145+
});
146+
147+
it('ignores events without a viewerId', async () => {
148+
const { emitted } = await renderAndLoad();
149+
mockEmitFromSafeHTML('startTracking', null);
150+
mockEmitFromSafeHTML('updateProgress', 0.5, null);
151+
mockEmitFromSafeHTML('addProgress', 0.5, null);
152+
mockEmitFromSafeHTML('finished', null);
153+
mockEmitFromSafeHTML('stopTracking', null);
154+
// No viewers registered, progress=scroll=0
155+
expect(getLastEmittedProgress(emitted)).toBe(0);
156+
});
157+
158+
it('ignores updates to unregistered viewers', async () => {
159+
const { emitted } = await renderAndLoad();
160+
mockEmitFromSafeHTML('updateProgress', 0.5, 'unknown');
161+
mockEmitFromSafeHTML('addProgress', 0.5, 'unknown');
162+
mockEmitFromSafeHTML('finished', 'unknown');
163+
// No viewers, progress=scroll=0
164+
expect(getLastEmittedProgress(emitted)).toBe(0);
165+
});
166+
167+
it('does not emit finished when progress is below 1', async () => {
168+
const { emitted } = await renderAndLoad();
169+
mockEmitFromSafeHTML('startTracking', 'viewer-1');
170+
mockEmitFromSafeHTML('updateProgress', 0.5, 'viewer-1');
171+
jest.advanceTimersByTime(5000);
172+
expect(emitted().finished).toBeUndefined();
173+
});
174+
175+
it('does not emit finished when viewers are incomplete', async () => {
176+
const { emitted } = await renderAndLoad();
177+
mockEmitFromSafeHTML('startTracking', 'viewer-1');
178+
mockEmitFromSafeHTML('updateProgress', 0.5, 'viewer-1');
179+
simulateFullScroll();
180+
// scroll=1, viewer=0.5, aggregated=(1+0.5)/2=0.75 and viewer not complete
181+
expect(emitted().finished).toBeUndefined();
182+
});
183+
184+
it('emits finished when scroll is complete and no embedded viewers exist', async () => {
185+
const { emitted } = await renderAndLoad();
186+
simulateFullScroll();
187+
// scroll=1, no viewers, aggregated=1, allSourcesComplete=true
188+
expect(emitted().finished).toHaveLength(1);
189+
});
190+
191+
it('emits finished when scroll is complete and all viewers are finished', async () => {
192+
const { emitted } = await renderAndLoad();
193+
mockEmitFromSafeHTML('startTracking', 'viewer-1');
194+
mockEmitFromSafeHTML('startTracking', 'viewer-2');
195+
mockEmitFromSafeHTML('finished', 'viewer-1');
196+
mockEmitFromSafeHTML('finished', 'viewer-2');
197+
simulateFullScroll();
198+
// scroll=1, both viewers complete at progress=1
199+
// aggregated=(1+1)/2=1, allSourcesComplete=true
200+
expect(emitted().finished).toHaveLength(1);
201+
});
202+
203+
it('emits finished only once across multiple recordProgress calls', async () => {
204+
const { emitted } = await renderAndLoad();
205+
simulateFullScroll(); // triggers recordProgress via handleScroll
206+
jest.advanceTimersByTime(5000); // triggers another recordProgress via poll
207+
jest.advanceTimersByTime(5000); // and another
208+
expect(emitted().finished).toHaveLength(1);
209+
});
210+
});

0 commit comments

Comments
 (0)