Skip to content

Commit b7a810f

Browse files
committed
feat: enhance chapter marker functionality with translation support and improve chapter loading logic & subtitle feedbacks addressed
1 parent 0d6cdd9 commit b7a810f

5 files changed

Lines changed: 665 additions & 144 deletions

File tree

packages/video-player/javascript/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@ class ImageKitVideoPlayerPlugin extends Plugin {
9191
}
9292

9393
initPromises.push(
94-
initChapterMarkers(this.player, this.currentSource_, this.ikGlobalSettings_, this.ikGlobalSettings_.signerFn)
94+
initChapterMarkers(this.player, this.currentSource_, this.ikGlobalSettings_)
9595
);
9696

9797
initPromises.push(this.initRecommendationsOverlay());

packages/video-player/javascript/interfaces/TextTrack.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -25,10 +25,10 @@ export interface TextTrackOptions {
2525
*/
2626
default?: boolean;
2727
/**
28-
* Maximum number of words per subtitle line (only for transcript files)
29-
* @default 4
28+
* Maximum number of characters that can appear on a subtitle frame (only for transcript files)
29+
* @default 60
3030
*/
31-
maxWordsPerLine?: number;
31+
maxChars?: number;
3232
/**
3333
* Enable word-level highlighting in subtitles (only for transcript files)
3434
* When enabled, words are highlighted as they are spoken
@@ -306,10 +306,10 @@ export interface AutoGeneratedTextTrackOptions {
306306
*/
307307
showAutoGenerated?: boolean;
308308
/**
309-
* Maximum number of words per subtitle line
310-
* @default 4
309+
* Maximum number of characters that can appear on a subtitle frame
310+
* @default 60
311311
*/
312-
maxWordsPerLine?: number;
312+
maxChars?: number;
313313
/**
314314
* Enable word-level highlighting in subtitles
315315
* When enabled, words are highlighted as they are spoken

packages/video-player/javascript/modules/chapters/chapters.ts

Lines changed: 182 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,15 @@ import type { SourceOptions, IKPlayerOptions } from '../../interfaces';
33
import { ChapterMarker, parseChaptersFromVTT } from './chapterMarkerProgressBar';
44
import { prepareChaptersVttSrc } from '../../utils';
55

6+
interface ChapterTrackMetadata {
7+
langCode: string;
8+
chapterList: ChapterMarker[];
9+
vttUrl: string;
10+
}
11+
12+
// Map to store chapter tracks by language
13+
const chapterTracksCache = new Map<string, ChapterTrackMetadata>();
14+
615
/**
716
* Clean up existing chapter text tracks from the player.
817
*/
@@ -37,6 +46,147 @@ function cleanupChapterLabelDisplay(player: Player): void {
3746
}
3847
}
3948

