Skip to content

Commit 4b18e85

Browse files
committed
feat: text tracks on demand POC
1 parent cf63bf5 commit 4b18e85

File tree

11 files changed

+627
-459
lines changed

11 files changed

+627
-459
lines changed

docs/subtitles-and-captions.html

Lines changed: 3 additions & 456 deletions
Large diffs are not rendered by default.
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
/* eslint-disable */
2+
import videojs from 'video.js';
3+
4+
const Component = videojs.getComponent('Component');
5+
6+
class SearchableLanguageDropdown extends Component {
7+
constructor(player, options = {}) {
8+
super(player, options);
9+
10+
this.languages = options.languages || [];
11+
this.onSelect = options.onSelect || (() => {});
12+
this.open = false;
13+
14+
this.el().addEventListener('click', (e) => this.handleToggle(e));
15+
document.addEventListener('click', (e) => {
16+
if (!this.el().contains(e.target)) this.hideDropdown();
17+
});
18+
}
19+
20+
createEl() {
21+
return videojs.dom.createEl('div', {
22+
className: 'vjs-searchable-language-dropdown vjs-control vjs-button',
23+
innerHTML: `
24+
<button class="vjs-lang-toggle" aria-label="Select Language" title="Select Language">
25+
🌐
26+
</button>
27+
<div class="vjs-lang-popover hidden">
28+
<input type="text" placeholder="Search..." class="vjs-lang-search">
29+
<div class="vjs-lang-header">Languages</div>
30+
<ul class="vjs-lang-list"></ul>
31+
</div>
32+
`
33+
});
34+
}
35+
36+
handleToggle(e) {
37+
const isToggle = e.target.classList.contains('vjs-lang-toggle');
38+
if (isToggle) {
39+
this.open ? this.hideDropdown() : this.showDropdown();
40+
e.stopPropagation();
41+
}
42+
}
43+
44+
showDropdown() {
45+
this.open = true;
46+
const popover = this.el().querySelector('.vjs-lang-popover');
47+
popover.classList.remove('hidden');
48+
49+
const input = popover.querySelector('.vjs-lang-search');
50+
input.value = '';
51+
input.focus();
52+
input.addEventListener('input', () => {
53+
this.renderList(input.value.toLowerCase());
54+
});
55+
56+
this.renderList('');
57+
}
58+
59+
hideDropdown() {
60+
this.open = false;
61+
this.el().querySelector('.vjs-lang-popover').classList.add('hidden');
62+
}
63+
64+
renderList(query = '') {
65+
const ul = this.el().querySelector('.vjs-lang-list');
66+
ul.innerHTML = '';
67+
68+
const filtered = this.languages.filter(l =>
69+
l.label.toLowerCase().includes(query)
70+
);
71+
72+
if (filtered.length === 0) {
73+
const li = document.createElement('li');
74+
li.className = 'vjs-lang-empty';
75+
li.textContent = 'No results...';
76+
ul.appendChild(li);
77+
return;
78+
}
79+
80+
filtered.forEach(lang => {
81+
const iconMap = {
82+
idle: '',
83+
loading: '⏳',
84+
loaded: '✅',
85+
error: '❌'
86+
};
87+
const icon = iconMap[lang.status || 'idle'] || '';
88+
const li = document.createElement('li');
89+
li.className = `vjs-lang-item vjs-lang-${lang.status || 'idle'} ${lang.selected ? 'vjs-lang-selected' : ''}`;
90+
li.innerHTML = `<span>${lang.label}</span><span class="vjs-lang-icon">${icon}</span>`;
91+
li.addEventListener('click', (e) => {
92+
e.stopPropagation();
93+
this.onSelect(lang);
94+
});
95+
ul.appendChild(li);
96+
});
97+
}
98+
99+
updateLanguages(languages) {
100+
this.languages = languages;
101+
if (this.open) {
102+
const q = this.el().querySelector('.vjs-lang-search').value.toLowerCase();
103+
this.renderList(q);
104+
}
105+
}
106+
}
107+
108+
videojs.registerComponent('SearchableLanguageDropdown', SearchableLanguageDropdown);
109+
export default SearchableLanguageDropdown;
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
// Player Cloudinary button
2+
.vjs-control-bar a.vjs-control.vjs-cloudinary-button {
3+
background-image: url("../../assets/icons/cloudinary_icon_for_black_bg.svg");
4+
background-size: 25px;
5+
background-position: center;
6+
background-repeat: no-repeat;
7+
color: inherit;
8+
9+
.cld-video-player-skin-light & {
10+
background-image: url("../../assets/icons/cloudinary_icon_for_white_bg.svg");
11+
}
12+
13+
&:hover {
14+
cursor: pointer;
15+
}
16+
17+
&:last-child {
18+
margin-right: 0.4em;
19+
margin-left: 0.8em;
20+
&::before {
21+
content: '';
22+
position: absolute;
23+
left: -0.25em;
24+
top: 0.3em;
25+
bottom: 0.3em;
26+
border-left: 1px solid currentColor;
27+
opacity: 0.25;
28+
}
29+
}
30+
31+
}

