Skip to content

Commit e6ef480

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 5759901 commit e6ef480

4 files changed

Lines changed: 334 additions & 14 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: 21 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,16 @@
11
import { render, screen, waitFor } from '@testing-library/vue';
22
import { createTranslator } from 'kolibri/utils/i18n';
3+
// eslint-disable-next-line import-x/named
4+
import useContentViewer, { useContentViewerMock } from 'kolibri/composables/useContentViewer';
35
import SafeHtml5RendererIndex from '../SafeHtml5RendererIndex.vue';
46

7+
// Mock kolibri to prevent initialization side effects
8+
jest.mock('kolibri', () => ({
9+
canHandleElement: jest.fn(() => false),
10+
}));
11+
12+
jest.mock('kolibri/composables/useContentViewer');
13+
514
const { articleContent$ } = createTranslator(
615
SafeHtml5RendererIndex.name,
716
SafeHtml5RendererIndex.$trs,
@@ -31,13 +40,9 @@ jest.mock('kolibri-zip', () => {
3140
}));
3241
});
3342

34-
const DUMMY_HTML5_URL = 'mock://test.html';
3543
const renderComponent = (dataOverrides = {}) => {
3644
return render(SafeHtml5RendererIndex, {
37-
data: () => ({
38-
defaultFile: { storage_url: DUMMY_HTML5_URL },
39-
...dataOverrides,
40-
}),
45+
data: () => dataOverrides,
4146
});
4247
};
4348

@@ -57,6 +62,12 @@ async function setupTableContainer(scrollWidth, clientWidth) {
5762
}
5863

5964
describe('SafeHtml5RendererIndex', () => {
65+
beforeEach(() => {
66+
useContentViewer.mockImplementation(() =>
67+
useContentViewerMock({ defaultFile: { storage_url: 'mock://test.html' } }),
68+
);
69+
});
70+
6071
describe('first render', () => {
6172
it('smoke test', async () => {
6273
renderComponent();
@@ -79,9 +90,11 @@ describe('SafeHtml5RendererIndex', () => {
7990
renderComponent();
8091
await waitFor(() => {
8192
expect(screen.getByLabelText(articleContent$())).toBeInTheDocument();
82-
expect(screen.getByText(HEADING)).toBeInTheDocument();
83-
expect(screen.getByText(TABLE_CAPTION)).toBeInTheDocument();
84-
CELLS.forEach(cell => expect(screen.getByText(cell)).toBeInTheDocument());
93+
expect(screen.getByRole('heading', { name: HEADING, level: 1 })).toBeInTheDocument();
94+
expect(screen.getByText(TABLE_CAPTION).tagName).toBe('CAPTION');
95+
CELLS.forEach(cell => {
96+
expect(screen.getByRole('cell', { name: cell })).toBeInTheDocument();
97+
});
8598
});
8699
});
87100
});

0 commit comments

Comments
 (0)