From da4bd01812320b72f8dcb441291505d891241184 Mon Sep 17 00:00:00 2001 From: Blaine Jester Date: Wed, 17 Apr 2019 10:01:13 -0700 Subject: [PATCH 01/49] Remove yarn.lock file no longer needed --- kolibri/plugins/media_player/yarn.lock | 126 ------------------------- 1 file changed, 126 deletions(-) delete mode 100644 kolibri/plugins/media_player/yarn.lock diff --git a/kolibri/plugins/media_player/yarn.lock b/kolibri/plugins/media_player/yarn.lock deleted file mode 100644 index 06915c4f129..00000000000 --- a/kolibri/plugins/media_player/yarn.lock +++ /dev/null @@ -1,126 +0,0 @@ -# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. -# yarn lockfile v1 - - -babel-runtime@^6.9.2: - version "6.23.0" - resolved "https://registry.yarnpkg.com/babel-runtime/-/babel-runtime-6.23.0.tgz#0a9489f144de70efb3ce4300accdb329e2fc543b" - dependencies: - core-js "^2.4.0" - regenerator-runtime "^0.10.0" - -core-js@^2.4.0: - version "2.4.1" - resolved "https://registry.yarnpkg.com/core-js/-/core-js-2.4.1.tgz#4de911e667b0eae9124e34254b53aea6fc618d3e" - -dom-walk@^0.1.0: - version "0.1.1" - resolved "https://registry.yarnpkg.com/dom-walk/-/dom-walk-0.1.1.tgz#672226dc74c8f799ad35307df936aba11acd6018" - -es5-shim@^4.5.1: - version "4.5.9" - resolved "https://registry.yarnpkg.com/es5-shim/-/es5-shim-4.5.9.tgz#2a1e2b9e583ff5fed0c20a3ee2cbf3f75230a5c0" - -for-each@^0.3.2: - version "0.3.2" - resolved "https://registry.yarnpkg.com/for-each/-/for-each-0.3.2.tgz#2c40450b9348e97f281322593ba96704b9abd4d4" - dependencies: - is-function "~1.0.0" - -global@4.3.2, global@^4.3.1, global@~4.3.0: - version "4.3.2" - resolved "https://registry.yarnpkg.com/global/-/global-4.3.2.tgz#e76989268a6c74c38908b1305b10fc0e394e9d0f" - dependencies: - min-document "^2.19.0" - process "~0.5.1" - -individual@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/individual/-/individual-2.0.0.tgz#833b097dad23294e76117a98fb38e0d9ad61bb97" - -is-function@^1.0.1, is-function@~1.0.0: - version "1.0.1" - resolved "https://registry.yarnpkg.com/is-function/-/is-function-1.0.1.tgz#12cfb98b65b57dd3d193a3121f5f6e2f437602b5" - -min-document@^2.19.0: - version "2.19.0" - resolved "https://registry.yarnpkg.com/min-document/-/min-document-2.19.0.tgz#7bd282e3f5842ed295bb748cdd9f1ffa2c824685" - dependencies: - dom-walk "^0.1.0" - -parse-headers@^2.0.0: - version "2.0.1" - resolved "https://registry.yarnpkg.com/parse-headers/-/parse-headers-2.0.1.tgz#6ae83a7aa25a9d9b700acc28698cd1f1ed7e9536" - dependencies: - for-each "^0.3.2" - trim "0.0.1" - -process@~0.5.1: - version "0.5.2" - resolved "https://registry.yarnpkg.com/process/-/process-0.5.2.tgz#1638d8a8e34c2f440a91db95ab9aeb677fc185cf" - -regenerator-runtime@^0.10.0: - version "0.10.5" - resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.10.5.tgz#336c3efc1220adcedda2c9fab67b5a7955a33658" - -rust-result@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/rust-result/-/rust-result-1.0.0.tgz#34c75b2e6dc39fe5875e5bdec85b5e0f91536f72" - dependencies: - individual "^2.0.0" - -safe-json-parse@4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/safe-json-parse/-/safe-json-parse-4.0.0.tgz#7c0f578cfccd12d33a71c0e05413e2eca171eaac" - dependencies: - rust-result "^1.0.0" - -trim@0.0.1: - version "0.0.1" - resolved "https://registry.yarnpkg.com/trim/-/trim-0.0.1.tgz#5858547f6b290757ee95cccc666fb50084c460dd" - -tsml@1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/tsml/-/tsml-1.0.1.tgz#89f8218b9d9e257f47d7f6b56d01c5a4d2c68fc3" - -video.js@6.8.0: - version "6.8.0" - resolved "https://registry.yarnpkg.com/video.js/-/video.js-6.8.0.tgz#c3cff35d483595e22efc294ba2e720198151f9c9" - dependencies: - babel-runtime "^6.9.2" - global "4.3.2" - safe-json-parse "4.0.0" - tsml "1.0.1" - videojs-font "2.1.0" - videojs-ie8 "1.1.2" - videojs-vtt.js "0.12.6" - xhr "2.4.0" - -videojs-font@2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/videojs-font/-/videojs-font-2.1.0.tgz#a25930a67f6c9cfbf2bb88dacb8c6b451f093379" - -videojs-ie8@1.1.2: - version "1.1.2" - resolved "https://registry.yarnpkg.com/videojs-ie8/-/videojs-ie8-1.1.2.tgz#a23d3d8608ad7192b69c6077fc4eb848998d35d9" - dependencies: - es5-shim "^4.5.1" - -videojs-vtt.js@0.12.6: - version "0.12.6" - resolved "https://registry.yarnpkg.com/videojs-vtt.js/-/videojs-vtt.js-0.12.6.tgz#e078600bda899eaa6f9c3307134cd0c811947b8e" - dependencies: - global "^4.3.1" - -xhr@2.4.0: - version "2.4.0" - resolved "https://registry.yarnpkg.com/xhr/-/xhr-2.4.0.tgz#e16e66a45f869861eeefab416d5eff722dc40993" - dependencies: - global "~4.3.0" - is-function "^1.0.1" - parse-headers "^2.0.0" - xtend "^4.0.0" - -xtend@^4.0.0: - version "4.0.1" - resolved "https://registry.yarnpkg.com/xtend/-/xtend-4.0.1.tgz#a5c6d532be656e23db820efb943a1f04998d63af" From b1e8aa2a6002bdeddb6c92568add43e903c3b8f9 Mon Sep 17 00:00:00 2001 From: Blaine Jester Date: Thu, 25 Apr 2019 17:04:24 -0700 Subject: [PATCH 02/49] Refactor creating pattern for breaking out more functionality --- .../src/views/MediaPlayerFullscreen.vue | 90 +++++++ .../assets/src/views/MediaPlayerIndex.vue | 233 +++++++++--------- .../assets/src/views/customButtons.js | 9 +- .../media_player/assets/src/views/settings.js | 63 +++++ 4 files changed, 279 insertions(+), 116 deletions(-) create mode 100755 kolibri/plugins/media_player/assets/src/views/MediaPlayerFullscreen.vue create mode 100755 kolibri/plugins/media_player/assets/src/views/settings.js diff --git a/kolibri/plugins/media_player/assets/src/views/MediaPlayerFullscreen.vue b/kolibri/plugins/media_player/assets/src/views/MediaPlayerFullscreen.vue new file mode 100755 index 00000000000..df763a99f26 --- /dev/null +++ b/kolibri/plugins/media_player/assets/src/views/MediaPlayerFullscreen.vue @@ -0,0 +1,90 @@ + + + + + + + diff --git a/kolibri/plugins/media_player/assets/src/views/MediaPlayerIndex.vue b/kolibri/plugins/media_player/assets/src/views/MediaPlayerIndex.vue index 5cb746bb37c..f84cde0d97b 100644 --- a/kolibri/plugins/media_player/assets/src/views/MediaPlayerIndex.vue +++ b/kolibri/plugins/media_player/assets/src/views/MediaPlayerIndex.vue @@ -1,17 +1,24 @@ - - + + @@ -56,24 +63,62 @@ import vue from 'kolibri.lib.vue'; import videojs from 'video.js'; import throttle from 'lodash/throttle'; - import Lockr from 'lockr'; + import themeMixin from 'kolibri.coreVue.mixins.themeMixin'; import KCircularLoader from 'kolibri.coreVue.components.KCircularLoader'; import ResponsiveElement from 'kolibri.coreVue.mixins.responsiveElement'; import contentRendererMixin from 'kolibri.coreVue.mixins.contentRendererMixin'; - import CoreFullscreen from 'kolibri.coreVue.components.CoreFullscreen'; - import { fullscreenApiIsSupported } from 'kolibri.utils.browser'; - import { ReplayButton, ForwardButton, MimicFullscreenToggle } from './customButtons'; + + import Settings from './settings'; + import { ReplayButton, ForwardButton } from './customButtons'; + import MediaPlayerFullscreen, { MimicFullscreenToggle } from './MediaPlayerFullscreen'; + import audioIconPoster from './audio-icon-poster.svg'; const GlobalLangCode = vue.locale; + const PLAYER_CONFIG = { + fluid: false, + fill: true, + controls: true, + textTrackDisplay: true, + bigPlayButton: true, + preload: 'metadata', + playbackRates: [0.5, 1.0, 1.25, 1.5, 2.0], + controlBar: { + children: [ + { name: 'PlayToggle' }, + { name: 'ReplayButton' }, + { name: 'ForwardButton' }, + { name: 'CurrentTimeDisplay' }, + { name: 'ProgressControl' }, + { name: 'TimeDivider' }, + { name: 'DurationDisplay' }, + { + name: 'VolumePanel', + inline: false, + }, + { name: 'PlaybackRateMenuButton' }, + { name: 'CaptionsButton' }, + { name: 'MimicFullscreenToggle' }, + ], + }, + language: GlobalLangCode, + }; + + const componentsToRegister = { + MimicFullscreenToggle, + ReplayButton, + ForwardButton, + }; - const MEDIA_PLAYER_SETTINGS_KEY = 'kolibriMediaPlayerSettings'; + Object.entries(componentsToRegister).forEach(([name, component]) => + videojs.registerComponent(name, component) + ); export default { name: 'MediaPlayerIndex', - components: { KCircularLoader, CoreFullscreen }, + components: { KCircularLoader, MediaPlayerFullscreen }, mixins: [ResponsiveElement, contentRendererMixin, themeMixin], @@ -87,6 +132,7 @@ playerRate: 1.0, videoLangCode: GlobalLangCode, updateContentStateInterval: null, + isFullscreen: false, }), computed: { @@ -137,64 +183,48 @@ }, }, created() { - ReplayButton.prototype.controlText_ = this.$tr('replay'); - ForwardButton.prototype.controlText_ = this.$tr('forward'); - videojs.registerComponent('ReplayButton', ReplayButton); - videojs.registerComponent('ForwardButton', ForwardButton); - const { videoLangCode = this.videoLangCode } = this.getSavedSettings(); - this.videoLangCode = videoLangCode; + this.settings = new Settings({ + playerVolume: this.playerVolume, + playerMuted: this.playerMuted, + playerRate: this.playerRate, + videoLangCode: this.videoLangCode, + }); + + this.videoLangCode = this.settings.videoLangCode; }, mounted() { this.initPlayer(); window.addEventListener('resize', this.throttledResizePlayer); }, beforeDestroy() { + clearInterval(this.updateContentStateInterval); this.updateContentState(); + this.$emit('stopTracking'); window.removeEventListener('resize', this.throttledResizePlayer); this.player.dispose(); - clearInterval(this.updateContentStateInterval); }, methods: { isDefaultTrack(langCode) { const shortLangCode = langCode.split('-')[0]; const shortGlobalLangCode = this.videoLangCode.split('-')[0]; - if (shortLangCode === shortGlobalLangCode) { - return true; - } - return false; + + return shortLangCode === shortGlobalLangCode; }, initPlayer() { - const videojsConfig = { - fluid: true, - aspectRatio: '16:9', - controls: true, - textTrackDisplay: true, - bigPlayButton: true, - preload: 'metadata', - playbackRates: [0.5, 1.0, 1.25, 1.5, 2.0], - controlBar: { - children: [ - { name: 'playToggle' }, - { name: 'ReplayButton' }, - { name: 'ForwardButton' }, - { name: 'currentTimeDisplay' }, - { name: 'progressControl' }, - { name: 'timeDivider' }, - { name: 'durationDisplay' }, - { - name: 'volumePanel', - inline: false, - }, - { name: 'playbackRateMenuButton' }, - { name: 'captionsButton' }, - ], - }, - language: GlobalLangCode, + this.$nextTick(() => { + this.player = videojs(this.$refs.player, this.getPlayerConfig(), this.handleReadyPlayer); + this.$refs.fullscreen.setPlayer(this.player); + }); + }, + getPlayerConfig() { + const videojsConfig = Object.assign({}, PLAYER_CONFIG, { languages: { [GlobalLangCode]: { Play: this.$tr('play'), Pause: this.$tr('pause'), + Replay: this.$tr('replay'), + Forward: this.$tr('forward'), 'Current Time': this.$tr('currentTime'), 'Duration Time': this.$tr('durationTime'), Loaded: this.$tr('loaded'), @@ -223,23 +253,13 @@ ), }, }, - }; + }); if (!this.isVideo) { videojsConfig.poster = this.audioPoster; } - // Add appropriate fullscreen button - if (fullscreenApiIsSupported) { - videojsConfig.controlBar.children.push({ name: 'fullscreenToggle' }); - } else { - videojs.registerComponent('MimicFullscreenToggle', MimicFullscreenToggle); - videojsConfig.controlBar.children.push({ name: 'MimicFullscreenToggle' }); - } - - this.$nextTick(() => { - this.player = videojs(this.$refs.player, videojsConfig, this.handleReadyPlayer); - }); + return videojsConfig; }, handleReadyPlayer() { const startTime = this.savedLocation >= this.player.duration() ? 0 : this.savedLocation; @@ -261,12 +281,11 @@ this.player.on('ratechange', this.updateRate); this.player.on('texttrackchange', this.updateLang); this.player.on('ended', () => this.setPlayState(false)); - this.player.on('mimicFullscreenToggled', () => { - this.$refs.container.toggleFullscreen(); - }); + this.$watch('elementWidth', this.updatePlayerSizeClass); this.updatePlayerSizeClass(); this.resizePlayer(); + this.useSavedSettings(); this.loading = false; this.$refs.player.tabIndex = -1; @@ -274,10 +293,15 @@ this.updateContentStateInterval = setInterval(this.updateContentState, 30000); }, resizePlayer() { - const wrapperWidth = this.$refs.wrapper.clientWidth; + if (this.isFullscreen) { + this.$refs.wrapper.style.height = `100vh`; + return; + } + const aspectRatio = 16 / 9; - const adjustedHeight = wrapperWidth * (1 / aspectRatio); - this.$refs.wrapper.setAttribute('style', `height:${adjustedHeight}px`); + const adjustedHeight = this.$refs.wrapper.clientWidth * (1 / aspectRatio); + + this.$refs.wrapper.style.height = `${adjustedHeight}px`; }, throttledResizePlayer: throttle(function resizePlayer() { this.resizePlayer(); @@ -287,57 +311,37 @@ this.updateVolume(); }, 1000), - getSavedSettings() { - return Lockr.get(MEDIA_PLAYER_SETTINGS_KEY) || {}; - }, - saveSettings(updatedSettings) { - const savedSettings = this.getSavedSettings(); - Lockr.set(MEDIA_PLAYER_SETTINGS_KEY, { - ...savedSettings, - ...updatedSettings, - }); - }, updateVolume() { - this.saveSettings({ - playerVolume: this.player.volume(), - playerMuted: this.player.muted(), - }); + this.settings.playerVolume = this.player.volume(); + this.settings.playerMuted = this.player.muted(); }, updateRate() { - this.saveSettings({ - playerRate: this.player.playbackRate(), - }); + this.settings.playerRate = this.player.playbackRate(); + }, + + getTextTracks() { + return Array.from(this.player.textTracks()); }, updateLang() { - const currentTrack = Array.from(this.player.textTracks()).find( - track => track.mode === 'showing' - ); + const currentTrack = this.getTextTracks().find(track => track.mode === 'showing'); if (currentTrack) { - this.saveSettings({ - videoLangCode: currentTrack.language, - }); + this.settings.videoLangCode = currentTrack.language; } }, useSavedSettings() { - const { - savedPlayerVolume = this.playerVolume, - savedPlayerMuted = this.playerMuted, - savedPlayerRate = this.playerRate, - } = this.getSavedSettings(); - this.playerVolume = savedPlayerVolume; - this.playerMuted = savedPlayerMuted; - this.playerRate = savedPlayerRate; + this.playerVolume = this.settings.playerVolume; + this.playerMuted = this.settings.playerMuted; + this.playerRate = this.settings.playerRate; this.player.volume(this.playerVolume); this.player.muted(this.playerMuted); this.player.playbackRate(this.playerRate); }, focusOnPlayControl() { - const wrapper = this.$refs.wrapper; - wrapper.getElementsByClassName('vjs-play-control')[0].focus(); + this.$refs.wrapper.getElementsByClassName('vjs-play-control')[0].focus(); }, handleSeek() { // record progress before updating the times, @@ -399,6 +403,7 @@ this.player.addClass('player-tiny'); } }, + updateContentState() { const currentLocation = this.player.currentTime(); let contentState; @@ -454,10 +459,13 @@ @import '~kolibri.styles.definitions'; .wrapper { - width: 854px; max-width: 100%; - height: 480px; - max-height: 480px; + max-height: 562px; + } + + .normalize-fullscreen .wrapper, + .mimic-fullscreen .wrapper { + max-height: none; } .fill-space { @@ -466,6 +474,11 @@ height: 100%; } + .loading-space { + box-sizing: border-box; + padding-top: calc(100% * 9 / 16); + } + .loader { position: absolute; top: 50%; diff --git a/kolibri/plugins/media_player/assets/src/views/customButtons.js b/kolibri/plugins/media_player/assets/src/views/customButtons.js index f8de3d4bfba..bead6b81813 100644 --- a/kolibri/plugins/media_player/assets/src/views/customButtons.js +++ b/kolibri/plugins/media_player/assets/src/views/customButtons.js @@ -1,7 +1,6 @@ import videojs from 'video.js'; const videojsButton = videojs.getComponent('Button'); -const videojsFullscreenToggle = videojs.getComponent('FullscreenToggle'); export class ReplayButton extends videojsButton { buildCSSClass() { @@ -13,6 +12,8 @@ export class ReplayButton extends videojsButton { } } +ReplayButton.prototype.controlText_ = 'Replay'; + export class ForwardButton extends videojsButton { buildCSSClass() { return `vjs-icon-forward_10 ${super.buildCSSClass()}`; @@ -23,8 +24,4 @@ export class ForwardButton extends videojsButton { } } -export class MimicFullscreenToggle extends videojsFullscreenToggle { - handleClick() { - this.player().trigger('mimicFullscreenToggled'); - } -} +ForwardButton.prototype.controlText_ = 'Forward'; diff --git a/kolibri/plugins/media_player/assets/src/views/settings.js b/kolibri/plugins/media_player/assets/src/views/settings.js new file mode 100755 index 00000000000..fb9fdfd4d3c --- /dev/null +++ b/kolibri/plugins/media_player/assets/src/views/settings.js @@ -0,0 +1,63 @@ +import Lockr from 'lockr'; + +const MEDIA_PLAYER_SETTINGS_KEY = 'kolibriMediaPlayerSettings'; + +class Settings { + constructor(defaults = {}) { + this._defaults = defaults; + } + + set playerMuted(playerMuted) { + return this.save({ playerMuted }); + } + + get playerMuted() { + return this.get().playerMuted; + } + + set playerRate(playerRate) { + return this.save({ playerRate }); + } + + get playerRate() { + return this.get().playerRate; + } + + set playerVolume(playerVolume) { + return this.save({ playerVolume }); + } + + get playerVolume() { + return this.get().playerVolume; + } + + set showTranscript(showTranscript) { + return this.save({ showTranscript }); + } + + get showTranscript() { + return this.get().showTranscript; + } + + set videoLangCode(videoLangCode) { + return this.save({ videoLangCode }); + } + + get videoLangCode() { + return this.get().videoLangCode; + } + + get() { + return Object.assign({}, this._defaults, Lockr.get(MEDIA_PLAYER_SETTINGS_KEY) || {}); + } + + save(updated) { + const saved = this.get(); + Lockr.set(MEDIA_PLAYER_SETTINGS_KEY, { + ...saved, + ...updated, + }); + } +} + +export default Settings; From 11b6d95b54035c23f1c5700521caf3114fe459ba Mon Sep 17 00:00:00 2001 From: Blaine Jester Date: Fri, 3 May 2019 09:35:49 -0700 Subject: [PATCH 03/49] Track handling and cue display --- kolibri/core/assets/src/utils/i18n.js | 4 +- .../src/views/MediaPlayerFullscreen.vue | 90 --------- .../src/views/MediaPlayerFullscreen/index.vue | 50 +++++ .../mimicFullscreenToggle.js | 42 +++++ .../assets/src/views/MediaPlayerIndex.vue | 72 ++++++- .../MediaPlayerTranscript/TranscriptCue.vue | 138 ++++++++++++++ .../src/views/MediaPlayerTranscript/index.vue | 177 ++++++++++++++++++ .../MediaPlayerTranscript/trackHandler.js | 83 ++++++++ .../MediaPlayerTranscript/transcript-icon.svg | 1 + .../MediaPlayerTranscript/transcriptButton.js | 116 ++++++++++++ .../transcriptMenuItem.js | 52 +++++ .../transcriptOffMenuItem.js | 40 ++++ .../media_player/assets/src/views/settings.js | 16 +- 13 files changed, 780 insertions(+), 101 deletions(-) delete mode 100755 kolibri/plugins/media_player/assets/src/views/MediaPlayerFullscreen.vue create mode 100755 kolibri/plugins/media_player/assets/src/views/MediaPlayerFullscreen/index.vue create mode 100644 kolibri/plugins/media_player/assets/src/views/MediaPlayerFullscreen/mimicFullscreenToggle.js create mode 100755 kolibri/plugins/media_player/assets/src/views/MediaPlayerTranscript/TranscriptCue.vue create mode 100755 kolibri/plugins/media_player/assets/src/views/MediaPlayerTranscript/index.vue create mode 100644 kolibri/plugins/media_player/assets/src/views/MediaPlayerTranscript/trackHandler.js create mode 100644 kolibri/plugins/media_player/assets/src/views/MediaPlayerTranscript/transcript-icon.svg create mode 100644 kolibri/plugins/media_player/assets/src/views/MediaPlayerTranscript/transcriptButton.js create mode 100644 kolibri/plugins/media_player/assets/src/views/MediaPlayerTranscript/transcriptMenuItem.js create mode 100644 kolibri/plugins/media_player/assets/src/views/MediaPlayerTranscript/transcriptOffMenuItem.js diff --git a/kolibri/core/assets/src/utils/i18n.js b/kolibri/core/assets/src/utils/i18n.js index 9eda2116c54..0784e4fd4c4 100644 --- a/kolibri/core/assets/src/utils/i18n.js +++ b/kolibri/core/assets/src/utils/i18n.js @@ -95,9 +95,7 @@ const languageDensityMapping = { zh: languageDensities.dense, }; -function languageIdToCode(id) { - return id.split('-')[0].toLowerCase(); -} +export const languageIdToCode = id => id.split('-')[0].toLowerCase(); function setLanguageDensity(id) { const langCode = languageIdToCode(id); diff --git a/kolibri/plugins/media_player/assets/src/views/MediaPlayerFullscreen.vue b/kolibri/plugins/media_player/assets/src/views/MediaPlayerFullscreen.vue deleted file mode 100755 index df763a99f26..00000000000 --- a/kolibri/plugins/media_player/assets/src/views/MediaPlayerFullscreen.vue +++ /dev/null @@ -1,90 +0,0 @@ - - - - - - - diff --git a/kolibri/plugins/media_player/assets/src/views/MediaPlayerFullscreen/index.vue b/kolibri/plugins/media_player/assets/src/views/MediaPlayerFullscreen/index.vue new file mode 100755 index 00000000000..9edf87e2bd5 --- /dev/null +++ b/kolibri/plugins/media_player/assets/src/views/MediaPlayerFullscreen/index.vue @@ -0,0 +1,50 @@ + + + + + + + diff --git a/kolibri/plugins/media_player/assets/src/views/MediaPlayerFullscreen/mimicFullscreenToggle.js b/kolibri/plugins/media_player/assets/src/views/MediaPlayerFullscreen/mimicFullscreenToggle.js new file mode 100644 index 00000000000..ec419490bbf --- /dev/null +++ b/kolibri/plugins/media_player/assets/src/views/MediaPlayerFullscreen/mimicFullscreenToggle.js @@ -0,0 +1,42 @@ +import videojs from 'video.js'; + +const Button = videojs.getComponent('Button'); +const FullscreenToggle = videojs.getComponent('FullscreenToggle'); + +class MimicFullscreenToggle extends FullscreenToggle { + constructor(player, options) { + super(player, options); + + this.handleChangeFullscreen(false); + } + + buildCSSClass() { + return `vjs-mimic-fullscreen-control ${Button.prototype.buildCSSClass.call(this)}`; + } + + handleClick() { + this.trigger('changeFullscreen'); + } + + handleChangeFullscreen(isFullscreen) { + let el = this.$('.vjs-icon-placeholder'), + addClass = MimicFullscreenToggle.INACTIVE_CLASS, + removeClass = MimicFullscreenToggle.ACTIVE_CLASS, + controlText = 'Fullscreen'; + + if (isFullscreen) { + addClass = MimicFullscreenToggle.ACTIVE_CLASS; + removeClass = MimicFullscreenToggle.INACTIVE_CLASS; + controlText = 'Non-Fullscreen'; + } + + videojs.dom.addClass(el, addClass); + videojs.dom.removeClass(el, removeClass); + this.controlText(controlText); + } +} + +MimicFullscreenToggle.ACTIVE_CLASS = 'vjs-icon-fullscreen-exit'; +MimicFullscreenToggle.INACTIVE_CLASS = 'vjs-icon-fullscreen-enter'; + +export default MimicFullscreenToggle; diff --git a/kolibri/plugins/media_player/assets/src/views/MediaPlayerIndex.vue b/kolibri/plugins/media_player/assets/src/views/MediaPlayerIndex.vue index f84cde0d97b..8f91a3c19ad 100644 --- a/kolibri/plugins/media_player/assets/src/views/MediaPlayerIndex.vue +++ b/kolibri/plugins/media_player/assets/src/views/MediaPlayerIndex.vue @@ -9,6 +9,7 @@ ref="wrapper" :class="[ 'wrapper', + { 'transcript-visible': isShowingTranscript }, $computedClass(progressStyle) ]" > @@ -40,6 +41,13 @@ :label="track.lang.lang_name" :default="isDefaultTrack(track.lang.id)" > + @@ -52,6 +60,12 @@ > + + @@ -64,6 +78,7 @@ import videojs from 'video.js'; import throttle from 'lodash/throttle'; + import { languageIdToCode } from 'kolibri.utils.i18n'; import themeMixin from 'kolibri.coreVue.mixins.themeMixin'; import KCircularLoader from 'kolibri.coreVue.components.KCircularLoader'; import ResponsiveElement from 'kolibri.coreVue.mixins.responsiveElement'; @@ -71,7 +86,10 @@ import Settings from './settings'; import { ReplayButton, ForwardButton } from './customButtons'; - import MediaPlayerFullscreen, { MimicFullscreenToggle } from './MediaPlayerFullscreen'; + import MediaPlayerFullscreen from './MediaPlayerFullscreen'; + import MimicFullscreenToggle from './MediaPlayerFullscreen/mimicFullscreenToggle'; + import MediaPlayerTranscript from './MediaPlayerTranscript'; + import TranscriptButton from './MediaPlayerTranscript/transcriptButton'; import audioIconPoster from './audio-icon-poster.svg'; @@ -100,6 +118,7 @@ { name: 'PlaybackRateMenuButton' }, { name: 'CaptionsButton' }, { name: 'MimicFullscreenToggle' }, + { name: 'TranscriptButton' }, ], }, language: GlobalLangCode, @@ -109,6 +128,7 @@ MimicFullscreenToggle, ReplayButton, ForwardButton, + TranscriptButton, }; Object.entries(componentsToRegister).forEach(([name, component]) => @@ -118,7 +138,7 @@ export default { name: 'MediaPlayerIndex', - components: { KCircularLoader, MediaPlayerFullscreen }, + components: { KCircularLoader, MediaPlayerFullscreen, MediaPlayerTranscript }, mixins: [ResponsiveElement, contentRendererMixin, themeMixin], @@ -130,9 +150,11 @@ playerVolume: 1.0, playerMuted: false, playerRate: 1.0, + defaultLangCode: GlobalLangCode, videoLangCode: GlobalLangCode, updateContentStateInterval: null, isFullscreen: false, + isShowingTranscript: false, }), computed: { @@ -206,8 +228,8 @@ }, methods: { isDefaultTrack(langCode) { - const shortLangCode = langCode.split('-')[0]; - const shortGlobalLangCode = this.videoLangCode.split('-')[0]; + const shortLangCode = languageIdToCode(langCode); + const shortGlobalLangCode = languageIdToCode(this.videoLangCode); return shortLangCode === shortGlobalLangCode; }, @@ -215,6 +237,7 @@ this.$nextTick(() => { this.player = videojs(this.$refs.player, this.getPlayerConfig(), this.handleReadyPlayer); this.$refs.fullscreen.setPlayer(this.player); + this.$refs.transcript.setPlayer(this.player); }); }, getPlayerConfig() { @@ -237,6 +260,8 @@ 'Playback Rate': this.$tr('playbackRate'), Captions: this.$tr('captions'), 'captions off': this.$tr('captionsOff'), + Transcript: this.$tr('transcript'), + 'Transcript off': this.$tr('transcriptOff'), 'Volume Level': this.$tr('volumeLevel'), 'A network error caused the media download to fail part-way.': this.$tr( 'networkError' @@ -436,6 +461,8 @@ playbackRate: 'Playback rate', captions: 'Captions', captionsOff: 'Captions off', + transcript: 'Transcript', + transcriptOff: 'Transcript off', volumeLevel: 'Volume level', networkError: 'A network error caused the media download to fail part-way', formatError: @@ -486,6 +513,16 @@ transform: translate(-50%, -50%); } + .media-player-transcript { + position: absolute; + top: 0; + right: 0; + bottom: 0; + z-index: 0; + width: 33.333%; + transition: width $core-time ease; + } + /***** PLAYER OVERRIDES *****/ /* !!rtl:begin:ignore */ @@ -499,6 +536,15 @@ $video-player-font-size: 12px; + .video-js.vjs-fill { + z-index: 1; + transition: width $core-time ease; + } + + .transcript-visible > .video-js.vjs-fill { + width: 66.666%; + } + /* Hide control bar when playing & inactive */ /deep/ .vjs-has-started.vjs-playing.vjs-user-inactive { .vjs-control-bar { @@ -610,6 +656,24 @@ margin-left: auto; } + /* Transcript button */ + .vjs-button-transcript img { + max-width: 20px; + } + + .vjs-transcript-visible > .vjs-transcript { + display: block; + } + + .vjs-transcript-visible > .vjs-tech, + .vjs-transcript-visible > .vjs-modal-dialog, + .vjs-transcript-visible > .vjs-text-track-display, + .vjs-transcript-visible > .vjs-text-track-settings, + .vjs-transcript-visible > .vjs-control-bar { + right: auto; + width: calc(100% - 330px); + } + /* Menus */ .vjs-menu { li { diff --git a/kolibri/plugins/media_player/assets/src/views/MediaPlayerTranscript/TranscriptCue.vue b/kolibri/plugins/media_player/assets/src/views/MediaPlayerTranscript/TranscriptCue.vue new file mode 100755 index 00000000000..a1b7d99daa3 --- /dev/null +++ b/kolibri/plugins/media_player/assets/src/views/MediaPlayerTranscript/TranscriptCue.vue @@ -0,0 +1,138 @@ + + + + + + + diff --git a/kolibri/plugins/media_player/assets/src/views/MediaPlayerTranscript/index.vue b/kolibri/plugins/media_player/assets/src/views/MediaPlayerTranscript/index.vue new file mode 100755 index 00000000000..46cfd6e62e9 --- /dev/null +++ b/kolibri/plugins/media_player/assets/src/views/MediaPlayerTranscript/index.vue @@ -0,0 +1,177 @@ + + + + + + + diff --git a/kolibri/plugins/media_player/assets/src/views/MediaPlayerTranscript/trackHandler.js b/kolibri/plugins/media_player/assets/src/views/MediaPlayerTranscript/trackHandler.js new file mode 100644 index 00000000000..bc1e482fabf --- /dev/null +++ b/kolibri/plugins/media_player/assets/src/views/MediaPlayerTranscript/trackHandler.js @@ -0,0 +1,83 @@ +import EventEmitter from 'events'; + +// Array.from doesn't work with DOM track list objects +const toArray = thing => Array.prototype.slice.call(thing, 0); + +class TrackHandler extends EventEmitter { + /** + * @param {TextTrack} track + */ + constructor(track) { + super(); + + this._track = track; + this.activate(); + this._cues = this.processCues(track.cues || []); + this._activeCues = toArray(track.activeCues || []); + this._cuechange = () => this.handleCueChange(); + this._addcue = cue => this.handleAddCue(cue); + + track.addEventListener('cuechange', this._cuechange); + track.addEventListener('addcue', this._addcue); + } + + /** + * Ensure track mode is `hidden`, which triggers cue events + */ + activate() { + this._track.mode = 'hidden'; + } + + /** + * @param {VTTCue} cue + */ + handleAddCue(cue) { + if (this._track.cues && this._track.cues.length !== this._cues.length) { + this._cues = this.processCues(this._track.cues); + } + + this._cues.concat(this.processCues([cue])); + this.emit('addcue'); + } + + handleCueChange() { + this._activeCues = toArray(this._track.activeCues || []); + this.emit('cuechange'); + } + + /** + * @param {TextTrackCueList|VTTCue[]} cues + * @returns {VTTCue[]} + */ + processCues(cues) { + return toArray(cues).map((cue, i) => { + cue.id = `${this._track.id}-cue-${i}`; + return cue; + }); + } + + /** + * @returns {VTTCue[]} + */ + getCues() { + return this._cues; + } + + /** + * @returns {string[]} + */ + getActiveCueIds() { + return this._activeCues.map(cue => cue.id); + } + + /** + * Change mode and remove event listeners + */ + deactivate() { + this._track.mode = 'disabled'; + this._track.removeEventListener('cuechange', this._cuechange); + this._track.removeEventListener('addcue', this._addcue); + } +} + +export default TrackHandler; diff --git a/kolibri/plugins/media_player/assets/src/views/MediaPlayerTranscript/transcript-icon.svg b/kolibri/plugins/media_player/assets/src/views/MediaPlayerTranscript/transcript-icon.svg new file mode 100644 index 00000000000..7f4c8876b61 --- /dev/null +++ b/kolibri/plugins/media_player/assets/src/views/MediaPlayerTranscript/transcript-icon.svg @@ -0,0 +1 @@ + diff --git a/kolibri/plugins/media_player/assets/src/views/MediaPlayerTranscript/transcriptButton.js b/kolibri/plugins/media_player/assets/src/views/MediaPlayerTranscript/transcriptButton.js new file mode 100644 index 00000000000..b2eb3f668fa --- /dev/null +++ b/kolibri/plugins/media_player/assets/src/views/MediaPlayerTranscript/transcriptButton.js @@ -0,0 +1,116 @@ +import videojs from 'video.js'; +import TranscriptMenuItem from './transcriptMenuItem'; +import TranscriptOffMenuItem from './transcriptOffMenuItem'; +import transcriptIcon from './transcript-icon.svg'; + +const TextTrackButton = videojs.getComponent('TextTrackButton'); +const OffTextTrackMenuItem = videojs.getComponent('OffTextTrackMenuItem'); + +/** + * The Component for the Button that will open the Transcript + */ +class TranscriptButton extends TextTrackButton { + /** + * @param player + * @param {Object} options + */ + constructor(player, options) { + super(player, options); + + const iconHolder = this.$('.vjs-icon-placeholder'); + const icon = videojs.dom.createEl('img', { + src: transcriptIcon, + alt: this.controlText_, + }); + videojs.dom.prependTo(icon, iconHolder); + } + + /** + * @returns {string} + */ + buildCSSClass() { + return `vjs-transcript-button ${super.buildCSSClass()}`; + } + + /** + * @returns {string} + */ + buildWrapperCSSClass() { + return `vjs-transcript-button ${super.buildWrapperCSSClass()}`; + } + + handleClick() { + this.trigger('toggleTranscript'); + } + + /** + * @returns {TranscriptMenuItem[]} + */ + createItems() { + return super.createItems([], TranscriptMenuItem).map(item => { + // Replace `OffTextTrackMenuItem` which messes with our handling + if (item instanceof OffTextTrackMenuItem) { + item = new TranscriptOffMenuItem(this.player(), { + kind: this.kind_, + label: `${this.label_} off`, + }); + item.selected(true); + } else { + item.selected(false); + } + + item.on('activate', () => { + this.items.forEach(otherItem => { + otherItem.selected(item.id_ === otherItem.id_); + }); + this.trigger('trackChange'); + }); + return item; + }); + } + + /** + * @returns {TextTrack[]} + */ + getTracks() { + return this.items + .map(item => { + if (item instanceof TranscriptOffMenuItem) { + return null; + } + + return item.track; + }) + .filter(Boolean); + } + + /** + * @returns {TranscriptMenuItem|null} + */ + getActiveItem() { + return this.items.find(item => item.isActive()); + } + + /** + * @param {TextTrack} track + */ + selectTrack(track) { + this.items.forEach(item => { + item.selected(item.track && item.track.id === track.id); + }); + } + + /** + * @returns {TextTrack|null} + */ + getActiveTrack() { + const item = this.getActiveItem(); + return item && !(item instanceof TranscriptOffMenuItem) ? item.track : null; + } +} + +TranscriptButton.prototype.kind_ = 'metadata'; +TranscriptButton.prototype.controlText_ = 'Transcript'; +TranscriptButton.prototype.label_ = 'Transcript'; + +export default TranscriptButton; diff --git a/kolibri/plugins/media_player/assets/src/views/MediaPlayerTranscript/transcriptMenuItem.js b/kolibri/plugins/media_player/assets/src/views/MediaPlayerTranscript/transcriptMenuItem.js new file mode 100644 index 00000000000..9995c05161a --- /dev/null +++ b/kolibri/plugins/media_player/assets/src/views/MediaPlayerTranscript/transcriptMenuItem.js @@ -0,0 +1,52 @@ +import videojs from 'video.js'; + +const MenuItem = videojs.getComponent('MenuItem'); + +/** + * This videojs Component outputs the individual languages available to the Transcript. A good + * portion of this was taken from videojs.TextTrackMenuItem since it does what we need, with + * the exception that we don't want this to sync any changes or listen to any changes to the + * actual video text track + */ +class TranscriptMenuItem extends MenuItem { + constructor(player, options) { + const track = options.track; + + // Modify options for parent MenuItem class's init. + options.label = track.label || track.language || 'Unknown'; + options.selected = track.mode === 'hidden'; + + super(player, options); + + this.track = track; + } + + activate() { + this.trigger('activate'); + this.selected(true); + } + + handleClick() { + this.activate(); + } + + deactivate() { + this.selected(false); + } + + /** + * @returns {Boolean} + */ + isActive() { + return this.isSelected_; + } + + dispose() { + // remove reference to track object on dispose + this.track = null; + + super.dispose(); + } +} + +export default TranscriptMenuItem; diff --git a/kolibri/plugins/media_player/assets/src/views/MediaPlayerTranscript/transcriptOffMenuItem.js b/kolibri/plugins/media_player/assets/src/views/MediaPlayerTranscript/transcriptOffMenuItem.js new file mode 100644 index 00000000000..320def16f00 --- /dev/null +++ b/kolibri/plugins/media_player/assets/src/views/MediaPlayerTranscript/transcriptOffMenuItem.js @@ -0,0 +1,40 @@ +import TranscriptMenuItem from './transcriptMenuItem'; + +class TranscriptOffMenuItem extends TranscriptMenuItem { + /** + * Mostly taken from OffTextTrackMenuItem from video.js + * + * @param player + * @param {Object} [options] + */ + constructor(player, options) { + // Create pseudo track info + // Requires options['kind'] + options.track = { + player, + kind: options.kind, + kinds: options.kinds, + default: false, + mode: 'disabled', + }; + + if (!options.kinds) { + options.kinds = [options.kind]; + } + + if (options.label) { + options.track.label = options.label; + } else { + options.track.label = options.kinds.join(' and ') + ' off'; + } + + // MenuItem is selectable + options.selectable = true; + // MenuItem is NOT multiSelectable (i.e. only one can be marked "selected" at a time) + options.multiSelectable = false; + + super(player, options); + } +} + +export default TranscriptOffMenuItem; diff --git a/kolibri/plugins/media_player/assets/src/views/settings.js b/kolibri/plugins/media_player/assets/src/views/settings.js index fb9fdfd4d3c..33414d7e9bf 100755 --- a/kolibri/plugins/media_player/assets/src/views/settings.js +++ b/kolibri/plugins/media_player/assets/src/views/settings.js @@ -31,12 +31,20 @@ class Settings { return this.get().playerVolume; } - set showTranscript(showTranscript) { - return this.save({ showTranscript }); + set transcriptShowing(transcriptShowing) { + return this.save({ transcriptShowing }); } - get showTranscript() { - return this.get().showTranscript; + get transcriptShowing() { + return this.get().transcriptShowing; + } + + set transcriptLangCode(transcriptLangCode) { + return this.save({ transcriptLangCode }); + } + + get transcriptLangCode() { + return this.get().transcriptLangCode; } set videoLangCode(videoLangCode) { From 10745be3c019c7383117da27dac987fc9677d042 Mon Sep 17 00:00:00 2001 From: Blaine Jester Date: Fri, 3 May 2019 09:39:30 -0700 Subject: [PATCH 04/49] Fix css import --- .../assets/src/views/MediaPlayerFullscreen/index.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/kolibri/plugins/media_player/assets/src/views/MediaPlayerFullscreen/index.vue b/kolibri/plugins/media_player/assets/src/views/MediaPlayerFullscreen/index.vue index 9edf87e2bd5..bbcb415bb63 100755 --- a/kolibri/plugins/media_player/assets/src/views/MediaPlayerFullscreen/index.vue +++ b/kolibri/plugins/media_player/assets/src/views/MediaPlayerFullscreen/index.vue @@ -45,6 +45,6 @@ From 79deecef6a2e77baf8589b90996c3d0109a32474 Mon Sep 17 00:00:00 2001 From: Blaine Jester Date: Wed, 15 May 2019 11:34:18 -0700 Subject: [PATCH 05/49] Add scrolling and responsive positioning of transcript --- .../assets/src/mixins/responsive-window.js | 8 +++ .../assets/src/views/MediaPlayerIndex.vue | 30 ++++++++-- .../MediaPlayerTranscript/TranscriptCue.vue | 14 +++++ .../src/views/MediaPlayerTranscript/index.vue | 60 ++++++++++++++++++- .../MediaPlayerTranscript/trackHandler.js | 4 +- 5 files changed, 108 insertions(+), 8 deletions(-) diff --git a/kolibri/core/assets/src/mixins/responsive-window.js b/kolibri/core/assets/src/mixins/responsive-window.js index 95a5e9a724e..c172b44f9e4 100644 --- a/kolibri/core/assets/src/mixins/responsive-window.js +++ b/kolibri/core/assets/src/mixins/responsive-window.js @@ -125,15 +125,19 @@ export default { windowBreakpoint: undefined, windowGutter: 16, windowIsShort: false, + windowIsPortrait: false, + windowIsLandscape: false, }; }, watch: { windowWidth() { this._updateBreakpoint(); this._updateGutter(); + this._updateOrientation(); }, windowHeight() { this._updateGutter(); + this._updateOrientation(); this.windowIsShort = this.windowHeight < 600; }, }, @@ -196,6 +200,10 @@ export default { this.windowGutter = 24; } }, + _updateOrientation() { + this.windowIsPortrait = this.windowWidth < this.windowHeight; + this.windowIsLandscape = !this.windowIsPortrait; + }, }, mounted() { addWindowListener(this._updateWindow); diff --git a/kolibri/plugins/media_player/assets/src/views/MediaPlayerIndex.vue b/kolibri/plugins/media_player/assets/src/views/MediaPlayerIndex.vue index 8f91a3c19ad..12ba8b849dd 100644 --- a/kolibri/plugins/media_player/assets/src/views/MediaPlayerIndex.vue +++ b/kolibri/plugins/media_player/assets/src/views/MediaPlayerIndex.vue @@ -9,7 +9,10 @@ ref="wrapper" :class="[ 'wrapper', - { 'transcript-visible': isShowingTranscript }, + { + 'transcript-visible': isShowingTranscript, + 'transcript-wrap': windowIsPortrait || windowIsSmall, + }, $computedClass(progressStyle) ]" > @@ -82,6 +85,7 @@ import themeMixin from 'kolibri.coreVue.mixins.themeMixin'; import KCircularLoader from 'kolibri.coreVue.components.KCircularLoader'; import ResponsiveElement from 'kolibri.coreVue.mixins.responsiveElement'; + import ResponsiveWindow from 'kolibri.coreVue.mixins.responsiveWindow'; import contentRendererMixin from 'kolibri.coreVue.mixins.contentRendererMixin'; import Settings from './settings'; @@ -140,7 +144,7 @@ components: { KCircularLoader, MediaPlayerFullscreen, MediaPlayerTranscript }, - mixins: [ResponsiveElement, contentRendererMixin, themeMixin], + mixins: [ResponsiveWindow, ResponsiveElement, contentRendererMixin, themeMixin], data: () => ({ dummyTime: 0, @@ -485,9 +489,13 @@ @import './videojs-style/videojs-font/css/videojs-icons.css'; @import '~kolibri.styles.definitions'; + $transcript-wrap-height: 250px; + .wrapper { + box-sizing: content-box; max-width: 100%; max-height: 562px; + transition: padding-bottom $core-time ease; } .normalize-fullscreen .wrapper, @@ -495,6 +503,10 @@ max-height: none; } + .wrapper.transcript-visible.transcript-wrap { + padding-bottom: $transcript-wrap-height; + } + .fill-space { position: relative; width: 100%; @@ -515,12 +527,20 @@ .media-player-transcript { position: absolute; - top: 0; right: 0; bottom: 0; z-index: 0; + box-sizing: border-box; + } + + .wrapper:not(.transcript-wrap) .media-player-transcript { + top: 0; width: 33.333%; - transition: width $core-time ease; + } + + .wrapper.transcript-wrap .media-player-transcript { + left: 0; + height: $transcript-wrap-height; } /***** PLAYER OVERRIDES *****/ @@ -541,7 +561,7 @@ transition: width $core-time ease; } - .transcript-visible > .video-js.vjs-fill { + .transcript-visible:not(.transcript-wrap) > .video-js.vjs-fill { width: 66.666%; } diff --git a/kolibri/plugins/media_player/assets/src/views/MediaPlayerTranscript/TranscriptCue.vue b/kolibri/plugins/media_player/assets/src/views/MediaPlayerTranscript/TranscriptCue.vue index a1b7d99daa3..18e1d8b1aa0 100755 --- a/kolibri/plugins/media_player/assets/src/views/MediaPlayerTranscript/TranscriptCue.vue +++ b/kolibri/plugins/media_player/assets/src/views/MediaPlayerTranscript/TranscriptCue.vue @@ -93,6 +93,20 @@ preventScroll: true, }); }, + + /** + * @public + */ + offsetTop() { + return this.$el.offsetTop; + }, + + /** + * @public + */ + height() { + return this.$el.offsetHeight; + }, }, $trs: { title: ' Seek to {startTime}', diff --git a/kolibri/plugins/media_player/assets/src/views/MediaPlayerTranscript/index.vue b/kolibri/plugins/media_player/assets/src/views/MediaPlayerTranscript/index.vue index 46cfd6e62e9..9764579bbfa 100755 --- a/kolibri/plugins/media_player/assets/src/views/MediaPlayerTranscript/index.vue +++ b/kolibri/plugins/media_player/assets/src/views/MediaPlayerTranscript/index.vue @@ -1,7 +1,9 @@ @@ -30,6 +44,7 @@ @@ -232,4 +257,10 @@ background: #ffffff; } + .transcript-cap { + padding: 20px; + font-size: 0.9rem; + text-align: center; + } + From d885cdf43fdd1e05bee8e08175ab96ec6a427e60 Mon Sep 17 00:00:00 2001 From: Blaine Jester Date: Wed, 15 May 2019 16:41:57 -0700 Subject: [PATCH 09/49] Styling for older browsers --- .../assets/src/views/MediaPlayerIndex.vue | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/kolibri/plugins/media_player/assets/src/views/MediaPlayerIndex.vue b/kolibri/plugins/media_player/assets/src/views/MediaPlayerIndex.vue index 90820b1804d..75d966b0366 100644 --- a/kolibri/plugins/media_player/assets/src/views/MediaPlayerIndex.vue +++ b/kolibri/plugins/media_player/assets/src/views/MediaPlayerIndex.vue @@ -489,9 +489,8 @@ @import './videojs-style/videojs-font/css/videojs-icons.css'; @import '~kolibri.styles.definitions'; - /* 16:10 ratio gives video a little breathing remove, even though using it's 16:9 */ - $transcript-wrap-fill-height: 100vw * 10 / 16; $transcript-wrap-height: 250px; + $transcript-wrap-fill-height: 100% * 9 / 16; .wrapper { box-sizing: content-box; @@ -546,12 +545,19 @@ max-height: none; } + .wrapper.transcript-visible.transcript-wrap { + padding-bottom: 0; + } + .wrapper.transcript-visible.transcript-wrap .media-player-transcript { - height: calc(100% - (#{$transcript-wrap-fill-height})); + top: 0; + height: auto; + margin-top: #{$transcript-wrap-fill-height}; } .wrapper.transcript-visible.transcript-wrap .video-js.vjs-fill { - height: calc(#{$transcript-wrap-fill-height}); + height: auto; + padding-top: #{$transcript-wrap-fill-height}; } } From ef71d0926f5001c670e1cb1610b6b9ec7b4e7abe Mon Sep 17 00:00:00 2001 From: Blaine Jester Date: Thu, 6 Jun 2019 12:52:26 -0700 Subject: [PATCH 10/49] Fix theme references --- .../src/views/MediaPlayerTranscript/TranscriptCue.vue | 6 +++--- .../assets/src/views/MediaPlayerTranscript/index.vue | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/kolibri/plugins/media_player/assets/src/views/MediaPlayerTranscript/TranscriptCue.vue b/kolibri/plugins/media_player/assets/src/views/MediaPlayerTranscript/TranscriptCue.vue index e40a0f97216..e0bea08dde2 100755 --- a/kolibri/plugins/media_player/assets/src/views/MediaPlayerTranscript/TranscriptCue.vue +++ b/kolibri/plugins/media_player/assets/src/views/MediaPlayerTranscript/TranscriptCue.vue @@ -61,14 +61,14 @@ style() { const activeStyles = this.active ? { - backgroundColor: this.$coreGrey200, - borderLeftColor: this.$coreActionNormal, + backgroundColor: this.$themeColors.palette.grey.v_200, + borderLeftColor: this.$themeTokens.video, } : {}; return Object.assign(activeStyles, { ':hover': { - backgroundColor: this.$coreActionLight, + backgroundColor: this.$themeColors.palette.grey.v_100, }, ':focus': this.$coreOutline, }); diff --git a/kolibri/plugins/media_player/assets/src/views/MediaPlayerTranscript/index.vue b/kolibri/plugins/media_player/assets/src/views/MediaPlayerTranscript/index.vue index 59bc290e0ff..b7df784e358 100755 --- a/kolibri/plugins/media_player/assets/src/views/MediaPlayerTranscript/index.vue +++ b/kolibri/plugins/media_player/assets/src/views/MediaPlayerTranscript/index.vue @@ -80,7 +80,7 @@ }, capStyle() { - return { color: this.$coreTextAnnotation }; + return { color: this.$themeTokens.annotation }; }, }, From 1143ca53da5357e971ce8c71df6ed46ad0e7c8a5 Mon Sep 17 00:00:00 2001 From: Blaine Jester Date: Tue, 18 Jun 2019 10:56:41 -0700 Subject: [PATCH 11/49] Styling updates --- .../assets/src/views/MediaPlayerIndex.vue | 6 +++ .../MediaPlayerTranscript/TranscriptCue.vue | 47 ++++++++++++++----- 2 files changed, 40 insertions(+), 13 deletions(-) diff --git a/kolibri/plugins/media_player/assets/src/views/MediaPlayerIndex.vue b/kolibri/plugins/media_player/assets/src/views/MediaPlayerIndex.vue index 3ee5120bb4d..8e948d285c8 100644 --- a/kolibri/plugins/media_player/assets/src/views/MediaPlayerIndex.vue +++ b/kolibri/plugins/media_player/assets/src/views/MediaPlayerIndex.vue @@ -3,6 +3,9 @@
- {{ startTime }} - + {{ startTime }} + {{ speaker }} {{ text }} @@ -50,14 +58,20 @@ data: () => ({}), computed: { - startTime() { - return videojs.formatTime(this.cue.startTime, this.mediaDuration); - }, - dir() { return getLangDir(this.langCode); }, + speaker() { + return this.cue.text.match(SPEAKER_REGEX) + ? this.cue.text.replace(SPEAKER_REGEX, '$1') + : null; + }, + + startTime() { + return videojs.formatTime(this.cue.startTime, this.mediaDuration); + }, + style() { const activeStyles = this.active ? { @@ -74,15 +88,21 @@ }); }, - speaker() { - return this.cue.text.match(SPEAKER_REGEX) - ? this.cue.text.replace(SPEAKER_REGEX, '$1') - : null; - }, - text() { return this.cue.text.replace(SPEAKER_REGEX, ''); }, + + textStyle() { + return { + 'border-color': this.$themeTokens.fineLine, + }; + }, + + timeStyle() { + return { + color: this.$themeTokens.annotation, + }; + }, }, methods: { @@ -141,12 +161,13 @@ .transcript-cue-time { width: 50px; + margin-top: 0.1rem; font-size: 0.9rem; } .transcript-cue-text { width: calc(100% - 50px); - border-bottom: 1px solid #dddddd; + border-bottom: 1px solid transparent; } } From 47fb09d18fde1189cf0998378da093b78ac2528e Mon Sep 17 00:00:00 2001 From: Blaine Jester Date: Tue, 18 Jun 2019 15:50:55 -0700 Subject: [PATCH 12/49] Handle edge case where cue(s) could be larger than viewable area --- kolibri/core/package.json | 2 +- .../MediaPlayerTranscript/TranscriptCue.vue | 11 +++- .../src/views/MediaPlayerTranscript/index.vue | 53 ++++++++++++++++--- yarn.lock | 8 +-- 4 files changed, 61 insertions(+), 13 deletions(-) diff --git a/kolibri/core/package.json b/kolibri/core/package.json index eb3af609d38..1c410dcdc72 100644 --- a/kolibri/core/package.json +++ b/kolibri/core/package.json @@ -15,7 +15,7 @@ "d3-color": "^1.2.3", "date-fns": "^1.28.2", "fontfaceobserver": "^2.0.13", - "frame-throttle": "^2.0.1", + "frame-throttle": "^3.0.0", "intl": "^1.2.4", "js-cookie": "^2.1.2", "keen-ui": "https://github.com/learningequality/Keen-UI/", diff --git a/kolibri/plugins/media_player/assets/src/views/MediaPlayerTranscript/TranscriptCue.vue b/kolibri/plugins/media_player/assets/src/views/MediaPlayerTranscript/TranscriptCue.vue index 39eb138a2bc..249438e8949 100755 --- a/kolibri/plugins/media_player/assets/src/views/MediaPlayerTranscript/TranscriptCue.vue +++ b/kolibri/plugins/media_player/assets/src/views/MediaPlayerTranscript/TranscriptCue.vue @@ -116,8 +116,8 @@ /** * @public */ - offsetTop() { - return this.$el.offsetTop; + duration() { + return this.cue.endTime - this.cue.startTime; }, /** @@ -126,6 +126,13 @@ height() { return this.$el.offsetHeight; }, + + /** + * @public + */ + offsetTop() { + return this.$el.offsetTop; + }, }, $trs: { title: 'Seek to {startTime}', diff --git a/kolibri/plugins/media_player/assets/src/views/MediaPlayerTranscript/index.vue b/kolibri/plugins/media_player/assets/src/views/MediaPlayerTranscript/index.vue index b7df784e358..9a3951d875e 100755 --- a/kolibri/plugins/media_player/assets/src/views/MediaPlayerTranscript/index.vue +++ b/kolibri/plugins/media_player/assets/src/views/MediaPlayerTranscript/index.vue @@ -46,6 +46,7 @@ import themeMixin from 'kolibri.coreVue.mixins.themeMixin'; import KCircularLoader from 'kolibri.coreVue.components.KCircularLoader'; + import { throttle } from 'frame-throttle'; import Settings from '../settings'; import TrackHandler from './trackHandler'; @@ -70,6 +71,7 @@ showing: false, hovering: false, nextScroll: null, + scrollThrottle: null, cues: [], activeCueIds: [], }), @@ -100,12 +102,22 @@ return Math.max(offsetBottom, cue.offsetTop() + cue.height()); }, 0); - this.scrollTo(offsetTop, offsetBottom); + const duration = newActiveCueIds.reduce((duration, cueId) => { + const [cue] = this.$refs[cueId]; + + // Multiply duration by 1000 to get milliseconds + return duration + cue.duration() * 1000; + }, 0); + + this.scrollTo(offsetTop, offsetBottom, duration); }, hovering(isHovering) { if (!isHovering && this.nextScroll) { - this.scrollTo(...this.nextScroll); + const { offsetTop, offsetBottom, duration, start } = this.nextScroll; + const now = new Date().getTime(); + + this.scrollTo(offsetTop, offsetBottom, duration - (now - start)); this.nextScroll = null; } }, @@ -211,23 +223,38 @@ } }, - scrollTo(offsetTop, offsetBottom) { + scrollTo(offsetTop, offsetBottom, duration) { + const start = new Date().getTime(); + + // Clear scroll throttle, if current call is from a scroll throttle this doesn't matter + if (this.scrollThrottle) { + this.scrollThrottle.cancel(); + this.scrollThrottle = null; + } + if (this.hovering) { - this.nextScroll = [offsetTop, offsetBottom]; + this.nextScroll = { offsetTop, offsetBottom, duration, start }; return; } const height = this.$el.offsetHeight; - const offsetMiddle = offsetTop + Math.min(offsetBottom - offsetTop, height) / 2; + const targetHeight = offsetBottom - offsetTop; + const offsetMiddle = offsetTop + Math.min(targetHeight, height) / 2; const currentScrollTop = this.$el.scrollTop; const currentScrollMiddle = currentScrollTop + height / 2; const scrollMax = this.$el.scrollHeight - height; - if (offsetTop > currentScrollTop && offsetTop < currentScrollMiddle) { + // Don't trigger a scroll if target scroll position is in top half of container + if ( + offsetTop > currentScrollTop && + offsetTop < currentScrollMiddle && + targetHeight <= height + ) { return; } + // Jump backwards to cue if (offsetTop < currentScrollTop) { this.$el.scrollTo(0, offsetTop); return; @@ -235,6 +262,20 @@ const scrollTo = currentScrollTop + (offsetMiddle - currentScrollMiddle); this.$el.scrollTo(0, Math.min(scrollMax, scrollTo)); + + if (targetHeight <= height) { + return; + } + + // In the event the cue('s) contents is taller than the container, we'll slow scroll for + // the cue duration until offsetBottom fits + const step = (offsetBottom - offsetTop - height) / duration; + this.scrollThrottle = throttle(() => { + const now = new Date().getTime(); + this.scrollTo(offsetTop + step * (now - start), offsetBottom, duration - (now - start)); + }); + + this.$nextTick(this.scrollThrottle); }, }, $trs: { diff --git a/yarn.lock b/yarn.lock index 9850ff09500..922d29672d3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5331,10 +5331,10 @@ fragment-cache@^0.2.1: dependencies: map-cache "^0.2.2" -frame-throttle@^2.0.1: - version "2.0.1" - resolved "https://registry.yarnpkg.com/frame-throttle/-/frame-throttle-2.0.1.tgz#d184334335da04e7f48a5318a0aac7a9901f048f" - integrity sha1-0YQzQzXaBOf0ilMYoKrHqZAfBI8= +frame-throttle@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/frame-throttle/-/frame-throttle-3.0.0.tgz#a666e007c10af6239909cf73871be09a7ea1c6d1" + integrity sha1-pmbgB8EK9iOZCc9zhxvgmn6hxtE= fresh@0.5.2: version "0.5.2" From bba1351e5b189d48470093bd4c7ea158626d81a0 Mon Sep 17 00:00:00 2001 From: Blaine Jester Date: Mon, 8 Jul 2019 18:27:13 -0700 Subject: [PATCH 13/49] WIP updates to transcript control --- .../media_player/assets/src/constants.json | 13 + .../src/track/textTrackLanguageGroup.js | 191 ++++++++++++++ .../assets/src/{views => utils}/settings.js | 16 ++ .../media_player/assets/src/utils/track.js | 54 ++++ .../assets/src/utils/videojsVueConnector.js | 66 +++++ .../MediaPlayerCaptions/CaptionsMenu.vue | 209 +++++++++++++++ .../MediaPlayerCaptions/CaptionsMenuItem.vue | 51 ++++ .../CaptionsMenuSetting.vue | 118 +++++++++ .../MediaPlayerCaptions/captionsButton.js | 237 ++++++++++++++++++ .../views/MediaPlayerCaptions/captionsMenu.js | 103 ++++++++ .../MediaPlayerCaptions/captionsMenuItem.js | 79 ++++++ .../assets/src/views/MediaPlayerIndex.vue | 142 ++++++----- .../src/views/MediaPlayerTranscript/index.vue | 2 +- .../MediaPlayerTranscript/transcriptButton.js | 2 +- .../assets/src/views/textTrackCueAdapter.js | 59 +++++ .../src/views/videojs-style/variables.scss | 8 + 16 files changed, 1279 insertions(+), 71 deletions(-) create mode 100644 kolibri/plugins/media_player/assets/src/constants.json create mode 100644 kolibri/plugins/media_player/assets/src/track/textTrackLanguageGroup.js rename kolibri/plugins/media_player/assets/src/{views => utils}/settings.js (81%) create mode 100644 kolibri/plugins/media_player/assets/src/utils/track.js create mode 100644 kolibri/plugins/media_player/assets/src/utils/videojsVueConnector.js create mode 100644 kolibri/plugins/media_player/assets/src/views/MediaPlayerCaptions/CaptionsMenu.vue create mode 100644 kolibri/plugins/media_player/assets/src/views/MediaPlayerCaptions/CaptionsMenuItem.vue create mode 100644 kolibri/plugins/media_player/assets/src/views/MediaPlayerCaptions/CaptionsMenuSetting.vue create mode 100644 kolibri/plugins/media_player/assets/src/views/MediaPlayerCaptions/captionsButton.js create mode 100644 kolibri/plugins/media_player/assets/src/views/MediaPlayerCaptions/captionsMenu.js create mode 100644 kolibri/plugins/media_player/assets/src/views/MediaPlayerCaptions/captionsMenuItem.js create mode 100644 kolibri/plugins/media_player/assets/src/views/textTrackCueAdapter.js create mode 100644 kolibri/plugins/media_player/assets/src/views/videojs-style/variables.scss diff --git a/kolibri/plugins/media_player/assets/src/constants.json b/kolibri/plugins/media_player/assets/src/constants.json new file mode 100644 index 00000000000..ab5302950bf --- /dev/null +++ b/kolibri/plugins/media_player/assets/src/constants.json @@ -0,0 +1,13 @@ +{ + "KINDS": ["captions", "metadata"], + "KIND_SUBTITLES": "captions", + "KIND_TRANSCRIPT": "metadata", + "KIND_ENABLED_MODE": { + "captions": "showing", + "metadata": "hidden" + }, + "KIND_DISABLED_MODE": { + "captions": "disabled", + "metadata": "disabled" + } +} diff --git a/kolibri/plugins/media_player/assets/src/track/textTrackLanguageGroup.js b/kolibri/plugins/media_player/assets/src/track/textTrackLanguageGroup.js new file mode 100644 index 00000000000..dd57da2b32f --- /dev/null +++ b/kolibri/plugins/media_player/assets/src/track/textTrackLanguageGroup.js @@ -0,0 +1,191 @@ +import EventEmitter from 'events'; +import constants from '../constants.json'; +import trackUtils from '../utils/track'; + +// This class will pretend to be of "subtitle" kind +const KIND = constants.KIND_SUBTITLES; +const ENABLED_MODE = constants.KIND_ENABLED_MODE[KIND]; +const DISABLED_MODE = constants.KIND_DISABLED_MODE[KIND]; + +/** + * Groups different kinds of text tracks by common language. Specifically, in our use case, we're + * duplicating the same text track as both 'captions' and 'metadata' kinds. The 'caption' kind is + * for captions displayed as subtitles and the 'metadata' kind is for the transcript. This allows + * us to use the built-in language selector of video.js without it affecting an actual track. + */ +class TextTrackLanguageGroup extends EventEmitter { + /** + * @param {String} language + * @param {TextTrack[]} tracks + */ + constructor(language, tracks) { + super(); + + this._mode = DISABLED_MODE; + this._language = language; + this._tracks = tracks; + this._id = null; + this._disabledKinds = []; + + tracks.forEach(track => { + if (track.language !== language) { + throw new Error('Mismatched track language'); + } + + if (trackUtils.isEnabled(track)) { + this.enableKind(track.kind); + } else { + this.disableKind(track.kind); + } + + track.on('trackchange', () => { + if (trackUtils.isEnabled(track)) { + this.enableKind(track.kind); + } + }); + }); + } + + /** + * @return {TextTrackCueList|null} + */ + get activeCues() { + const activeTrack = this.findEnabled(); + return activeTrack ? activeTrack.activeCues : null; + } + + /** + * Return cues from the first track that has cues + * + * @return {TextTrackCueList} + */ + get cues() { + return this._tracks + .map(track => track.cues) + .filter(cues => cues && cues.length) + .shift(); + } + + /** + * @return {String} + */ + get id() { + return this._id || this._tracks[0].id; + } + + /** + * @param {String} id + */ + set id(id) { + this._id = id; + } + + /** + * @return {String} + */ + get kind() { + // We'll simply return one kind + return KIND; + } + + /** + * @return {String} + */ + get label() { + return this._tracks[0].label; + } + + /** + * @return {String} + */ + get language() { + return this._language; + } + + /** + * @return {String} + */ + get mode() { + return this._mode; + } + + /** + * @param {String} mode + */ + set mode(mode) { + const enabling = trackUtils.isEnabledMode(mode); + + this._mode = enabling ? ENABLED_MODE : DISABLED_MODE; + + this._tracks.forEach(track => { + if (this._disabledKinds.find(disabledKind => track.kind === disabledKind)) { + return; + } + + // Get the enabled/disabled mode for each track based on its kind + const newMode = enabling + ? trackUtils.getEnabledMode(track) + : trackUtils.getDisabledMode(track); + + if (track.mode !== newMode) { + track.mode = newMode; + this.emit(enabling ? 'enable' : 'disable'); + } + }); + } + + /** + * @param {TextTrackCue} cue + */ + addCue(cue) { + this._tracks.forEach(track => { + track.addCue(cue); + }); + } + + enable() { + this.mode = ENABLED_MODE; + } + + disable() { + this.mode = DISABLED_MODE; + } + + enableKind(kind) { + this._disabledKinds = this._disabledKinds.filter(disabledKind => disabledKind !== kind); + + if (this.isEnabled()) { + this._tracks.filter(track => track.kind === kind).forEach(track => { + track.mode = trackUtils.getEnabledMode(track); + }); + } + + this.emit('enableKind', kind); + } + + disableKind(kind) { + this._disabledKinds.push(kind); + + this._tracks.filter(track => track.kind === kind).forEach(track => { + track.mode = trackUtils.getDisabledMode(track); + }); + + this.emit('disableKind', kind); + } + + /** + * @return {boolean} + */ + isEnabled() { + return this._mode === ENABLED_MODE; + } + + /** + * @return {TextTrack|null} + */ + findEnabled() { + return this._tracks.find(track => trackUtils.isEnabled(track)); + } +} + +export default TextTrackLanguageGroup; diff --git a/kolibri/plugins/media_player/assets/src/views/settings.js b/kolibri/plugins/media_player/assets/src/utils/settings.js similarity index 81% rename from kolibri/plugins/media_player/assets/src/views/settings.js rename to kolibri/plugins/media_player/assets/src/utils/settings.js index 33414d7e9bf..898112f55d1 100755 --- a/kolibri/plugins/media_player/assets/src/views/settings.js +++ b/kolibri/plugins/media_player/assets/src/utils/settings.js @@ -31,6 +31,22 @@ class Settings { return this.get().playerVolume; } + set captionKinds(captionKinds) { + return this.save({ captionKinds }); + } + + get captionKinds() { + return this.get().captionKinds; + } + + set captionLanguage(captionLanguage) { + return this.save({ captionLanguage }); + } + + get captionLanguage() { + return this.get().captionLanguage; + } + set transcriptShowing(transcriptShowing) { return this.save({ transcriptShowing }); } diff --git a/kolibri/plugins/media_player/assets/src/utils/track.js b/kolibri/plugins/media_player/assets/src/utils/track.js new file mode 100644 index 00000000000..d7ae6eafadf --- /dev/null +++ b/kolibri/plugins/media_player/assets/src/utils/track.js @@ -0,0 +1,54 @@ +import constants from '../constants.json'; + +export default { + /** + * @param {String} mode + * @return {boolean} + */ + isEnabledMode(mode) { + return Object.values(constants.KIND_ENABLED_MODE).indexOf(mode) >= 0; + }, + + /** + * @param {TextTrack} track + * @return {String} + */ + getEnabledMode(track) { + if (!(track.kind in constants.KIND_ENABLED_MODE)) { + throw new Error('Unknown track kind'); + } + + return constants.KIND_ENABLED_MODE[track.kind]; + }, + + /** + * @param {TextTrack} track + * @return {String} + */ + getDisabledMode(track) { + if (!(track.kind in constants.KIND_DISABLED_MODE)) { + throw new Error('Unknown track kind'); + } + + return constants.KIND_DISABLED_MODE[track.kind]; + }, + + /** + * @param {TextTrack} track + * @return {boolean} + */ + isEnabled(track) { + return track.mode === this.getEnabledMode(track); + }, + + /** + * Text track lists do not implement all array-like features, so this will convert it into an + * array + * + * @param {TextTrackList|TextTrackCueList} list + * @return {TextTrack[]|TextTrackCue[]} + */ + listToArray(list) { + return Array.prototype.slice.call(list, 0); + }, +}; diff --git a/kolibri/plugins/media_player/assets/src/utils/videojsVueConnector.js b/kolibri/plugins/media_player/assets/src/utils/videojsVueConnector.js new file mode 100644 index 00000000000..c375c7e5b42 --- /dev/null +++ b/kolibri/plugins/media_player/assets/src/utils/videojsVueConnector.js @@ -0,0 +1,66 @@ +import Vue from 'kolibri.lib.vue'; +import videojs from 'video.js'; + +/** + * @param {String} videojsComponent A string of the videojs component to extend + * @param {Object} vueComponent A compiled vue component object + * @return {{new(Player, Object=): VueMixin, _vueComponent: null, prototype: VueMixin}} + */ +export default function videojsVueConnector(videojsComponent, vueComponent) { + const VideojsComponent = videojs.getComponent(videojsComponent); + const VueComponent = Vue.extend(vueComponent); + + return class VueMixin extends VideojsComponent { + /** + * This is called by video.js code that usually constructs an element, but here we'll leverage + * vue by calling it manually. + * + * @return {Element} + */ + createEl() { + return this.createVueComponent().$el; + } + + /** + * @param {Object} [options] + * @return {VueComponent} + */ + createVueComponent(options) { + this.clearVueComponent(); + this._vueComponent = new VueComponent(options).$mount(); + return this.getVueComponent(); + } + + /** + * @return {VueComponent} + */ + getVueComponent() { + return this._vueComponent; + } + + /** + * Clears held Vue component instance, destroying it first + */ + clearVueComponent() { + if (this._vueComponent) { + this._vueComponent.$destroy(); + this._vueComponent = null; + } + } + + /** + * @return {Settings} + */ + getSettings() { + return this.options_.settings; + } + + /** + * video.js hook to dispose this video.js component, so be sure to `clearComponent` + */ + dispose() { + this.clearVueComponent(); + super.dispose(); + } + }; +} diff --git a/kolibri/plugins/media_player/assets/src/views/MediaPlayerCaptions/CaptionsMenu.vue b/kolibri/plugins/media_player/assets/src/views/MediaPlayerCaptions/CaptionsMenu.vue new file mode 100644 index 00000000000..9be684ae27f --- /dev/null +++ b/kolibri/plugins/media_player/assets/src/views/MediaPlayerCaptions/CaptionsMenu.vue @@ -0,0 +1,209 @@ + + + + + + + diff --git a/kolibri/plugins/media_player/assets/src/views/MediaPlayerCaptions/CaptionsMenuItem.vue b/kolibri/plugins/media_player/assets/src/views/MediaPlayerCaptions/CaptionsMenuItem.vue new file mode 100644 index 00000000000..cd710a72f20 --- /dev/null +++ b/kolibri/plugins/media_player/assets/src/views/MediaPlayerCaptions/CaptionsMenuItem.vue @@ -0,0 +1,51 @@ + + + + + + + diff --git a/kolibri/plugins/media_player/assets/src/views/MediaPlayerCaptions/CaptionsMenuSetting.vue b/kolibri/plugins/media_player/assets/src/views/MediaPlayerCaptions/CaptionsMenuSetting.vue new file mode 100644 index 00000000000..37de92a9849 --- /dev/null +++ b/kolibri/plugins/media_player/assets/src/views/MediaPlayerCaptions/CaptionsMenuSetting.vue @@ -0,0 +1,118 @@ + + + + + + + diff --git a/kolibri/plugins/media_player/assets/src/views/MediaPlayerCaptions/captionsButton.js b/kolibri/plugins/media_player/assets/src/views/MediaPlayerCaptions/captionsButton.js new file mode 100644 index 00000000000..1371488006c --- /dev/null +++ b/kolibri/plugins/media_player/assets/src/views/MediaPlayerCaptions/captionsButton.js @@ -0,0 +1,237 @@ +import videojs from 'video.js'; +import constants from '../../constants.json'; +import trackUtils from '../../utils/track'; +import TextTrackLanguageGroup from '../../track/textTrackLanguageGroup'; +import CaptionsMenu from './captionsMenu'; +import CaptionsMenuItem from './captionsMenuItem'; + +const TextTrackButton = videojs.getComponent('TextTrackButton'); +const { handleSelectedLanguageChange } = videojs.getComponent('TextTrackMenuItem').prototype; + +/** + * The Component for the Button that will open the CaptionsMenu + */ +class CaptionsButton extends TextTrackButton { + /** + * @return {CaptionsMenu} + */ + createMenu() { + if (this.items) { + this.items.forEach(item => item.dispose()); + this.items = []; + } + + const menu = new CaptionsMenu(this.player(), { + menuButton: this, + settings: this.getSettings(), + }); + + this.hideThreshold_ = 0; + + this.items = this.createItems(); + this.items.forEach(item => menu.addItem(item)); + + menu.on('show', () => { + this.addClass('active'); + + this._wasPlaying = !this.player().paused(); + + // Handlers to trigger a "click" when menu should exited + this._playListener = () => this.handleClick(); + this._blurListener = e => { + // Don't hide the menu if event target is one of our elements + if ( + this.el().contains(e.target) || + menu.el().contains(e.target) || + e.target === this.el() || + e.target === menu.el() + ) { + return; + } + + this.handleClick(); + }; + + if (this._wasPlaying) { + this.player().pause(); + } + + this.player().one('play', this._playListener); + this.on(document, 'click', this._blurListener); + }); + + menu.on('hide', () => { + this.removeClass('active'); + + if (this._playListener) { + this.player().off('play', this._playListener); + } + + if (this._blurListener) { + this.off(document, 'click', this._blurListener); + } + + // Restore video to playing after close, if it was playing when it was opened + if (this._wasPlaying && this.player().paused()) { + this.player().play(); + } + + this._wasPlaying = false; + this._playListener = null; + this._blurListener = null; + }); + + return menu; + } + + /** + * Removes class that adds specific functionality we don't want + * + * @param {String} classNames + * @return {String} + */ + removePopupClass(classNames) { + return classNames.replace(/\bvjs-menu-button-popup\b/, ' '); + } + + /** + * @return {string} + */ + buildCSSClass() { + return this.removePopupClass(`vjs-captions-button ${super.buildCSSClass()}`); + } + + /** + * @return {string} + */ + buildWrapperCSSClass() { + return this.removePopupClass(`vjs-captions-button ${super.buildWrapperCSSClass()}`); + } + + /** + * @see https://github.com/videojs/video.js/blob/v7.4.1/src/js/control-bar/text-track-controls/text-track-button.js#L40 + * @returns {CaptionsMenuItem[]} + */ + createItems() { + const player = this.player(); + const tracks = trackUtils.listToArray(player.textTracks()); + + if (!tracks.length) { + return []; + } + + // Filter tracks to the kinds we care about, and group by language + const trackGroups = tracks + .filter(track => constants.KINDS.find(kind => kind === track.kind)) + .reduce((trackGroups, track) => { + if (!(track.language in trackGroups)) { + trackGroups[track.language] = []; + } + + trackGroups[track.language].push(track); + return trackGroups; + }, {}); + + return Object.entries(trackGroups).map(([language, tracks]) => + this.createItem(language, tracks) + ); + } + + /** + * @param {String} language + * @param {TextTrack[]} tracks + * @return {CaptionsMenuItem} + */ + createItem(language, tracks) { + const trackList = this.player().textTracks(); + const settings = this.getSettings(); + + const track = new TextTrackLanguageGroup(language, tracks); + + if (settings.captionLanguage === language) { + track.enable(); + } else { + track.disable(); + } + + constants.KINDS.forEach(kind => { + if (!settings.captionKinds.find(captionKind => kind === captionKind)) { + track.disableKind(kind); + } + }); + + track + .on('enable', () => { + settings.captionLanguage = language; + }) + .on('enableKind', kind => { + settings.captionKinds = settings.captionKinds.concat([kind]); + }) + .on('disableKind', kind => { + settings.captionKinds = settings.captionKinds.filter(captionKind => captionKind !== kind); + }); + + const item = new CaptionsMenuItem(this.player(), { + track, + selectable: true, + multiSelectable: false, + selected: track.isEnabled(), + }); + + const changeHandler = () => { + if (!track.isEnabled() || !this.menu) { + return; + } + + this.menu.getCaptionItems().forEach(otherItem => { + if (otherItem.getTrack().id !== track.id) { + otherItem.selected(false); + } + }); + }; + + const selectedLanguageChangeHandler = handleSelectedLanguageChange.bind({ + track, + player_: this.player(), + }); + + item.on('change', changeHandler); + trackList.addEventListener('selectedlanguagechange', selectedLanguageChangeHandler); + + item.on('dispose', () => { + item.off('change', changeHandler); + trackList.removeEventListener('selectedlanguagechange', selectedLanguageChangeHandler); + }); + + return item; + } + + /** + * @return {TextTrackLanguageGroup[]} + */ + getTracks() { + return this.items ? this.items.map(item => item.getTrack()) : []; + } + + /** + * @return {TextTrack} + */ + getActiveTrack() { + return this.items + .filter(item => item instanceof CaptionsMenuItem && item.isSelected()) + .map(item => item.getTrack()) + .shift(); + } + + /** + * @return {Settings} + */ + getSettings() { + return this.options_.settings; + } +} + +CaptionsButton.prototype.kind_ = 'captions'; +CaptionsButton.prototype.controlText_ = 'Captions'; + +export default CaptionsButton; diff --git a/kolibri/plugins/media_player/assets/src/views/MediaPlayerCaptions/captionsMenu.js b/kolibri/plugins/media_player/assets/src/views/MediaPlayerCaptions/captionsMenu.js new file mode 100644 index 00000000000..92ace836704 --- /dev/null +++ b/kolibri/plugins/media_player/assets/src/views/MediaPlayerCaptions/captionsMenu.js @@ -0,0 +1,103 @@ +import connector from '../../utils/videojsVueConnector'; +import captionsMenu from './CaptionsMenu.vue'; +import CaptionsMenuItem from './captionsMenuItem'; + +const BaseCaptionsMenu = connector('Menu', captionsMenu); + +class CaptionsMenu extends BaseCaptionsMenu { + /** + * @param {Object} [options] + * @return {VueComponent} + */ + createVueComponent(options) { + const component = super.createVueComponent( + Object.assign( + { + propsData: { + settings: this.getSettings(), + }, + }, + options + ) + ); + component.$on('changeKind', (kind, isActive) => this.handleKindChange(kind, isActive)); + return component; + } + + /** + * `contentEl` is used when `addItem` is called, so this allows the addition of the text track + * options (the languages) in the right spot + * + * @return {*|Element} + */ + contentEl() { + return this.getVueComponent().contentEl(); + } + + /** + * Override parent's method, which adds event handlers we don't want + * + * @param {CaptionsMenuItem|Component|String} item The name or instance of the item to add + */ + addItem(item) { + this.addChild(item); + } + + /** + * @param {String} kind + * @param {Boolean} isActive + */ + handleKindChange(kind, isActive) { + this.getCaptionItems().forEach(item => { + if (isActive) { + item.getTrack().enableKind(kind); + } else { + item.getTrack().disableKind(kind); + } + }); + } + + /** + * Disables default show/hide functionality, which is triggered on hover. `lockShowing()` gets + * called instead on click. + */ + show() {} + hide() {} + + /** + * Triggered on click in ancestor + */ + lockShowing() { + const component = this.getVueComponent(); + + if (!component || component.showing()) { + return; + } + + component.show(); + this.trigger('show'); + } + + /** + * Triggered on blur in ancestor + */ + unlockShowing() { + const component = this.getVueComponent(); + + if (!component || !component.showing()) { + return; + } + + component.hide(); + this.trigger('hide'); + } + + /** + * @return {CaptionsMenuItem[]} + */ + getCaptionItems() { + return this.children().filter(item => item instanceof CaptionsMenuItem); + } +} + +export default CaptionsMenu; diff --git a/kolibri/plugins/media_player/assets/src/views/MediaPlayerCaptions/captionsMenuItem.js b/kolibri/plugins/media_player/assets/src/views/MediaPlayerCaptions/captionsMenuItem.js new file mode 100644 index 00000000000..e2af82fd595 --- /dev/null +++ b/kolibri/plugins/media_player/assets/src/views/MediaPlayerCaptions/captionsMenuItem.js @@ -0,0 +1,79 @@ +import connector from '../../utils/videojsVueConnector'; +import captionsMenuItem from './CaptionsMenuItem.vue'; + +const BaseCaptionsMenuItem = connector('MenuItem', captionsMenuItem); + +class CaptionsMenuItem extends BaseCaptionsMenuItem { + /** + * @param {Player} player + * @param {Object} options + * @param {TextTrackLanguageGroup} options.track + */ + constructor(player, options = {}) { + const track = options.track; + + // Copied from `TextTrackMenuItem` + options.label = track.label || track.language || 'Unknown'; + + super(player, options); + } + + /** + * @param {Object} [options] + * @return {VueComponent} + */ + createVueComponent(options = {}) { + const component = super.createVueComponent( + Object.assign( + { + propsData: { + label: this.localize(this.options_.label), + selected: this.getTrack().isEnabled(), + }, + }, + options + ) + ); + + component.$on('change', () => { + this.selected(true); + this.trigger('change'); + }); + return component; + } + + /** + * @override + * @param selected + */ + selected(selected) { + this.getVueComponent().$props.selected = selected; + + if (selected) { + this.getTrack().enable(); + } else { + this.getTrack().disable(); + } + } + + /** + * @return {boolean} + */ + isSelected() { + return this.getTrack().isEnabled(); + } + + /** + * @return {TextTrackLanguageGroup} + */ + getTrack() { + return this.options_.track; + } + + /** + * We don't need to handle clicks + */ + handleClick() {} +} + +export default CaptionsMenuItem; diff --git a/kolibri/plugins/media_player/assets/src/views/MediaPlayerIndex.vue b/kolibri/plugins/media_player/assets/src/views/MediaPlayerIndex.vue index 8e948d285c8..806e5657201 100644 --- a/kolibri/plugins/media_player/assets/src/views/MediaPlayerIndex.vue +++ b/kolibri/plugins/media_player/assets/src/views/MediaPlayerIndex.vue @@ -40,19 +40,12 @@ @@ -91,51 +84,25 @@ import ResponsiveWindow from 'kolibri.coreVue.mixins.responsiveWindow'; import contentRendererMixin from 'kolibri.coreVue.mixins.contentRendererMixin'; - import Settings from './settings'; + import Settings from '../utils/settings'; + import constants from '../constants.json'; import { ReplayButton, ForwardButton } from './customButtons'; import MediaPlayerFullscreen from './MediaPlayerFullscreen'; import MimicFullscreenToggle from './MediaPlayerFullscreen/mimicFullscreenToggle'; import MediaPlayerTranscript from './MediaPlayerTranscript'; import TranscriptButton from './MediaPlayerTranscript/transcriptButton'; + import CaptionsButton from './MediaPlayerCaptions/captionsButton'; import audioIconPoster from './audio-icon-poster.svg'; const GlobalLangCode = vue.locale; - const PLAYER_CONFIG = { - fluid: false, - fill: true, - controls: true, - textTrackDisplay: true, - bigPlayButton: true, - preload: 'metadata', - playbackRates: [0.5, 1.0, 1.25, 1.5, 2.0], - controlBar: { - children: [ - { name: 'PlayToggle' }, - { name: 'ReplayButton' }, - { name: 'ForwardButton' }, - { name: 'CurrentTimeDisplay' }, - { name: 'ProgressControl' }, - { name: 'TimeDivider' }, - { name: 'DurationDisplay' }, - { - name: 'VolumePanel', - inline: false, - }, - { name: 'PlaybackRateMenuButton' }, - { name: 'CaptionsButton' }, - { name: 'MimicFullscreenToggle' }, - { name: 'TranscriptButton' }, - ], - }, - language: GlobalLangCode, - }; const componentsToRegister = { MimicFullscreenToggle, ReplayButton, ForwardButton, TranscriptButton, + CaptionsButton, }; Object.entries(componentsToRegister).forEach(([name, component]) => @@ -158,7 +125,6 @@ playerMuted: false, playerRate: 1.0, defaultLangCode: GlobalLangCode, - videoLangCode: GlobalLangCode, updateContentStateInterval: null, isFullscreen: false, isShowingTranscript: false, @@ -187,9 +153,28 @@ }, trackSources() { const trackFileExtensions = ['vtt']; - return this.supplementaryFiles.filter(file => + const trackSources = this.supplementaryFiles.filter(file => trackFileExtensions.some(ext => ext === file.extension) ); + + // Create duplicates so subtitles and transcript can be enabled/disabled separately. + return [ + ...trackSources.map(track => { + return Object.assign({}, track, { + key: track.storage_url + constants.KIND_SUBTITLES, + kind: constants.KIND_SUBTITLES, + is_default: this.isDefaultTrack(track.lang.id), + }); + }), + ...trackSources.map(track => { + // for transcript + return Object.assign({}, track, { + key: track.storage_url + constants.KIND_TRANSCRIPT, + kind: constants.KIND_TRANSCRIPT, + is_default: false, + }); + }), + ]; }, isVideo() { return this.videoSources.length; @@ -216,10 +201,9 @@ playerVolume: this.playerVolume, playerMuted: this.playerMuted, playerRate: this.playerRate, - videoLangCode: this.videoLangCode, + captionLanguage: this.defaultLangCode, + captionKinds: [], }); - - this.videoLangCode = this.settings.videoLangCode; }, mounted() { this.initPlayer(); @@ -236,9 +220,13 @@ methods: { isDefaultTrack(langCode) { const shortLangCode = languageIdToCode(langCode); - const shortGlobalLangCode = languageIdToCode(this.videoLangCode); + const shortGlobalLangCode = languageIdToCode(this.settings.captionLanguage); + + const captionsEnabled = this.settings.captionKinds.find( + captionKind => captionKind === constants.KIND_SUBTITLES + ); - return shortLangCode === shortGlobalLangCode; + return shortLangCode === shortGlobalLangCode && captionsEnabled; }, initPlayer() { this.$nextTick(() => { @@ -248,7 +236,37 @@ }); }, getPlayerConfig() { - const videojsConfig = Object.assign({}, PLAYER_CONFIG, { + const videojsConfig = { + fluid: false, + fill: true, + controls: true, + textTrackDisplay: true, + bigPlayButton: true, + preload: 'metadata', + playbackRates: [0.5, 1.0, 1.25, 1.5, 2.0], + controlBar: { + children: [ + { name: 'PlayToggle' }, + { name: 'ReplayButton' }, + { name: 'ForwardButton' }, + { name: 'CurrentTimeDisplay' }, + { name: 'ProgressControl' }, + { name: 'TimeDivider' }, + { name: 'DurationDisplay' }, + { + name: 'VolumePanel', + inline: false, + }, + { name: 'PlaybackRateMenuButton' }, + { + name: 'CaptionsButton', + settings: this.settings, + }, + { name: 'MimicFullscreenToggle' }, + // { name: 'TranscriptButton' }, + ], + }, + language: GlobalLangCode, languages: { [GlobalLangCode]: { Play: this.$tr('play'), @@ -285,7 +303,7 @@ ), }, }, - }); + }; if (!this.isVideo) { videojsConfig.poster = this.audioPoster; @@ -311,7 +329,6 @@ this.player.on('seeking', this.handleSeek); this.player.on('volumechange', this.throttledUpdateVolume); this.player.on('ratechange', this.updateRate); - this.player.on('texttrackchange', this.updateLang); this.player.on('ended', () => this.setPlayState(false)); this.$watch('elementWidth', this.updatePlayerSizeClass); @@ -352,17 +369,6 @@ this.settings.playerRate = this.player.playbackRate(); }, - getTextTracks() { - return Array.from(this.player.textTracks()); - }, - - updateLang() { - const currentTrack = this.getTextTracks().find(track => track.mode === 'showing'); - if (currentTrack) { - this.settings.videoLangCode = currentTrack.language; - } - }, - useSavedSettings() { this.playerVolume = this.settings.playerVolume; this.playerMuted = this.settings.playerMuted; @@ -571,14 +577,7 @@ /* !!rtl:begin:ignore */ - /** COLOR PALLETTE **/ - $video-player-color: #212121; - // tint if $video-player-color = black-ish, shade if $video-player-color = white-ish - $video-player-color-2: tint($video-player-color, 7%); - $video-player-color-3: tint($video-player-color, 15%); - $video-player-font-color: white; - - $video-player-font-size: 12px; + @import './videojs-style/variables'; .video-js.vjs-fill { z-index: 1; @@ -596,6 +595,11 @@ } } + /* Mimics glow video.js adds on fullscreen button when focused */ + /deep/ .vjs-captions-button.active .vjs-icon-placeholder { + text-shadow: 0 0 1em #ffffff; + } + /*** CUSTOM VIDEOJS SKIN ***/ /deep/ .custom-skin { $button-height-normal: 40px; diff --git a/kolibri/plugins/media_player/assets/src/views/MediaPlayerTranscript/index.vue b/kolibri/plugins/media_player/assets/src/views/MediaPlayerTranscript/index.vue index 9a3951d875e..058da1b36d2 100755 --- a/kolibri/plugins/media_player/assets/src/views/MediaPlayerTranscript/index.vue +++ b/kolibri/plugins/media_player/assets/src/views/MediaPlayerTranscript/index.vue @@ -48,7 +48,7 @@ import KCircularLoader from 'kolibri.coreVue.components.KCircularLoader'; import { throttle } from 'frame-throttle'; - import Settings from '../settings'; + import Settings from '../../utils/settings'; import TrackHandler from './trackHandler'; import TranscriptCue from './TranscriptCue'; diff --git a/kolibri/plugins/media_player/assets/src/views/MediaPlayerTranscript/transcriptButton.js b/kolibri/plugins/media_player/assets/src/views/MediaPlayerTranscript/transcriptButton.js index b2eb3f668fa..bedd6e1fecf 100644 --- a/kolibri/plugins/media_player/assets/src/views/MediaPlayerTranscript/transcriptButton.js +++ b/kolibri/plugins/media_player/assets/src/views/MediaPlayerTranscript/transcriptButton.js @@ -88,7 +88,7 @@ class TranscriptButton extends TextTrackButton { * @returns {TranscriptMenuItem|null} */ getActiveItem() { - return this.items.find(item => item.isActive()); + return this.items.find(item => item.isEnabled()); } /** diff --git a/kolibri/plugins/media_player/assets/src/views/textTrackCueAdapter.js b/kolibri/plugins/media_player/assets/src/views/textTrackCueAdapter.js new file mode 100644 index 00000000000..f467100bd65 --- /dev/null +++ b/kolibri/plugins/media_player/assets/src/views/textTrackCueAdapter.js @@ -0,0 +1,59 @@ +import EventEmitter from 'events'; + +/** + * Wraps `TextTrackCue` for special handling + */ +class TextTrackCueAdapter extends EventEmitter { + /** + * @param {TextTrackCue} cue + * @param {TextTrack} track + */ + constructor(cue, track) { + super(); + + this._cue = cue; + this._track = track; + this._id = null; + + ['enter', 'exit'].forEach(event => { + cue.on(event, () => this.emit(event)); + }); + } + + /** + * @return {TextTrack} + */ + get track() { + return this._track; + } + + /** + * @return {String} + */ + get id() { + return this._id || this._cue.id; + } + + /** + * @param {String} id + */ + set id(id) { + this._id = id; + } + + /** + * @return {Number} + */ + get startTime() { + return this._cue.startTime; + } + + /** + * @return {Number} + */ + get endTime() { + return this._cue.endTime; + } +} + +export default TextTrackCueAdapter; diff --git a/kolibri/plugins/media_player/assets/src/views/videojs-style/variables.scss b/kolibri/plugins/media_player/assets/src/views/videojs-style/variables.scss new file mode 100644 index 00000000000..37348213955 --- /dev/null +++ b/kolibri/plugins/media_player/assets/src/views/videojs-style/variables.scss @@ -0,0 +1,8 @@ +/** COLOR PALLETTE **/ +$video-player-color: #212121; +// tint if $video-player-color = black-ish, shade if $video-player-color = white-ish +$video-player-color-2: tint($video-player-color, 7%); +$video-player-color-3: tint($video-player-color, 15%); +$video-player-font-color: white; + +$video-player-font-size: 12px; From cb64e029c6eb0ca5644fc0ed5c79c998c686c08f Mon Sep 17 00:00:00 2001 From: Blaine Jester Date: Tue, 9 Jul 2019 09:18:50 -0700 Subject: [PATCH 14/49] Show active language in menu --- .../src/views/MediaPlayerCaptions/CaptionsMenu.vue | 11 +++++------ .../src/views/MediaPlayerCaptions/captionsMenu.js | 13 +++++++++++++ .../views/MediaPlayerCaptions/captionsMenuItem.js | 9 ++++++++- 3 files changed, 26 insertions(+), 7 deletions(-) diff --git a/kolibri/plugins/media_player/assets/src/views/MediaPlayerCaptions/CaptionsMenu.vue b/kolibri/plugins/media_player/assets/src/views/MediaPlayerCaptions/CaptionsMenu.vue index 9be684ae27f..ed0ecb3cb44 100644 --- a/kolibri/plugins/media_player/assets/src/views/MediaPlayerCaptions/CaptionsMenu.vue +++ b/kolibri/plugins/media_player/assets/src/views/MediaPlayerCaptions/CaptionsMenu.vue @@ -30,7 +30,7 @@ @@ -64,6 +64,10 @@ type: Settings, required: true, }, + activeLanguage: { + type: String, + required: false, + }, }, data: function() { @@ -88,7 +92,6 @@ [constants.KIND_SUBTITLES]: false, [constants.KIND_TRANSCRIPT]: false, }, - // activeLanguage: null, }; }, @@ -100,10 +103,6 @@ return kindNames.length ? kindNames.join(', ') : 'None'; }, - - activeLanguageName() { - return 'Todo'; - }, }, watch: { diff --git a/kolibri/plugins/media_player/assets/src/views/MediaPlayerCaptions/captionsMenu.js b/kolibri/plugins/media_player/assets/src/views/MediaPlayerCaptions/captionsMenu.js index 92ace836704..2d75d86f31b 100644 --- a/kolibri/plugins/media_player/assets/src/views/MediaPlayerCaptions/captionsMenu.js +++ b/kolibri/plugins/media_player/assets/src/views/MediaPlayerCaptions/captionsMenu.js @@ -15,6 +15,7 @@ class CaptionsMenu extends BaseCaptionsMenu { { propsData: { settings: this.getSettings(), + activeLanguage: '', }, }, options @@ -41,6 +42,18 @@ class CaptionsMenu extends BaseCaptionsMenu { */ addItem(item) { this.addChild(item); + + item.on('change', () => this.onChange()); + this.onChange(); + } + + /** + * Handle language change + */ + onChange() { + const activeItem = this.getCaptionItems().find(item => item.isSelected()); + + this.getVueComponent().$props.activeLanguage = activeItem ? activeItem.getLabel() : ''; } /** diff --git a/kolibri/plugins/media_player/assets/src/views/MediaPlayerCaptions/captionsMenuItem.js b/kolibri/plugins/media_player/assets/src/views/MediaPlayerCaptions/captionsMenuItem.js index e2af82fd595..7ba1776ea6f 100644 --- a/kolibri/plugins/media_player/assets/src/views/MediaPlayerCaptions/captionsMenuItem.js +++ b/kolibri/plugins/media_player/assets/src/views/MediaPlayerCaptions/captionsMenuItem.js @@ -27,7 +27,7 @@ class CaptionsMenuItem extends BaseCaptionsMenuItem { Object.assign( { propsData: { - label: this.localize(this.options_.label), + label: this.getLabel(), selected: this.getTrack().isEnabled(), }, }, @@ -70,6 +70,13 @@ class CaptionsMenuItem extends BaseCaptionsMenuItem { return this.options_.track; } + /** + * @return {String} + */ + getLabel() { + return this.localize(this.options_.label); + } + /** * We don't need to handle clicks */ From cfcbd612cc4972607fc4772f50dcf0955d2d44bf Mon Sep 17 00:00:00 2001 From: Blaine Jester Date: Wed, 10 Jul 2019 11:00:34 -0700 Subject: [PATCH 15/49] Refactor state management, use vuex --- .../media_player/assets/src/constants.json | 13 - .../plugins/media_player/assets/src/module.js | 10 + .../assets/src/modules/captions/index.js | 230 ++++++++++++++++++ .../media_player/assets/src/modules/index.js | 39 +++ .../src/track/textTrackLanguageGroup.js | 191 --------------- .../media_player/assets/src/utils/settings.js | 30 ++- .../media_player/assets/src/utils/track.js | 36 ++- .../assets/src/utils/videojsVueConnector.js | 10 +- .../MediaPlayerCaptions/CaptionsMenu.vue | 80 ++---- .../MediaPlayerCaptions/CaptionsMenuItem.vue | 18 +- .../MediaPlayerCaptions/captionsButton.js | 120 +-------- .../views/MediaPlayerCaptions/captionsMenu.js | 43 +--- .../MediaPlayerCaptions/captionsMenuItem.js | 31 +-- .../src/views/MediaPlayerFullscreen/index.vue | 23 +- .../assets/src/views/MediaPlayerIndex.vue | 60 ++--- .../src/views/MediaPlayerTranscript/index.vue | 177 ++++---------- .../MediaPlayerTranscript/trackHandler.js | 85 ------- .../MediaPlayerTranscript/transcript-icon.svg | 1 - .../MediaPlayerTranscript/transcriptButton.js | 116 --------- .../transcriptMenuItem.js | 52 ---- .../transcriptOffMenuItem.js | 40 --- .../assets/src/views/textTrackCueAdapter.js | 59 ----- 22 files changed, 466 insertions(+), 998 deletions(-) delete mode 100644 kolibri/plugins/media_player/assets/src/constants.json create mode 100644 kolibri/plugins/media_player/assets/src/modules/captions/index.js create mode 100644 kolibri/plugins/media_player/assets/src/modules/index.js delete mode 100644 kolibri/plugins/media_player/assets/src/track/textTrackLanguageGroup.js delete mode 100644 kolibri/plugins/media_player/assets/src/views/MediaPlayerTranscript/trackHandler.js delete mode 100644 kolibri/plugins/media_player/assets/src/views/MediaPlayerTranscript/transcript-icon.svg delete mode 100644 kolibri/plugins/media_player/assets/src/views/MediaPlayerTranscript/transcriptButton.js delete mode 100644 kolibri/plugins/media_player/assets/src/views/MediaPlayerTranscript/transcriptMenuItem.js delete mode 100644 kolibri/plugins/media_player/assets/src/views/MediaPlayerTranscript/transcriptOffMenuItem.js delete mode 100644 kolibri/plugins/media_player/assets/src/views/textTrackCueAdapter.js diff --git a/kolibri/plugins/media_player/assets/src/constants.json b/kolibri/plugins/media_player/assets/src/constants.json deleted file mode 100644 index ab5302950bf..00000000000 --- a/kolibri/plugins/media_player/assets/src/constants.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "KINDS": ["captions", "metadata"], - "KIND_SUBTITLES": "captions", - "KIND_TRANSCRIPT": "metadata", - "KIND_ENABLED_MODE": { - "captions": "showing", - "metadata": "hidden" - }, - "KIND_DISABLED_MODE": { - "captions": "disabled", - "metadata": "disabled" - } -} diff --git a/kolibri/plugins/media_player/assets/src/module.js b/kolibri/plugins/media_player/assets/src/module.js index 80c8c24f6de..0e311294522 100644 --- a/kolibri/plugins/media_player/assets/src/module.js +++ b/kolibri/plugins/media_player/assets/src/module.js @@ -1,10 +1,20 @@ +import store from 'kolibri.coreVue.vuex.store'; import MediaPlayerComponent from './views/MediaPlayerIndex'; +import storeModule from './modules'; import ContentRendererModule from 'content_renderer_module'; class MediaPlayerModule extends ContentRendererModule { get rendererComponent() { return MediaPlayerComponent; } + + get store() { + return store; + } + + ready() { + this.store.registerModule('mediaPlayer', storeModule); + } } const mediaPlayerModule = new MediaPlayerModule(); diff --git a/kolibri/plugins/media_player/assets/src/modules/captions/index.js b/kolibri/plugins/media_player/assets/src/modules/captions/index.js new file mode 100644 index 00000000000..23a6aab11ee --- /dev/null +++ b/kolibri/plugins/media_player/assets/src/modules/captions/index.js @@ -0,0 +1,230 @@ +import vue from 'kolibri.lib.vue'; +import videojs from 'video.js'; +import trackUtils from '../../utils/track'; +import Settings from '../../utils/settings'; + +const { handleSelectedLanguageChange } = videojs.getComponent('TextTrackMenuItem').prototype; + +/** + * @return {{captionLanguage: *, captionSubtitles: boolean, captionTranscript: boolean}} + */ +const defaultSettings = () => ({ + captionLanguage: vue.locale, + captionSubtitles: false, + captionTranscript: false, +}); + +/** + * @param state + * @return {TextTrack[]} + */ +const tracks = state => { + return trackUtils.listToArray(state.trackList || []); +}; + +export default { + namespaced: true, + state: () => { + const settings = new Settings(defaultSettings()); + + return { + language: settings.captionLanguage, + subtitles: settings.captionSubtitles, + transcript: settings.captionTranscript, + + trackList: null, + cueList: null, + activeCueList: null, + + trackListeners: [], + }; + }, + mutations: { + SET_LANGUAGE(state, language) { + state.language = language; + }, + SET_SUBTITLES(state, subtitles) { + state.subtitles = subtitles; + }, + SET_TRANSCRIPT(state, transcript) { + state.transcript = transcript; + }, + SET_TRACK_LIST(state, trackList) { + state.trackList = trackList; + }, + SET_CUE_LIST(state, cueList) { + state.cueList = cueList; + }, + SET_ACTIVE_CUE_LIST(state, cueList) { + state.activeCueList = cueList; + }, + ADD_TRACK_LISTENERS(state, trackId, event, listeners) { + state.trackListeners.push({ trackId, event, listeners }); + }, + RESET_TRACK_LISTENERS(state) { + state.trackListeners = []; + }, + }, + getters: { + /** + * @param state + * @return {string} + */ + languageLabel(state) { + const track = tracks(state).find(track => state.language === track.language); + return track ? track.label : ''; + }, + + /** + * @param state + * @return {TextTrackCue[]} + */ + cues(state) { + return trackUtils.listToArray(state.cueList || []); + }, + + /** + * @param state + * @return {String[]} + */ + activeCueIds(state) { + return trackUtils + .listToArray(state.activeCueList || []) + .map(cue => cue.id) + .filter(Boolean); + }, + + /** + * @param state + * @return {TextTrack[]} + */ + tracks, + + /** + * @param state + * @return {TextTrack} + */ + activeTrack(state) { + return tracks(state).find(track => trackUtils.isEnabled(track)); + }, + }, + actions: { + setLanguage(store, language) { + if (store.state.language === language) { + return; + } + + store.commit('SET_LANGUAGE', language); + + const settings = new Settings(defaultSettings()); + settings.captionLanguage = language; + store.dispatch('synchronizeTrackList'); + + // Retain video.js behavior on language change, + // see TextTrackMenuItem.handleSelectedLanguageChange + store.dispatch( + 'mediaPlayer/withPlayer', + player_ => { + handleSelectedLanguageChange.call({ + track: store.getters.activeTrack, + player_, + }); + }, + { root: true } + ); + }, + + setTrackList(store, trackList) { + if (store.state.trackList) { + store.state.trackListeners.forEach(({ trackId, event, listener }) => { + const track = store.getters.tracks.find(track => track.id === trackId); + + if (track) { + track.removeEventListener(event, listener); + } + }); + + store.commit('RESET_TRACK_LISTENERS'); + } + + store.commit('SET_TRACK_LIST', trackList); + store.dispatch('synchronizeTrackList'); + + store.getters.tracks.forEach(track => { + const changeListener = () => { + if (trackUtils.isEnabled(track)) { + store.dispatch('setActiveCuesFromTrack', track); + } + }; + track.addEventListener('cuechange', changeListener); + store.commit('ADD_TRACK_LISTENERS', track.id, 'cuechange', changeListener); + + if (track.addCue.overridden) { + return; + } + + // Override `addCue` method to hook into the addition of cues + const addCue = track.addCue.bind(track); + track.addCue = (...args) => { + const result = addCue(...args); + store.dispatch('setCuesFromTrack', track); + store.dispatch('setActiveCuesFromTrack', track); + return result; + }; + track.addCue.overridden = true; + }); + }, + + updateTrackList(store, trackList) { + if (store.state.trackList.length !== trackList.length) { + return store.dispatch('setTrackList', trackList); + } + + store.commit('SET_TRACK_LIST', trackList); + store.dispatch('synchronizeTrackList'); + }, + + setCuesFromTrack(store, track) { + store.commit('SET_CUE_LIST', track.cues); + // Ensure cues have ids + store.getters.cues.forEach((cue, i) => { + cue.id = track.id + '-cue-' + i; + }); + }, + + setActiveCuesFromTrack(store, track) { + store.commit('SET_ACTIVE_CUE_LIST', track.activeCues); + }, + + synchronizeTrackList(store) { + const { language, subtitles, transcript } = store.state; + const settings = new Settings(defaultSettings()); + settings.captionSubtitles = subtitles; + settings.captionTranscript = transcript; + + store.getters.tracks.forEach(track => { + if (track.language === language) { + trackUtils.setMode(track, subtitles || transcript, !subtitles); + } else { + trackUtils.setMode(track, false); + } + + if (trackUtils.isEnabled(track)) { + store.dispatch('setCuesFromTrack', track); + store.dispatch('setActiveCuesFromTrack', track); + store.dispatch('setLanguage', track.language); + } + }); + }, + + toggleSubtitles(store) { + store.commit('SET_SUBTITLES', !store.state.subtitles); + store.dispatch('synchronizeTrackList'); + }, + + toggleTranscript(store) { + store.commit('SET_TRANSCRIPT', !store.state.transcript); + store.dispatch('synchronizeTrackList'); + }, + }, +}; diff --git a/kolibri/plugins/media_player/assets/src/modules/index.js b/kolibri/plugins/media_player/assets/src/modules/index.js new file mode 100644 index 00000000000..df1306709e6 --- /dev/null +++ b/kolibri/plugins/media_player/assets/src/modules/index.js @@ -0,0 +1,39 @@ +import captions from './captions'; + +export default { + namespaced: true, + state: { + player: null, + }, + mutations: { + SET_PLAYER(state, player) { + state.player = player; + }, + }, + getters: {}, + actions: { + setPlayer(store, player) { + if (store.state.player) { + store.state.player.dispose(); + store.commit('SET_PLAYER', null); + } + + player.one('loadstart', () => { + store.dispatch('captions/setTrackList', player.textTracks()); + + player.on('texttrackchange', () => { + store.dispatch('captions/updateTrackList', player.textTracks()); + }); + }); + + store.commit('SET_PLAYER', player); + }, + + withPlayer(store, callback) { + return callback(store.state.player); + }, + }, + modules: { + captions, + }, +}; diff --git a/kolibri/plugins/media_player/assets/src/track/textTrackLanguageGroup.js b/kolibri/plugins/media_player/assets/src/track/textTrackLanguageGroup.js deleted file mode 100644 index dd57da2b32f..00000000000 --- a/kolibri/plugins/media_player/assets/src/track/textTrackLanguageGroup.js +++ /dev/null @@ -1,191 +0,0 @@ -import EventEmitter from 'events'; -import constants from '../constants.json'; -import trackUtils from '../utils/track'; - -// This class will pretend to be of "subtitle" kind -const KIND = constants.KIND_SUBTITLES; -const ENABLED_MODE = constants.KIND_ENABLED_MODE[KIND]; -const DISABLED_MODE = constants.KIND_DISABLED_MODE[KIND]; - -/** - * Groups different kinds of text tracks by common language. Specifically, in our use case, we're - * duplicating the same text track as both 'captions' and 'metadata' kinds. The 'caption' kind is - * for captions displayed as subtitles and the 'metadata' kind is for the transcript. This allows - * us to use the built-in language selector of video.js without it affecting an actual track. - */ -class TextTrackLanguageGroup extends EventEmitter { - /** - * @param {String} language - * @param {TextTrack[]} tracks - */ - constructor(language, tracks) { - super(); - - this._mode = DISABLED_MODE; - this._language = language; - this._tracks = tracks; - this._id = null; - this._disabledKinds = []; - - tracks.forEach(track => { - if (track.language !== language) { - throw new Error('Mismatched track language'); - } - - if (trackUtils.isEnabled(track)) { - this.enableKind(track.kind); - } else { - this.disableKind(track.kind); - } - - track.on('trackchange', () => { - if (trackUtils.isEnabled(track)) { - this.enableKind(track.kind); - } - }); - }); - } - - /** - * @return {TextTrackCueList|null} - */ - get activeCues() { - const activeTrack = this.findEnabled(); - return activeTrack ? activeTrack.activeCues : null; - } - - /** - * Return cues from the first track that has cues - * - * @return {TextTrackCueList} - */ - get cues() { - return this._tracks - .map(track => track.cues) - .filter(cues => cues && cues.length) - .shift(); - } - - /** - * @return {String} - */ - get id() { - return this._id || this._tracks[0].id; - } - - /** - * @param {String} id - */ - set id(id) { - this._id = id; - } - - /** - * @return {String} - */ - get kind() { - // We'll simply return one kind - return KIND; - } - - /** - * @return {String} - */ - get label() { - return this._tracks[0].label; - } - - /** - * @return {String} - */ - get language() { - return this._language; - } - - /** - * @return {String} - */ - get mode() { - return this._mode; - } - - /** - * @param {String} mode - */ - set mode(mode) { - const enabling = trackUtils.isEnabledMode(mode); - - this._mode = enabling ? ENABLED_MODE : DISABLED_MODE; - - this._tracks.forEach(track => { - if (this._disabledKinds.find(disabledKind => track.kind === disabledKind)) { - return; - } - - // Get the enabled/disabled mode for each track based on its kind - const newMode = enabling - ? trackUtils.getEnabledMode(track) - : trackUtils.getDisabledMode(track); - - if (track.mode !== newMode) { - track.mode = newMode; - this.emit(enabling ? 'enable' : 'disable'); - } - }); - } - - /** - * @param {TextTrackCue} cue - */ - addCue(cue) { - this._tracks.forEach(track => { - track.addCue(cue); - }); - } - - enable() { - this.mode = ENABLED_MODE; - } - - disable() { - this.mode = DISABLED_MODE; - } - - enableKind(kind) { - this._disabledKinds = this._disabledKinds.filter(disabledKind => disabledKind !== kind); - - if (this.isEnabled()) { - this._tracks.filter(track => track.kind === kind).forEach(track => { - track.mode = trackUtils.getEnabledMode(track); - }); - } - - this.emit('enableKind', kind); - } - - disableKind(kind) { - this._disabledKinds.push(kind); - - this._tracks.filter(track => track.kind === kind).forEach(track => { - track.mode = trackUtils.getDisabledMode(track); - }); - - this.emit('disableKind', kind); - } - - /** - * @return {boolean} - */ - isEnabled() { - return this._mode === ENABLED_MODE; - } - - /** - * @return {TextTrack|null} - */ - findEnabled() { - return this._tracks.find(track => trackUtils.isEnabled(track)); - } -} - -export default TextTrackLanguageGroup; diff --git a/kolibri/plugins/media_player/assets/src/utils/settings.js b/kolibri/plugins/media_player/assets/src/utils/settings.js index 898112f55d1..08b8b8db5d1 100755 --- a/kolibri/plugins/media_player/assets/src/utils/settings.js +++ b/kolibri/plugins/media_player/assets/src/utils/settings.js @@ -5,6 +5,12 @@ const MEDIA_PLAYER_SETTINGS_KEY = 'kolibriMediaPlayerSettings'; class Settings { constructor(defaults = {}) { this._defaults = defaults; + + // `videoLangCode` predates `captionLanguage` code, so migrate it + if (this.videoLangCode && this.videoLangCode !== this.captionLanguage) { + this.captionLanguage = this.videoLangCode; + this.videoLangCode = null; + } } set playerMuted(playerMuted) { @@ -31,14 +37,6 @@ class Settings { return this.get().playerVolume; } - set captionKinds(captionKinds) { - return this.save({ captionKinds }); - } - - get captionKinds() { - return this.get().captionKinds; - } - set captionLanguage(captionLanguage) { return this.save({ captionLanguage }); } @@ -47,20 +45,20 @@ class Settings { return this.get().captionLanguage; } - set transcriptShowing(transcriptShowing) { - return this.save({ transcriptShowing }); + set captionSubtitles(captionSubtitles) { + return this.save({ captionSubtitles }); } - get transcriptShowing() { - return this.get().transcriptShowing; + get captionSubtitles() { + return this.get().captionSubtitles; } - set transcriptLangCode(transcriptLangCode) { - return this.save({ transcriptLangCode }); + set captionTranscript(captionTranscript) { + return this.save({ captionTranscript }); } - get transcriptLangCode() { - return this.get().transcriptLangCode; + get captionTranscript() { + return this.get().captionTranscript; } set videoLangCode(videoLangCode) { diff --git a/kolibri/plugins/media_player/assets/src/utils/track.js b/kolibri/plugins/media_player/assets/src/utils/track.js index d7ae6eafadf..318d09fd923 100644 --- a/kolibri/plugins/media_player/assets/src/utils/track.js +++ b/kolibri/plugins/media_player/assets/src/utils/track.js @@ -1,4 +1,6 @@ -import constants from '../constants.json'; +export const MODE_SHOWING = 'showing'; +export const MODE_HIDDEN = 'hidden'; +export const MODE_DISABLED = 'disabled'; export default { /** @@ -6,31 +8,27 @@ export default { * @return {boolean} */ isEnabledMode(mode) { - return Object.values(constants.KIND_ENABLED_MODE).indexOf(mode) >= 0; + return mode === MODE_SHOWING || mode === MODE_HIDDEN; }, /** + * Setting mode can cause events, which could cause loop if we don't make sure that the mode + * isn't already the mode we're going to set + * * @param {TextTrack} track - * @return {String} + * @param {Boolean} enabled + * @param {Boolean} [hidden] */ - getEnabledMode(track) { - if (!(track.kind in constants.KIND_ENABLED_MODE)) { - throw new Error('Unknown track kind'); - } - - return constants.KIND_ENABLED_MODE[track.kind]; - }, + setMode(track, enabled, hidden = false) { + let mode = MODE_DISABLED; - /** - * @param {TextTrack} track - * @return {String} - */ - getDisabledMode(track) { - if (!(track.kind in constants.KIND_DISABLED_MODE)) { - throw new Error('Unknown track kind'); + if (enabled) { + mode = hidden ? MODE_HIDDEN : MODE_SHOWING; } - return constants.KIND_DISABLED_MODE[track.kind]; + if (track.mode !== mode) { + track.mode = mode; + } }, /** @@ -38,7 +36,7 @@ export default { * @return {boolean} */ isEnabled(track) { - return track.mode === this.getEnabledMode(track); + return this.isEnabledMode(track.mode); }, /** diff --git a/kolibri/plugins/media_player/assets/src/utils/videojsVueConnector.js b/kolibri/plugins/media_player/assets/src/utils/videojsVueConnector.js index c375c7e5b42..d7a7341fece 100644 --- a/kolibri/plugins/media_player/assets/src/utils/videojsVueConnector.js +++ b/kolibri/plugins/media_player/assets/src/utils/videojsVueConnector.js @@ -1,4 +1,5 @@ import Vue from 'kolibri.lib.vue'; +import store from 'kolibri.coreVue.vuex.store'; import videojs from 'video.js'; /** @@ -27,7 +28,7 @@ export default function videojsVueConnector(videojsComponent, vueComponent) { */ createVueComponent(options) { this.clearVueComponent(); - this._vueComponent = new VueComponent(options).$mount(); + this._vueComponent = new VueComponent(Object.assign({ store }, options)).$mount(); return this.getVueComponent(); } @@ -48,13 +49,6 @@ export default function videojsVueConnector(videojsComponent, vueComponent) { } } - /** - * @return {Settings} - */ - getSettings() { - return this.options_.settings; - } - /** * video.js hook to dispose this video.js component, so be sure to `clearComponent` */ diff --git a/kolibri/plugins/media_player/assets/src/views/MediaPlayerCaptions/CaptionsMenu.vue b/kolibri/plugins/media_player/assets/src/views/MediaPlayerCaptions/CaptionsMenu.vue index ed0ecb3cb44..8dafa828227 100644 --- a/kolibri/plugins/media_player/assets/src/views/MediaPlayerCaptions/CaptionsMenu.vue +++ b/kolibri/plugins/media_player/assets/src/views/MediaPlayerCaptions/CaptionsMenu.vue @@ -15,22 +15,28 @@ @toggle="isKindOpen = $event" >
    - + +
@@ -47,59 +53,33 @@ diff --git a/kolibri/plugins/media_player/assets/src/views/MediaPlayerCaptions/captionsButton.js b/kolibri/plugins/media_player/assets/src/views/MediaPlayerCaptions/captionsButton.js index 1371488006c..e9a64b986e0 100644 --- a/kolibri/plugins/media_player/assets/src/views/MediaPlayerCaptions/captionsButton.js +++ b/kolibri/plugins/media_player/assets/src/views/MediaPlayerCaptions/captionsButton.js @@ -1,18 +1,16 @@ import videojs from 'video.js'; -import constants from '../../constants.json'; import trackUtils from '../../utils/track'; -import TextTrackLanguageGroup from '../../track/textTrackLanguageGroup'; import CaptionsMenu from './captionsMenu'; import CaptionsMenuItem from './captionsMenuItem'; const TextTrackButton = videojs.getComponent('TextTrackButton'); -const { handleSelectedLanguageChange } = videojs.getComponent('TextTrackMenuItem').prototype; /** * The Component for the Button that will open the CaptionsMenu */ class CaptionsButton extends TextTrackButton { /** + * @override * @return {CaptionsMenu} */ createMenu() { @@ -23,7 +21,6 @@ class CaptionsButton extends TextTrackButton { const menu = new CaptionsMenu(this.player(), { menuButton: this, - settings: this.getSettings(), }); this.hideThreshold_ = 0; @@ -95,6 +92,7 @@ class CaptionsButton extends TextTrackButton { } /** + * @override * @return {string} */ buildCSSClass() { @@ -102,6 +100,7 @@ class CaptionsButton extends TextTrackButton { } /** + * @override * @return {string} */ buildWrapperCSSClass() { @@ -110,6 +109,7 @@ class CaptionsButton extends TextTrackButton { /** * @see https://github.com/videojs/video.js/blob/v7.4.1/src/js/control-bar/text-track-controls/text-track-button.js#L40 + * @override * @returns {CaptionsMenuItem[]} */ createItems() { @@ -120,114 +120,14 @@ class CaptionsButton extends TextTrackButton { return []; } - // Filter tracks to the kinds we care about, and group by language - const trackGroups = tracks - .filter(track => constants.KINDS.find(kind => kind === track.kind)) - .reduce((trackGroups, track) => { - if (!(track.language in trackGroups)) { - trackGroups[track.language] = []; - } - - trackGroups[track.language].push(track); - return trackGroups; - }, {}); - - return Object.entries(trackGroups).map(([language, tracks]) => - this.createItem(language, tracks) - ); - } - - /** - * @param {String} language - * @param {TextTrack[]} tracks - * @return {CaptionsMenuItem} - */ - createItem(language, tracks) { - const trackList = this.player().textTracks(); - const settings = this.getSettings(); - - const track = new TextTrackLanguageGroup(language, tracks); - - if (settings.captionLanguage === language) { - track.enable(); - } else { - track.disable(); - } - - constants.KINDS.forEach(kind => { - if (!settings.captionKinds.find(captionKind => kind === captionKind)) { - track.disableKind(kind); - } - }); - - track - .on('enable', () => { - settings.captionLanguage = language; - }) - .on('enableKind', kind => { - settings.captionKinds = settings.captionKinds.concat([kind]); - }) - .on('disableKind', kind => { - settings.captionKinds = settings.captionKinds.filter(captionKind => captionKind !== kind); - }); - - const item = new CaptionsMenuItem(this.player(), { - track, - selectable: true, - multiSelectable: false, - selected: track.isEnabled(), - }); - - const changeHandler = () => { - if (!track.isEnabled() || !this.menu) { - return; - } - - this.menu.getCaptionItems().forEach(otherItem => { - if (otherItem.getTrack().id !== track.id) { - otherItem.selected(false); - } + return tracks.map(track => { + return new CaptionsMenuItem(this.player(), { + track, + selectable: true, + multiSelectable: false, + selected: trackUtils.isEnabled(track), }); - }; - - const selectedLanguageChangeHandler = handleSelectedLanguageChange.bind({ - track, - player_: this.player(), - }); - - item.on('change', changeHandler); - trackList.addEventListener('selectedlanguagechange', selectedLanguageChangeHandler); - - item.on('dispose', () => { - item.off('change', changeHandler); - trackList.removeEventListener('selectedlanguagechange', selectedLanguageChangeHandler); }); - - return item; - } - - /** - * @return {TextTrackLanguageGroup[]} - */ - getTracks() { - return this.items ? this.items.map(item => item.getTrack()) : []; - } - - /** - * @return {TextTrack} - */ - getActiveTrack() { - return this.items - .filter(item => item instanceof CaptionsMenuItem && item.isSelected()) - .map(item => item.getTrack()) - .shift(); - } - - /** - * @return {Settings} - */ - getSettings() { - return this.options_.settings; } } diff --git a/kolibri/plugins/media_player/assets/src/views/MediaPlayerCaptions/captionsMenu.js b/kolibri/plugins/media_player/assets/src/views/MediaPlayerCaptions/captionsMenu.js index 2d75d86f31b..d6cf60522b6 100644 --- a/kolibri/plugins/media_player/assets/src/views/MediaPlayerCaptions/captionsMenu.js +++ b/kolibri/plugins/media_player/assets/src/views/MediaPlayerCaptions/captionsMenu.js @@ -1,6 +1,5 @@ import connector from '../../utils/videojsVueConnector'; import captionsMenu from './CaptionsMenu.vue'; -import CaptionsMenuItem from './captionsMenuItem'; const BaseCaptionsMenu = connector('Menu', captionsMenu); @@ -14,7 +13,6 @@ class CaptionsMenu extends BaseCaptionsMenu { Object.assign( { propsData: { - settings: this.getSettings(), activeLanguage: '', }, }, @@ -29,6 +27,7 @@ class CaptionsMenu extends BaseCaptionsMenu { * `contentEl` is used when `addItem` is called, so this allows the addition of the text track * options (the languages) in the right spot * + * @override * @return {*|Element} */ contentEl() { @@ -38,47 +37,26 @@ class CaptionsMenu extends BaseCaptionsMenu { /** * Override parent's method, which adds event handlers we don't want * + * @override * @param {CaptionsMenuItem|Component|String} item The name or instance of the item to add */ addItem(item) { this.addChild(item); - - item.on('change', () => this.onChange()); - this.onChange(); - } - - /** - * Handle language change - */ - onChange() { - const activeItem = this.getCaptionItems().find(item => item.isSelected()); - - this.getVueComponent().$props.activeLanguage = activeItem ? activeItem.getLabel() : ''; - } - - /** - * @param {String} kind - * @param {Boolean} isActive - */ - handleKindChange(kind, isActive) { - this.getCaptionItems().forEach(item => { - if (isActive) { - item.getTrack().enableKind(kind); - } else { - item.getTrack().disableKind(kind); - } - }); } /** * Disables default show/hide functionality, which is triggered on hover. `lockShowing()` gets * called instead on click. + * + * @override */ show() {} hide() {} /** * Triggered on click in ancestor + * + * @override */ lockShowing() { const component = this.getVueComponent(); @@ -93,6 +71,8 @@ class CaptionsMenu extends BaseCaptionsMenu { /** * Triggered on blur in ancestor + * + * @override */ unlockShowing() { const component = this.getVueComponent(); @@ -104,13 +84,6 @@ class CaptionsMenu extends BaseCaptionsMenu { component.hide(); this.trigger('hide'); } - - /** - * @return {CaptionsMenuItem[]} - */ - getCaptionItems() { - return this.children().filter(item => item instanceof CaptionsMenuItem); - } } export default CaptionsMenu; diff --git a/kolibri/plugins/media_player/assets/src/views/MediaPlayerCaptions/captionsMenuItem.js b/kolibri/plugins/media_player/assets/src/views/MediaPlayerCaptions/captionsMenuItem.js index 7ba1776ea6f..d44302ed4f2 100644 --- a/kolibri/plugins/media_player/assets/src/views/MediaPlayerCaptions/captionsMenuItem.js +++ b/kolibri/plugins/media_player/assets/src/views/MediaPlayerCaptions/captionsMenuItem.js @@ -28,7 +28,7 @@ class CaptionsMenuItem extends BaseCaptionsMenuItem { { propsData: { label: this.getLabel(), - selected: this.getTrack().isEnabled(), + value: this.getTrack().language, }, }, options @@ -43,42 +43,27 @@ class CaptionsMenuItem extends BaseCaptionsMenuItem { } /** - * @override - * @param selected - */ - selected(selected) { - this.getVueComponent().$props.selected = selected; - - if (selected) { - this.getTrack().enable(); - } else { - this.getTrack().disable(); - } - } - - /** - * @return {boolean} + * @return {String} */ - isSelected() { - return this.getTrack().isEnabled(); + getLabel() { + return this.localize(this.options_.label); } /** - * @return {TextTrackLanguageGroup} + * @return {TextTrack} */ getTrack() { return this.options_.track; } /** - * @return {String} + * @override */ - getLabel() { - return this.localize(this.options_.label); - } + selected() {} /** * We don't need to handle clicks + * @override */ handleClick() {} } diff --git a/kolibri/plugins/media_player/assets/src/views/MediaPlayerFullscreen/index.vue b/kolibri/plugins/media_player/assets/src/views/MediaPlayerFullscreen/index.vue index bbcb415bb63..7d48bdc7587 100755 --- a/kolibri/plugins/media_player/assets/src/views/MediaPlayerFullscreen/index.vue +++ b/kolibri/plugins/media_player/assets/src/views/MediaPlayerFullscreen/index.vue @@ -9,6 +9,7 @@ - - - diff --git a/kolibri/plugins/media_player/assets/src/views/MediaPlayerCaptions/captionsButton.js b/kolibri/plugins/media_player/assets/src/views/MediaPlayerCaptions/captionsButton.js index e9a64b986e0..9c570e9f775 100644 --- a/kolibri/plugins/media_player/assets/src/views/MediaPlayerCaptions/captionsButton.js +++ b/kolibri/plugins/media_player/assets/src/views/MediaPlayerCaptions/captionsButton.js @@ -1,94 +1,14 @@ -import videojs from 'video.js'; -import trackUtils from '../../utils/track'; +import buttonMixin from '../../mixins/videojsButtonMixin'; import CaptionsMenu from './captionsMenu'; -import CaptionsMenuItem from './captionsMenuItem'; - -const TextTrackButton = videojs.getComponent('TextTrackButton'); /** * The Component for the Button that will open the CaptionsMenu */ -class CaptionsButton extends TextTrackButton { - /** - * @override - * @return {CaptionsMenu} - */ - createMenu() { - if (this.items) { - this.items.forEach(item => item.dispose()); - this.items = []; - } - - const menu = new CaptionsMenu(this.player(), { +class CaptionsButton extends buttonMixin('TextTrackButton') { + buildMenu() { + return new CaptionsMenu(this.player(), { menuButton: this, }); - - this.hideThreshold_ = 0; - - this.items = this.createItems(); - this.items.forEach(item => menu.addItem(item)); - - menu.on('show', () => { - this.addClass('active'); - - this._wasPlaying = !this.player().paused(); - - // Handlers to trigger a "click" when menu should exited - this._playListener = () => this.handleClick(); - this._blurListener = e => { - // Don't hide the menu if event target is one of our elements - if ( - this.el().contains(e.target) || - menu.el().contains(e.target) || - e.target === this.el() || - e.target === menu.el() - ) { - return; - } - - this.handleClick(); - }; - - if (this._wasPlaying) { - this.player().pause(); - } - - this.player().one('play', this._playListener); - this.on(document, 'click', this._blurListener); - }); - - menu.on('hide', () => { - this.removeClass('active'); - - if (this._playListener) { - this.player().off('play', this._playListener); - } - - if (this._blurListener) { - this.off(document, 'click', this._blurListener); - } - - // Restore video to playing after close, if it was playing when it was opened - if (this._wasPlaying && this.player().paused()) { - this.player().play(); - } - - this._wasPlaying = false; - this._playListener = null; - this._blurListener = null; - }); - - return menu; - } - - /** - * Removes class that adds specific functionality we don't want - * - * @param {String} classNames - * @return {String} - */ - removePopupClass(classNames) { - return classNames.replace(/\bvjs-menu-button-popup\b/, ' '); } /** @@ -108,26 +28,15 @@ class CaptionsButton extends TextTrackButton { } /** - * @see https://github.com/videojs/video.js/blob/v7.4.1/src/js/control-bar/text-track-controls/text-track-button.js#L40 - * @override - * @returns {CaptionsMenuItem[]} + * Items handled in Vue component + * @return {Array} */ createItems() { - const player = this.player(); - const tracks = trackUtils.listToArray(player.textTracks()); - - if (!tracks.length) { - return []; - } + const length = super.createItems().length; - return tracks.map(track => { - return new CaptionsMenuItem(this.player(), { - track, - selectable: true, - multiSelectable: false, - selected: trackUtils.isEnabled(track), - }); - }); + // Set hide threshold so the button and menu are hidden when not needed + this.hideThreshold_ = length > 1 ? -1 : 0; + return []; } } diff --git a/kolibri/plugins/media_player/assets/src/views/MediaPlayerCaptions/captionsMenu.js b/kolibri/plugins/media_player/assets/src/views/MediaPlayerCaptions/captionsMenu.js index f9a04110108..b1dfa8e85d6 100644 --- a/kolibri/plugins/media_player/assets/src/views/MediaPlayerCaptions/captionsMenu.js +++ b/kolibri/plugins/media_player/assets/src/views/MediaPlayerCaptions/captionsMenu.js @@ -1,70 +1,6 @@ -import connector from '../../utils/videojsVueConnector'; +import mixin from '../../mixins/videojsMenuVueMixin'; import captionsMenu from './CaptionsMenu.vue'; -const BaseCaptionsMenu = connector('Menu', captionsMenu); - -class CaptionsMenu extends BaseCaptionsMenu { - /** - * `contentEl` is used when `addItem` is called, so this allows the addition of the text track - * options (the languages) in the right spot - * - * @override - * @return {*|Element} - */ - contentEl() { - return this.getVueComponent().contentEl(); - } - - /** - * Override parent's method, which adds event handlers we don't want - * - * @override - * @param {CaptionsMenuItem|Component|String} item The name or instance of the item to add - */ - addItem(item) { - this.addChild(item); - } - - /** - * Disables default show/hide functionality, which is triggered on hover. `lockShowing()` gets - * called instead on click. - * - * @override - */ - show() {} - hide() {} - - /** - * Triggered on click in ancestor - * - * @override - */ - lockShowing() { - const component = this.getVueComponent(); - - if (!component || component.showing()) { - return; - } - - component.show(); - this.trigger('show'); - } - - /** - * Triggered on blur in ancestor - * - * @override - */ - unlockShowing() { - const component = this.getVueComponent(); - - if (!component || !component.showing()) { - return; - } - - component.hide(); - this.trigger('hide'); - } -} +class CaptionsMenu extends mixin(captionsMenu) {} export default CaptionsMenu; diff --git a/kolibri/plugins/media_player/assets/src/views/MediaPlayerIndex.vue b/kolibri/plugins/media_player/assets/src/views/MediaPlayerIndex.vue index c37b13db312..d3c84a67816 100644 --- a/kolibri/plugins/media_player/assets/src/views/MediaPlayerIndex.vue +++ b/kolibri/plugins/media_player/assets/src/views/MediaPlayerIndex.vue @@ -14,7 +14,7 @@ 'wrapper', { 'video-loading': loading, - 'transcript-visible': transcript, + 'transcript-visible': transcriptVisible, 'transcript-wrap': windowIsPortrait || (!isFullscreen && windowIsSmall), }, $computedClass(progressStyle) @@ -61,7 +61,7 @@ - +
@@ -71,7 +71,7 @@ + + + diff --git a/kolibri/plugins/media_player/assets/src/views/MediaPlayerLanguages/LanguagesMenu.vue b/kolibri/plugins/media_player/assets/src/views/MediaPlayerLanguages/LanguagesMenu.vue new file mode 100644 index 00000000000..4d97809959f --- /dev/null +++ b/kolibri/plugins/media_player/assets/src/views/MediaPlayerLanguages/LanguagesMenu.vue @@ -0,0 +1,39 @@ + + + + + + + diff --git a/kolibri/plugins/media_player/assets/src/views/MediaPlayerCaptions/CaptionsMenuItem.vue b/kolibri/plugins/media_player/assets/src/views/MediaPlayerLanguages/LanguagesMenuItem.vue similarity index 87% rename from kolibri/plugins/media_player/assets/src/views/MediaPlayerCaptions/CaptionsMenuItem.vue rename to kolibri/plugins/media_player/assets/src/views/MediaPlayerLanguages/LanguagesMenuItem.vue index 2e95970f077..3ca6e54d8c8 100644 --- a/kolibri/plugins/media_player/assets/src/views/MediaPlayerCaptions/CaptionsMenuItem.vue +++ b/kolibri/plugins/media_player/assets/src/views/MediaPlayerLanguages/LanguagesMenuItem.vue @@ -1,13 +1,13 @@