src/components/index.js

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,13 @@ import JumpBackButton from './jumpButtons/jump-10-minus';
33
import LogoButton from './logoButton/logo-button';
44
import ProgressControlEventsBlocker from './progress-control-events-blocker/progress-control-events-blocker';
55
import TitleBar from './title-bar/title-bar';
6+
import DynamicTextTracksButton from './dynamicTextTracksButton/dynamic-text-tracks-button';
67

78
export {
89
JumpForwardButton,
910
JumpBackButton,
1011
LogoButton,
1112
ProgressControlEventsBlocker,
12-
TitleBar
13+
TitleBar,
14+
DynamicTextTracksButton
1315
};

src/index.all.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import cloudinary from './index.js';
1010
export * from './index.js';
1111
export * from './plugins/adaptive-streaming/adaptive-streaming.js';
1212
export * from './plugins/chapters/chapters.js';
13+
export * from './plugins/dynamic-text-tracks/index.js';
1314
export * from './plugins/colors/colors.js';
1415
export * from './plugins/ima/ima.js';
1516
export * from './plugins/playlist/playlist.js';
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
.vjs-subs-caps-button {
2+
display: none !important;
3+
}
4+
5+
6+
.vjs-searchable-language-dropdown {
7+
position: relative;
8+
display: flex;
9+
align-items: center;
10+
11+
.vjs-lang-toggle {
12+
background: transparent;
13+
border: none;
14+
color: white;
15+
font-size: 16px;
16+
width: 100%;
17+
height: 100%;
18+
cursor: pointer;
19+
display: flex;
20+
align-items: center;
21+
justify-content: center;
22+
padding: 0;
23+
}
24+
25+
.vjs-lang-popover {
26+
position: absolute;
27+
bottom: 100%;
28+
left: 0;
29+
background: #1c1c1c;
30+
border: 1px solid #333;
31+
padding: 8px;
32+
width: 200px;
33+
z-index: 9999;
34+
border-radius: 4px;
35+
margin-bottom: 8px;
36+
}
37+
38+
.hidden {
39+
display: none;
40+
}
41+
42+
.vjs-lang-search {
43+
width: 100%;
44+
padding: 5px;
45+
margin-bottom: 6px;
46+
border: 1px solid #444;
47+
background: #111;
48+
color: white;
49+
border-radius: 3px;
50+
}
51+
52+
.vjs-lang-header {
53+
font-size: 12px;
54+
color: #aaa;
55+
font-weight: bold;
56+
margin-bottom: 6px;
57+
border-bottom: 1px solid #333;
58+
padding-bottom: 2px;
59+
}
60+
61+
.vjs-lang-list {
62+
list-style: none;
63+
margin: 0;
64+
padding: 0;
65+
max-height: 160px;
66+
overflow-y: auto;
67+
68+
.vjs-lang-item {
69+
padding: 6px 5px;
70+
display: flex;
71+
justify-content: space-between;
72+
cursor: pointer;
73+
color: white;
74+
75+
&:hover {
76+
background: #333;
77+
}
78+
79+
&.vjs-lang-loaded {
80+
opacity: 0.6;
81+
}
82+
83+
&.vjs-lang-error {
84+
color: #f44;
85+
}
86+
87+
&.vjs-lang-loading {
88+
color: #ffcc00;
89+
}
90+
91+
.vjs-lang-icon {
92+
padding-left: 6px;
93+
}
94+
}
95+
96+
.vjs-lang-empty {
97+
padding: 6px;
98+
color: #888;
99+
font-style: italic;
100+
}
101+
}
102+
}
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
// import videojs from 'video.js';
2+
/* eslint-disable */
3+
4+
import languagesList from './languages.json';
5+
import './dynamic-text-tracks.scss';
6+
import { utf8ToBase64 } from '../../utils/utf8Base64';
7+
import { getCloudinaryUrlPrefix } from '../cloudinary/common';
8+
import { parseTranscript } from './parse-transcript';
9+
10+
const dynamicTextTracksPlugin = function () {
11+
this.player.dynamicTextTracks = new DynamicTextTracks(this);
12+
};
13+
14+
const getTranscriptUrl = (player, languageCode) => {
15+
const source = player.cloudinary.source();
16+
const sourcePublicId = source.publicId();
17+
const sourceDeliveryType = source.resourceConfig().type;
18+
const urlPrefix = getCloudinaryUrlPrefix(player.cloudinary.cloudinaryConfig());
19+
return `${urlPrefix}/_applet_/video_service/transcription/${sourceDeliveryType}/${languageCode}/${utf8ToBase64(sourcePublicId)}.transcript`;
20+
};
21+
22+
23+
const DynamicTextTracks = (function () {
24+
function DynamicTextTracksPlugin(player) {
25+
this.player = player;
26+
this.player.one('loadedmetadata', this.initialize.bind(this));
27+
return this;
28+
}
29+
30+
DynamicTextTracksPlugin.prototype.initialize = async function() {
31+
const player = this;
32+
33+
const languages = languagesList.map(({ code, name }) => ({
34+
code,
35+
label: name,
36+
status: 'idle',
37+
}));
38+
39+
const updateDropdown = () => {
40+
const dropdown = this.player.controlBar.getChild('SearchableLanguageDropdown');
41+
if (dropdown) {
42+
dropdown.updateLanguages(languages);
43+
}
44+
};
45+
46+
const setStatus = (code, status) => {
47+
console.log('xxx', code, status);
48+
const lang = languages.find(l => l.code === code);
49+
if (lang) lang.status = status;
50+
updateDropdown();
51+
};
52+
53+
const addTextTrack = (lang, src, transcriptData) => {
54+
setStatus(lang.code, 'loaded');
55+
const captions = parseTranscript(JSON.parse(transcriptData));
56+
57+
const tracks = this.player.textTracks();
58+
for (let i = 0; i < tracks.length; i++) {
59+
const track = tracks[i];
60+
if (track.kind === 'subtitles') {
61+
track.mode = 'disabled';
62+
}
63+
}
64+
65+
const captionsTrack = this.player.addRemoteTextTrack({
66+
kind: 'subtitles',
67+
label: lang.label,
68+
srclang: src,
69+
default: false,
70+
mode: 'showing',
71+
});
72+
73+
captions.forEach(caption => {
74+
captionsTrack.track.addCue(new VTTCue(caption.startTime, caption.endTime, caption.text));
75+
});
76+
77+
languages.forEach(l => {
78+
l.selected = l.code === lang.code;
79+
});
80+
};
81+
82+
const pollTrackUntilReady = (lang, src, attempt = 0) => {
83+
fetch(src).then(res => {
84+
if (res.status === 200) {
85+
return res.text().then((value) => addTextTrack(lang, src, value)).catch((e) => {});
86+
} else if (res.status === 202 && attempt < 15) {
87+
setTimeout(() => pollTrackUntilReady(lang, src, attempt + 1), 2000);
88+
} else {
89+
setStatus(lang.code, 'error');
90+
}
91+
}).catch(() => {
92+
setStatus(lang.code, 'error');
93+
});
94+
};
95+
96+
const selectLanguage = (lang) => {
97+
if (lang.status === 'loaded') {
98+
const tracks = player.textTracks();
99+
for (let i = 0; i < tracks.length; i++) {
100+
tracks[i].mode = (tracks[i].language === lang.code) ? 'showing' : 'disabled';
101+
}
102+
return;
103+
}
104+
105+
setStatus(lang.code, 'loading');
106+
107+
const src = getTranscriptUrl(this.player, lang.code);
108+
pollTrackUntilReady(lang, src);
109+
};
110+
111+
this.player.controlBar.addChild('SearchableLanguageDropdown', {
112+
languages,
113+
onSelect: selectLanguage,
114+
});
115+
};
116+
117+
return DynamicTextTracksPlugin;
118+
}());
119+
120+
export default dynamicTextTracksPlugin;

0 commit comments

Comments
 (0)