49+
/**
50+
* Clean up all chapter-related components from the player.
51+
* Removes chapter text tracks, label display, and progress bar control.
52+
*/
53+
function cleanupChapters(player: Player): void {
54+
cleanupChapterTextTracks(player);
55+
cleanupChapterLabelDisplay(player);
56+
const existing = player.getChild('ChapterMarkersProgressBarControl');
57+
if (existing) {
58+
existing.dispose();
59+
}
60+
}
61+
62+
63+
/**
64+
* Load chapters for a specific language
65+
*/
66+
async function loadChaptersForLanguage(
67+
player: Player,
68+
vttUrl: string,
69+
langCode: string = 'en'
70+
): Promise<ChapterMarker[]> {
71+
// Check cache first
72+
if (chapterTracksCache.has(langCode)) {
73+
return chapterTracksCache.get(langCode)!.chapterList;
74+
}
75+
76+
try {
77+
const res = await fetch(vttUrl);
78+
if (!res.ok) {
79+
player.log.warn(`Chapter VTT fetch failed for ${langCode} with status: (${res.status})`);
80+
return [];
81+
}
82+
const data = await res.text();
83+
const chapterList = parseChaptersFromVTT(data);
84+
85+
// Cache the chapters
86+
chapterTracksCache.set(langCode, { langCode, chapterList, vttUrl });
87+
88+
return chapterList;
89+
} catch (e) {
90+
player.log.error(`Failed to fetch chapters for ${langCode}: ${e}`);
91+
return [];
92+
}
93+
}
94+
95+
/**
96+
* Switch chapters to match the active subtitle language
97+
*/
98+
async function switchChaptersLanguage(
99+
player: Player,
100+
langCode: string
101+
): Promise<void> {
102+
const metadata = chapterTracksCache.get(langCode);
103+
104+
if (!metadata) {
105+
player.log.warn(`No chapter track found for language: ${langCode}`);
106+
return;
107+
}
108+
109+
// Clean up existing chapters
110+
cleanupChapters(player);
111+
112+
// Apply new chapters
113+
applyChaptersToPlayer(player, metadata.chapterList);
114+
}
115+
116+
/**
117+
* Apply chapter list to player (extracted for reuse)
118+
*/
119+
function applyChaptersToPlayer(player: Player, chapterList: ChapterMarker[]): void {
120+
if (chapterList.length === 0) return;
121+
122+
try {
123+
const trackEl = player.addRemoteTextTrack(
124+
{
125+
kind: 'chapters',
126+
default: true,
127+
},
128+
false
129+
) as unknown as HTMLTrackElement;
130+
131+
chapterList.forEach((chapter) => {
132+
const cue = new VTTCue(chapter.startTime, chapter.endTime, chapter.label);
133+
trackEl.track.addCue(cue);
134+
});
135+
136+
setupChapterLabelDisplay(player, trackEl.track);
137+
138+
// Update the chapters button
139+
const controlBar = player.getChild('ControlBar');
140+
if (controlBar) {
141+
const chaptersButton = controlBar.getChild('ChaptersButton');
142+
if (chaptersButton && typeof (chaptersButton as any).update === 'function') {
143+
(chaptersButton as any).update();
144+
}
145+
}
146+
} catch (e) {
147+
player.log.warn(`Failed to create chapter text track: ${e}`);
148+
}
149+
150+
const existing = player.getChild('ChapterMarkersProgressBarControl');
151+
if (existing) {
152+
existing.dispose();
153+
}
154+
player.addChild('ChapterMarkersProgressBarControl', { chapters: chapterList });
155+
}
156+
157+
/**
158+
* Setup listener for subtitle track changes to switch chapters accordingly
159+
*/
160+
function setupSubtitleChapterSync(player: Player): void {
161+
const textTracks = player.textTracks();
162+
163+
textTracks.addEventListener('change', () => {
164+
let foundActiveTrack = false;
165+
166+
// Check for active subtitle track
167+
for (let i = 0; i < textTracks.length; i++) {
168+
const track = textTracks[i];
169+
170+
// Find the active subtitle track
171+
if ((track.kind === 'subtitles' || track.kind === 'captions') && track.mode === 'showing') {
172+
const langCode = track.language || 'en';
173+
174+
// Switch chapters to match this language
175+
if (chapterTracksCache.has(langCode)) {
176+
switchChaptersLanguage(player, langCode);
177+
foundActiveTrack = true;
178+
}
179+
break;
180+
}
181+
}
182+
183+
// If no subtitle track is active (user turned off captions), revert to base chapters
184+
if (!foundActiveTrack && chapterTracksCache.has('base')) {
185+
switchChaptersLanguage(player, 'base');
186+
}
187+
});
188+
}
189+
40190
/**
41191
* Setup chapter label display in the control bar.
42192
* Displays the current chapter name and updates on cuechange events.
@@ -76,23 +226,20 @@ function setupChapterLabelDisplay(player: Player, chaptersTrack: TextTrack): voi
76226
/**
77227
* Initialize chapter markers for the video player.
78228
* Supports three methods:
79-
* 1. Auto-generate: chapters: true
229+
* 1. Auto-generate: chapters: true (with optional translations)
80230
* 2. VTT URL: chapters: { url: string }
81231
* 3. Manual object: chapters: { [timeInSec]: title }
82232
*/
83233
export async function initChapterMarkers(
84234
player: Player,
85235
source: SourceOptions | SourceOptions[] | null,
86-
ikGlobalSettings: IKPlayerOptions,
87-
signerFn?: (src: string) => Promise<string>
236+
ikGlobalSettings: IKPlayerOptions
88237
): Promise<void> {
89238

90-
cleanupChapterTextTracks(player);
91-
cleanupChapterLabelDisplay(player);
92-
const existing = player.getChild('ChapterMarkersProgressBarControl');
93-
if (existing) {
94-
existing.dispose();
95-
}
239+
cleanupChapters(player);
240+
241+
// Clear cache for new source
242+
chapterTracksCache.clear();
96243

97244
if (!source) return;
98245

@@ -102,12 +249,13 @@ export async function initChapterMarkers(
102249
let chapterList: ChapterMarker[] = [];
103250

104251
if (typeof src.chapters === 'object' && 'url' in src.chapters) {
252+
// Manual VTT URL - load directly
105253
try {
106254
let vttUrl = src.chapters.url;
107255

108-
if (signerFn) {
256+
if (ikGlobalSettings.signerFn) {
109257
try {
110-
vttUrl = await signerFn(vttUrl);
258+
vttUrl = await ikGlobalSettings.signerFn(vttUrl);
111259
} catch (err) {
112260
player.log.error(`Failed to sign chapter VTT URL: ${err instanceof Error ? err.message : String(err)}`);
113261
return;
@@ -122,18 +270,19 @@ export async function initChapterMarkers(
122270
const data = await res.text();
123271
chapterList = parseChaptersFromVTT(data);
124272
} catch (e) {
125-
player.log.error(`Failed to fetch chapters VTT: ${e}`);
273+
player.log.error(`Failed to fetch chapters VTT: ${e instanceof Error ? e.message : String(e)}`);
126274
return;
127275
}
128276
} else if (typeof src.chapters === 'object') {
277+
// Manual chapter object - convert to ChapterMarker[]
129278
const entries = Object.entries(src.chapters)
130279
.map(([time, label]) => ({ startTime: Number(time), label: String(label) }))
131280
.sort((a, b) => a.startTime - b.startTime);
132281

133282
const duration = player.duration() || 0;
134283

135284
chapterList = entries.map((entry, index) => {
136-
const endTime =
285+
const endTime =
137286
index < entries.length - 1
138287
? entries[index + 1].startTime
139288
: duration || entry.startTime + 10;
@@ -144,55 +293,33 @@ export async function initChapterMarkers(
144293
};
145294
});
146295
} else if (src.chapters === true) {
296+
// Auto-generate chapters with translations support
147297
try {
148-
const chaptersVttSrc = await prepareChaptersVttSrc(src, ikGlobalSettings);
149-
const res = await fetch(chaptersVttSrc);
150-
if (!res.ok) {
151-
player.log.warn(`Default VTT fetch failed with status: (${res.status}); skipping chapters.`);
152-
return;
298+
const { baseUrl, translatedUrls } = await prepareChaptersVttSrc(src, ikGlobalSettings);
299+
300+
// Load base language chapters
301+
chapterList = await loadChaptersForLanguage(player, baseUrl, 'base');
302+
303+
// Pre-load translated chapters in background
304+
if (translatedUrls.size > 0) {
305+
for (const [langCode, url] of translatedUrls.entries()) {
306+
// Load in background without blocking
307+
loadChaptersForLanguage(player, url, langCode).catch(err => {
308+
player.log.warn(`Failed to pre-load chapters for ${langCode}:`, err);
309+
});
310+
}
311+
312+
// Setup subtitle-chapter synchronization
313+
setupSubtitleChapterSync(player);
153314
}
154-
const data = await res.text();
155-
chapterList = parseChaptersFromVTT(data);
156315
} catch (e) {
157316
player.log.error(`Failed to fetch default chapters VTT: ${e}`);
158317
return;
159318
}
160319
}
161320

162-
if (chapterList.length) {
163-
164-
try {
165-
const trackEl = player.addRemoteTextTrack(
166-
{
167-
kind: 'chapters',
168-
default: true,
169-
},
170-
false
171-
) as unknown as HTMLTrackElement;
172-
173-
chapterList.forEach((chapter) => {
174-
const cue = new VTTCue(chapter.startTime, chapter.endTime, chapter.label);
175-
trackEl.track.addCue(cue);
176-
});
177-
178-
setupChapterLabelDisplay(player, trackEl.track);
179-
180-
const controlBar = player.getChild('ControlBar');
181-
if (controlBar) {
182-
const chaptersButton = controlBar.getChild('ChaptersButton');
183-
if (chaptersButton && typeof (chaptersButton as any).update === 'function') {
184-
(chaptersButton as any).update();
185-
}
186-
}
187-
} catch (e) {
188-
player.log.warn(`Failed to create chapter text track: ${e}`);
189-
}
190-
191-
const existing = player.getChild('ChapterMarkersProgressBarControl');
192-
if (existing) {
193-
console.log('Disposing existing chapter markers progress bar control');
194-
existing.dispose();
195-
}
196-
player.addChild('ChapterMarkersProgressBarControl', { chapters: chapterList });
321+
// Apply the base/default chapters
322+
if (chapterList.length > 0) {
323+
applyChaptersToPlayer(player, chapterList);
197324
}
198325
}

0 commit comments

Comments
 (0)