FreeTube/src/renderer/components/ft-video-player/ft-video-player.js

2310 lines
78 KiB
JavaScript
Raw Normal View History

import { defineComponent } from 'vue'
import { mapActions } from 'vuex'
2020-02-16 19:30:00 +01:00
import videojs from 'video.js'
import qualitySelector from '@silvermine/videojs-quality-selector'
import fs from 'fs/promises'
import path from 'path'
import 'videojs-overlay/dist/videojs-overlay'
import 'videojs-overlay/dist/videojs-overlay.css'
import 'videojs-vtt-thumbnails-freetube'
import 'videojs-contrib-quality-levels'
import 'videojs-http-source-selector'
Improving touch input by bringing in `videojs-mobile-ui` (#2719) * Adding videojs-mobile-ui as a dependency - Using the beta because it fixes an issue with multiple version of videojs loading at once. This is related to MarmadileManteater#56. * Mapping defaultSkipInterval to seekSeconds * Adding CSS to prevent showing duplicate buttons - Added a new variable `usingTouch` to selectively hide the existing `vjs-big-play-button` when the `touch-overlay` is visible. * Renaming CSS class to something more specific * Adding text-shadow behind play / pause button This should make it more visually distinct against a light or colorful background. * Enabling touch-overlay anytime a touch is detected Disabling it whenever mouse input is detected The default behavior of `videojs-mobile-ui` is to only work in Android and iOS, but by forcing the touch behavior to be enabled and selectively showing it only when touch input is detected, it should work on any device with touch input even if the browser doesn't detect that it is running in Android or iOS. * Removing unnecessary code * Removing unintentionally leftover variable * Removing an unnecessary assignment Adding comments to explain why a flag called `forceForTesting` is set to true Disabling the `lockOnRotate` flag. * Moving this flag and wrapper class * Adding whitespace * Making my comment a little more consistent * Changing the punctuation of a comment * Adjusting the CSS to fix a firefox discrepancy * Adding a check for mobile firefox For whatever reason, mobile firefox sometimes triggers onmouseover when a touch occurs, and this is unwanted behavior. * Adding a drop-shadow to the ff and rw icons This should make them easier to see on top of light videos * Hiding the mobile play button according to setting - Added CSS to hide the videojs-mobile-ui play button when the `Display Play Button In Video Player` setting is disabled. * Replacing long computed string with class binding Co-authored-by: absidue <48293849+absidue@users.noreply.github.com> * Adding newline at the end of videoJS.css Co-authored-by: absidue <48293849+absidue@users.noreply.github.com>
2022-10-24 19:49:52 +02:00
import 'videojs-mobile-ui'
import 'videojs-mobile-ui/dist/videojs-mobile-ui.css'
Store Revamp / Full database synchronization across windows (#1833) * History: Refactor history module * Profiles: Refactor profiles module * IPC: Move channel ids to their own file and make them constants * IPC: Replace single sync channel for one channel per sync type * Everywhere: Replace default profile id magic strings with constant ref * Profiles: Refactor `activeProfile` property from store This commit makes it so that `activeProfile`'s getter returns the entire profile, while the related update function only needs the profile id (instead of the previously used array index) to change the currently active profile. This change was made due to inconsistency regarding the active profile when creating new profiles. If a new profile coincidentally landed in the current active profile's array index after sorting, the app would mistakenly change to it without any action from the user apart from the profile's creation. Turning the profile id into the selector instead solves this issue. * Revert "Store: Implement history synchronization between windows" This reverts commit 99b61e617873412eb393d8f4dfccd8f8c172021f. This is necessary for an upcoming improved implementation of the history synchronization. * History: Remove unused mutation * Everywhere: Create abstract database handlers The project now utilizes abstract handlers to fetch, modify or otherwise manipulate data from the database. This facilitates 3 aspects of the app, in addition of making them future proof: - Switching database libraries is now trivial Since most of the app utilizes the abstract handlers, it's incredibly easily to change to a different DB library. Hypothetically, all that would need to be done is to simply replace the the file containing the base handlers, while the rest of the app would go unchanged. - Syncing logic between Electron and web is now properly separated There are now two distinct DB handling APIs: the Electron one and the web one. The app doesn't need to manually choose the API, because it's detected which platform is being utilized on import. - All Electron windows now share the same database instance This provides a single source of truth, improving consistency regarding data manipulation and windows synchronization. As a sidenote, syncing implementation has been left as is (web unimplemented; Electron only syncs settings, remaining datastore syncing will be implemented in the upcoming commits). * Electron/History: Implement history synchronization * Profiles: Implement suplementary profile creation logic * ft-profile-edit: Small fix on profile name missing display * Electron/Profiles: Implement profile synchronization * Electron/Playlists: Implement playlist synchronization
2021-12-15 19:42:24 +01:00
import { IpcChannels } from '../../../constants'
import { sponsorBlockSkipSegments } from '../../helpers/sponsorblock'
import { calculateColorLuminance, colors } from '../../helpers/colors'
import { pathExists } from '../../helpers/filesystem'
import {
copyToClipboard,
getPicturesPath,
showSaveDialog,
showToast,
} from '../../helpers/utils'
import { getProxyUrl } from '../../helpers/api/invidious'
import store from '../../store'
Store Revamp / Full database synchronization across windows (#1833) * History: Refactor history module * Profiles: Refactor profiles module * IPC: Move channel ids to their own file and make them constants * IPC: Replace single sync channel for one channel per sync type * Everywhere: Replace default profile id magic strings with constant ref * Profiles: Refactor `activeProfile` property from store This commit makes it so that `activeProfile`'s getter returns the entire profile, while the related update function only needs the profile id (instead of the previously used array index) to change the currently active profile. This change was made due to inconsistency regarding the active profile when creating new profiles. If a new profile coincidentally landed in the current active profile's array index after sorting, the app would mistakenly change to it without any action from the user apart from the profile's creation. Turning the profile id into the selector instead solves this issue. * Revert "Store: Implement history synchronization between windows" This reverts commit 99b61e617873412eb393d8f4dfccd8f8c172021f. This is necessary for an upcoming improved implementation of the history synchronization. * History: Remove unused mutation * Everywhere: Create abstract database handlers The project now utilizes abstract handlers to fetch, modify or otherwise manipulate data from the database. This facilitates 3 aspects of the app, in addition of making them future proof: - Switching database libraries is now trivial Since most of the app utilizes the abstract handlers, it's incredibly easily to change to a different DB library. Hypothetically, all that would need to be done is to simply replace the the file containing the base handlers, while the rest of the app would go unchanged. - Syncing logic between Electron and web is now properly separated There are now two distinct DB handling APIs: the Electron one and the web one. The app doesn't need to manually choose the API, because it's detected which platform is being utilized on import. - All Electron windows now share the same database instance This provides a single source of truth, improving consistency regarding data manipulation and windows synchronization. As a sidenote, syncing implementation has been left as is (web unimplemented; Electron only syncs settings, remaining datastore syncing will be implemented in the upcoming commits). * Electron/History: Implement history synchronization * Profiles: Implement suplementary profile creation logic * ft-profile-edit: Small fix on profile name missing display * Electron/Profiles: Implement profile synchronization * Electron/Playlists: Implement playlist synchronization
2021-12-15 19:42:24 +01:00
const EXPECTED_PLAY_RELATED_ERROR_MESSAGES = [
// This is thrown when `play()` called but user already viewing another page
'The play() request was interrupted by a new load request.',
// This is thrown when `pause()` called before video started playing on load
'The play() request was interrupted by a call to pause()',
]
// YouTube now throttles if you use the `Range` header for the DASH formats, instead of the range query parameter
// videojs-http-streaming calls this hook everytime it makes a request,
// so we can use it to convert the Range header into the range query parameter for the streaming URLs
videojs.Vhs.xhr.beforeRequest = (options) => {
if (store.getters.getProxyVideos && !options.uri.startsWith('data:application/dash+xml')) {
const { uri } = options
options.uri = getProxyUrl(uri)
}
2023-05-10 14:00:27 +02:00
// pass in the optional base so it doesn't error for `dashFiles/videoId.xml` (DASH manifest in dev mode)
if (new URL(options.uri, window.location.origin).hostname.endsWith('.googlevideo.com')) {
// The official clients use POST requests with this body for the DASH requests, so we should do that too
options.method = 'POST'
options.body = 'x\x00' // protobuf: { 15: 0 } (no idea what it means but this is what YouTube uses)
if (options.headers?.Range) {
options.uri += `&range=${options.headers.Range.split('=')[1]}`
delete options.headers.Range
}
}
}
// videojs-http-streaming spits out a warning every time you access videojs.Vhs.BANDWIDTH_VARIANCE
// so we'll get the value once here, to stop it spamming the console
// https://github.com/videojs/http-streaming/blob/main/src/config.js#L8-L10
const VHS_BANDWIDTH_VARIANCE = videojs.Vhs.BANDWIDTH_VARIANCE
export default defineComponent({
2020-02-16 19:30:00 +01:00
name: 'FtVideoPlayer',
props: {
format: {
type: String,
required: true
},
sourceList: {
type: Array,
default: () => { return [] }
},
2021-05-15 21:08:41 +02:00
adaptiveFormats: {
type: Array,
default: () => { return [] }
},
dashSrc: {
type: Array,
default: null
},
2021-03-17 02:28:25 +01:00
captionHybridList: {
2020-02-16 19:30:00 +01:00
type: Array,
default: () => { return [] }
},
storyboardSrc: {
2020-02-16 19:30:00 +01:00
type: String,
default: ''
},
thumbnail: {
type: String,
default: ''
},
videoId: {
type: String,
required: true
},
lengthSeconds: {
type: Number,
required: true
},
chapters: {
type: Array,
default: () => { return [] }
},
currentChapterIndex: {
type: Number,
default: 0
},
audioTracks: {
type: Array,
default: () => ([])
},
theatrePossible: {
type: Boolean,
default: false
},
useTheatreMode: {
type: Boolean,
default: false
2020-02-16 19:30:00 +01:00
}
},
data: function () {
return {
powerSaveBlocker: null,
volume: 1,
muted: false,
/** @type {(import('video.js').VideoJsPlayer|null)} */
2020-02-16 19:30:00 +01:00
player: null,
useDash: false,
useHls: false,
2020-09-25 04:35:13 +02:00
selectedDefaultQuality: '',
selectedQuality: '',
selectedResolution: '',
selectedBitrate: '',
selectedMimeType: '',
selectedFPS: 0,
currentAdaptiveFormat: null,
autoQuality: '',
autoResolution: '',
autoBitrate: '',
autoMimeType: '',
autoFPS: 0,
using60Fps: false,
activeSourceList: [],
2021-05-15 21:08:41 +02:00
activeAdaptiveFormats: [],
playerStats: null,
statsModal: null,
showStatsModal: false,
statsModalEventName: 'updateStats',
Improving touch input by bringing in `videojs-mobile-ui` (#2719) * Adding videojs-mobile-ui as a dependency - Using the beta because it fixes an issue with multiple version of videojs loading at once. This is related to MarmadileManteater#56. * Mapping defaultSkipInterval to seekSeconds * Adding CSS to prevent showing duplicate buttons - Added a new variable `usingTouch` to selectively hide the existing `vjs-big-play-button` when the `touch-overlay` is visible. * Renaming CSS class to something more specific * Adding text-shadow behind play / pause button This should make it more visually distinct against a light or colorful background. * Enabling touch-overlay anytime a touch is detected Disabling it whenever mouse input is detected The default behavior of `videojs-mobile-ui` is to only work in Android and iOS, but by forcing the touch behavior to be enabled and selectively showing it only when touch input is detected, it should work on any device with touch input even if the browser doesn't detect that it is running in Android or iOS. * Removing unnecessary code * Removing unintentionally leftover variable * Removing an unnecessary assignment Adding comments to explain why a flag called `forceForTesting` is set to true Disabling the `lockOnRotate` flag. * Moving this flag and wrapper class * Adding whitespace * Making my comment a little more consistent * Changing the punctuation of a comment * Adjusting the CSS to fix a firefox discrepancy * Adding a check for mobile firefox For whatever reason, mobile firefox sometimes triggers onmouseover when a touch occurs, and this is unwanted behavior. * Adding a drop-shadow to the ff and rw icons This should make them easier to see on top of light videos * Hiding the mobile play button according to setting - Added CSS to hide the videojs-mobile-ui play button when the `Display Play Button In Video Player` setting is disabled. * Replacing long computed string with class binding Co-authored-by: absidue <48293849+absidue@users.noreply.github.com> * Adding newline at the end of videoJS.css Co-authored-by: absidue <48293849+absidue@users.noreply.github.com>
2022-10-24 19:49:52 +02:00
usingTouch: false,
// whether or not sponsor segments should be skipped
skipSponsors: true,
// countdown before actually skipping sponsor segments
skipCountdown: 1,
2020-02-16 19:30:00 +01:00
dataSetup: {
fluid: true,
nativeTextTracks: false,
plugins: {},
controlBar: {
children: [
'playToggle',
'volumePanel',
'currentTimeDisplay',
'timeDivider',
'durationDisplay',
'progressControl',
'liveDisplay',
'seekToLive',
'remainingTimeDisplay',
'customControlSpacer',
'screenshotButton',
'playbackRateMenuButton',
'loopButton',
'audioTrackButton',
'chaptersButton',
'descriptionsButton',
'subsCapsButton',
2020-12-15 00:25:51 +01:00
'pictureInPictureToggle',
'toggleTheatreModeButton',
2020-12-15 00:25:51 +01:00
'fullWindowButton',
'qualitySelector',
2020-12-15 00:25:51 +01:00
'fullscreenToggle'
]
}
2020-02-16 19:30:00 +01:00
}
}
},
computed: {
currentLocale: function () {
return this.$i18n.locale.replace('_', '-')
},
defaultPlayback: function () {
return this.$store.getters.getDefaultPlayback
},
defaultSkipInterval: function () {
return this.$store.getters.getDefaultSkipInterval
},
defaultQuality: function () {
const valueFromStore = this.$store.getters.getDefaultQuality
if (valueFromStore === 'auto') { return valueFromStore }
return parseInt(valueFromStore)
},
defaultCaptionSettings: function () {
try {
return JSON.parse(this.$store.getters.getDefaultCaptionSettings)
} catch (e) {
console.error(e)
return {}
}
},
autoplayVideos: function () {
return this.$store.getters.getAutoplayVideos
},
videoVolumeMouseScroll: function () {
return this.$store.getters.getVideoVolumeMouseScroll
},
videoPlaybackRateMouseScroll: function () {
return this.$store.getters.getVideoPlaybackRateMouseScroll
},
videoSkipMouseScroll: function () {
return this.$store.getters.getVideoSkipMouseScroll
},
useSponsorBlock: function () {
return this.$store.getters.getUseSponsorBlock
},
sponsorBlockShowSkippedToast: function () {
return this.$store.getters.getSponsorBlockShowSkippedToast
},
displayVideoPlayButton: function () {
return this.$store.getters.getDisplayVideoPlayButton
},
enterFullscreenOnDisplayRotate: function () {
return this.$store.getters.getEnterFullscreenOnDisplayRotate
},
sponsorSkips: function () {
const sponsorCats = ['sponsor',
'selfpromo',
'interaction',
'intro',
'outro',
'preview',
'music_offtopic',
'filler'
]
const autoSkip = {}
const seekBar = []
const promptSkip = {}
const categoryData = {}
sponsorCats.forEach(x => {
let sponsorVal = {}
switch (x) {
case 'sponsor':
sponsorVal = this.$store.getters.getSponsorBlockSponsor
break
case 'selfpromo':
sponsorVal = this.$store.getters.getSponsorBlockSelfPromo
break
case 'interaction':
sponsorVal = this.$store.getters.getSponsorBlockInteraction
break
case 'intro':
sponsorVal = this.$store.getters.getSponsorBlockIntro
break
case 'outro':
sponsorVal = this.$store.getters.getSponsorBlockOutro
break
case 'preview':
sponsorVal = this.$store.getters.getSponsorBlockRecap
break
case 'music_offtopic':
sponsorVal = this.$store.getters.getSponsorBlockMusicOffTopic
break
case 'filler':
sponsorVal = this.$store.getters.getSponsorBlockFiller
break
}
if (sponsorVal.skip !== 'doNothing') {
seekBar.push(x)
}
if (sponsorVal.skip === 'autoSkip') {
autoSkip[x] = true
}
if (sponsorVal.skip === 'promptToSkip') {
promptSkip[x] = true
}
categoryData[x] = sponsorVal
})
return { autoSkip, seekBar, promptSkip, categoryData }
},
maxVideoPlaybackRate: function () {
return parseInt(this.$store.getters.getMaxVideoPlaybackRate)
},
videoPlaybackRateInterval: function () {
return parseFloat(this.$store.getters.getVideoPlaybackRateInterval)
},
playbackRates: function () {
const playbackRates = []
let i = this.videoPlaybackRateInterval
while (i <= this.maxVideoPlaybackRate) {
playbackRates.push(i)
i = i + this.videoPlaybackRateInterval
i = parseFloat(i.toFixed(2))
}
return playbackRates
},
enableSubtitlesByDefault: function () {
return this.$store.getters.getEnableSubtitlesByDefault
},
enableScreenshot: function () {
return this.$store.getters.getEnableScreenshot
},
screenshotFormat: function () {
return this.$store.getters.getScreenshotFormat
},
screenshotQuality: function () {
return this.$store.getters.getScreenshotQuality
},
screenshotAskPath: function () {
return this.$store.getters.getScreenshotAskPath
},
screenshotFolder: function () {
return this.$store.getters.getScreenshotFolderPath
},
proxyVideos: function () {
return this.$store.getters.getProxyVideos
Stats for nerds (#1867) * transition duration of 0.5s added to watched videos * small code reformating * extra white spaces deleted * typo in the word transition corrected * original whitespaces respected * transition added when hovering end * video stat components started and properties chosen * ft-video-stats integraded into the video player for dev and debugging * using a timer to get video stats and a method to update the statistic every second * getting statistic from vhs and adaptativeFormat * frame drop capture * stats capture in the form of event * useless comment deleted * stats render with a for loop in the template * stats correctly displayed * overlay stats added * video stats component deleted * video stats component deleted inside template video player * video stats component fully deleted * modal solution working need more styling and code messy * lint * modal working with stats * keyboard shortcut for stats * lint fix * network state is now a string * new line deleted * useless whitespace deleted * package-lock.json remove and ignore * keyboard shortcut restricted to up arrow * stats overlay made larger * align to left corner * useless formatting of string deleted * renaming of variable formatedStrats for formattedStats * keyboard shortcut made into a variable * lint-fix * key change for i * label translated * whitespace added for gitignore * lock file not ignored * videoId stat deleted * ft-video-player.js, en-US.yaml, fr-FR.yaml: changing percentage stats display changing the display for percentage stats for the format 'x%' instead of 'xx.xx' * ft-video-player.js, en-US.yaml, fr-FR.yaml: network state video statistic deleted * ft-video-player.js: made stats modal background color darker * ft-video-player.js, en-US.yaml, fr-FR.yaml: video id are now related to the one of youtube * ft-video-player.js, en-US.yaml, fr-FR.yaml: stats displayed made closet to the youtube implementation the name are capitalized, the order of display is changed and fps is combined with viewport * lint-fix * en-US.yaml, fr-FR.yaml: network state possibilities deleted because not used * package.json.lock: deleted * ft-video-player.js: formated_stats renamed for formatted_stats * lock file deleted * index.js, ft-video-player.js: handling of right click context menu via electon ipc bus an event is send to tell the vue component to show the stats modal * ft-video-player.js, index.js: renaming of video stats display event and definition of it as a variable * index.js, en-US.yaml: inconsistant capitalization of video statistics label solved * index.js: pluralized video stats * ft-video-player.js: fix right click undefined this.player change the arrow function inside the closure for a function with a bind to this * ft-video-player.js: handling of the case when this.player is not defined the property this.stats.display.activated as been added and manage when the to show the stats. In this way in the runtime (it is still refered in the run time but it is capture in an event loop) with dont have to refer to this.player so when it is not defined it doesnt affect the behavior. * lint fix * src/renderer/components/ft-video-player/ft-video-player.js: modal.close move into the display event of the statistic context * lint fix * src/renderer/components/ft-video-player/ft-video-player.js, static/locales/en-US.yaml, static/locales/fr-FR.yaml: better capitalization of the stats labels * static/locales/en-US.yaml: fps capitalized * static/locales/fr-FR.yaml, static/locales/en-US.yaml: capitalized label
2021-11-23 12:34:04 +01:00
}
},
watch: {
showStatsModal: function () {
this.player.trigger(this.statsModalEventName)
},
enableScreenshot: function () {
this.toggleScreenshotButton()
2020-02-16 19:30:00 +01:00
}
},
created: function () {
this.dataSetup.playbackRates = this.playbackRates
if (this.format === 'audio') {
// hide the PIP button for the audio formats
const controlBarItems = this.dataSetup.controlBar.children
const index = controlBarItems.indexOf('pictureInPictureToggle')
controlBarItems.splice(index, 1)
}
if (this.format === 'legacy' || this.audioTracks.length === 0) {
// hide the audio track selector for the legacy formats
// and Invidious(it doesn't give us the information for multiple audio tracks yet)
const controlBarItems = this.dataSetup.controlBar.children
const index = controlBarItems.indexOf('audioTrackButton')
controlBarItems.splice(index, 1)
}
},
2020-02-16 19:30:00 +01:00
mounted: function () {
const volume = sessionStorage.getItem('volume')
const muted = sessionStorage.getItem('muted')
if (volume !== null) {
this.volume = volume
}
if (muted !== null) {
// as sessionStorage stores string values which are truthy by default so we must check with 'true'
// otherwise 'false' will be returned as true as well
this.muted = (muted === 'true')
}
if (this.format === 'dash') {
this.determineDefaultQualityDash()
}
2020-12-15 00:25:51 +01:00
this.createFullWindowButton()
this.createLoopButton()
this.createToggleTheatreModeButton()
this.createScreenshotButton()
this.determineFormatType()
if ('mediaSession' in navigator) {
navigator.mediaSession.setActionHandler('play', () => this.playVideo())
navigator.mediaSession.setActionHandler('pause', () => this.player.pause())
}
window.addEventListener('beforeunload', this.stopPowerSaveBlocker)
2020-02-16 19:30:00 +01:00
},
beforeDestroy: function () {
document.removeEventListener('keydown', this.keyboardShortcutHandler)
if (this.player !== null) {
this.exitFullWindow()
if (!this.player.isInPictureInPicture()) {
this.player.dispose()
this.player = null
}
}
2021-01-13 21:56:25 +01:00
if ('mediaSession' in navigator) {
navigator.mediaSession.setActionHandler('play', null)
navigator.mediaSession.setActionHandler('pause', null)
navigator.mediaSession.playbackState = 'none'
}
this.stopPowerSaveBlocker()
window.removeEventListener('beforeunload', this.stopPowerSaveBlocker)
},
2020-02-16 19:30:00 +01:00
methods: {
2020-09-25 04:35:13 +02:00
initializePlayer: async function () {
if (typeof this.$refs.video !== 'undefined') {
2020-05-24 23:32:02 +02:00
if (!this.useDash) {
qualitySelector(videojs, { showQualitySelectionLabelInControlBar: true })
2020-09-26 23:03:42 +02:00
await this.determineDefaultQualityLegacy()
}
// regardless of what DASH qualities you enable or disable in the qualityLevels plugin
// the first segments videojs-http-streaming requests are chosen based on the available bandwidth, which is set to 0.5MB/s by default
// overriding that to be the same as the quality we requested, makes videojs-http-streamming pick the correct quality
const playerBandwidthOption = {}
if (this.useDash && this.defaultQuality !== 'auto') {
// https://github.com/videojs/http-streaming#bandwidth
// Cannot be too high to fix https://github.com/FreeTubeApp/FreeTube/issues/595
// (when default quality is low like 240p)
playerBandwidthOption.bandwidth = this.selectedBitrate * VHS_BANDWIDTH_VARIANCE + 1
}
this.player = videojs(this.$refs.video, {
html5: {
2021-03-17 02:28:25 +01:00
preloadTextTracks: false,
vhs: {
limitRenditionByPlayerDimensions: false,
smoothQualityChange: false,
allowSeeksWithinUnsafeLiveWindow: true,
handlePartialData: true,
...playerBandwidthOption
}
}
})
Improving touch input by bringing in `videojs-mobile-ui` (#2719) * Adding videojs-mobile-ui as a dependency - Using the beta because it fixes an issue with multiple version of videojs loading at once. This is related to MarmadileManteater#56. * Mapping defaultSkipInterval to seekSeconds * Adding CSS to prevent showing duplicate buttons - Added a new variable `usingTouch` to selectively hide the existing `vjs-big-play-button` when the `touch-overlay` is visible. * Renaming CSS class to something more specific * Adding text-shadow behind play / pause button This should make it more visually distinct against a light or colorful background. * Enabling touch-overlay anytime a touch is detected Disabling it whenever mouse input is detected The default behavior of `videojs-mobile-ui` is to only work in Android and iOS, but by forcing the touch behavior to be enabled and selectively showing it only when touch input is detected, it should work on any device with touch input even if the browser doesn't detect that it is running in Android or iOS. * Removing unnecessary code * Removing unintentionally leftover variable * Removing an unnecessary assignment Adding comments to explain why a flag called `forceForTesting` is set to true Disabling the `lockOnRotate` flag. * Moving this flag and wrapper class * Adding whitespace * Making my comment a little more consistent * Changing the punctuation of a comment * Adjusting the CSS to fix a firefox discrepancy * Adding a check for mobile firefox For whatever reason, mobile firefox sometimes triggers onmouseover when a touch occurs, and this is unwanted behavior. * Adding a drop-shadow to the ff and rw icons This should make them easier to see on top of light videos * Hiding the mobile play button according to setting - Added CSS to hide the videojs-mobile-ui play button when the `Display Play Button In Video Player` setting is disabled. * Replacing long computed string with class binding Co-authored-by: absidue <48293849+absidue@users.noreply.github.com> * Adding newline at the end of videoJS.css Co-authored-by: absidue <48293849+absidue@users.noreply.github.com>
2022-10-24 19:49:52 +02:00
this.player.mobileUi({
fullscreen: {
enterOnRotate: this.enterFullscreenOnDisplayRotate,
exitOnRotate: this.enterFullscreenOnDisplayRotate,
Improving touch input by bringing in `videojs-mobile-ui` (#2719) * Adding videojs-mobile-ui as a dependency - Using the beta because it fixes an issue with multiple version of videojs loading at once. This is related to MarmadileManteater#56. * Mapping defaultSkipInterval to seekSeconds * Adding CSS to prevent showing duplicate buttons - Added a new variable `usingTouch` to selectively hide the existing `vjs-big-play-button` when the `touch-overlay` is visible. * Renaming CSS class to something more specific * Adding text-shadow behind play / pause button This should make it more visually distinct against a light or colorful background. * Enabling touch-overlay anytime a touch is detected Disabling it whenever mouse input is detected The default behavior of `videojs-mobile-ui` is to only work in Android and iOS, but by forcing the touch behavior to be enabled and selectively showing it only when touch input is detected, it should work on any device with touch input even if the browser doesn't detect that it is running in Android or iOS. * Removing unnecessary code * Removing unintentionally leftover variable * Removing an unnecessary assignment Adding comments to explain why a flag called `forceForTesting` is set to true Disabling the `lockOnRotate` flag. * Moving this flag and wrapper class * Adding whitespace * Making my comment a little more consistent * Changing the punctuation of a comment * Adjusting the CSS to fix a firefox discrepancy * Adding a check for mobile firefox For whatever reason, mobile firefox sometimes triggers onmouseover when a touch occurs, and this is unwanted behavior. * Adding a drop-shadow to the ff and rw icons This should make them easier to see on top of light videos * Hiding the mobile play button according to setting - Added CSS to hide the videojs-mobile-ui play button when the `Display Play Button In Video Player` setting is disabled. * Replacing long computed string with class binding Co-authored-by: absidue <48293849+absidue@users.noreply.github.com> * Adding newline at the end of videoJS.css Co-authored-by: absidue <48293849+absidue@users.noreply.github.com>
2022-10-24 19:49:52 +02:00
lockOnRotate: false
},
// Without this flag, the mobile UI will only activate
// if videojs detects it is in Android or iOS
// With this flag, the mobile UI could theoretically
// work on any device that has a touch input
forceForTesting: true,
touchControls: {
seekSeconds: this.defaultSkipInterval,
tapTimeout: 300
}
})
2020-12-15 00:25:51 +01:00
const qualityLevels = this.player.qualityLevels()
// Catch quality changes and update auto labels
// Event will not fire if new auto resolution is same as previous manual
// eg. 1080p30 -> auto 1080p30
qualityLevels.on('change', ({ selectedIndex }) => {
if (this.selectedQuality === 'auto' || (this.selectedQuality === '' && this.defaultQuality === 'auto')) {
const newQualityLevel = qualityLevels[selectedIndex]
this.autoBitrate = newQualityLevel.bitrate
this.autoFPS = newQualityLevel.frameRate
this.autoResolution = `${newQualityLevel.width}x${newQualityLevel.height}`
let qualityLabel = ''
const adaptiveFormat = this.activeAdaptiveFormats.find((format) => {
return format.bitrate === newQualityLevel.bitrate
})
if (adaptiveFormat) {
this.autoMimeType = adaptiveFormat.mimeType
this.currentAdaptiveFormat = adaptiveFormat
qualityLabel = `auto ${adaptiveFormat.qualityLabel}`
} else {
qualityLabel = `auto ${newQualityLevel.height}p`
}
// Can be run before createDashQualitySelector is called
const qualityElement = document.getElementById('vjs-current-quality')
if (qualityElement !== null) {
qualityElement.innerText = qualityLabel
}
}
})
// disable any quality the isn't the default one, as soon as it gets added
// we don't need to disable any qualities for auto
if (this.useDash && this.defaultQuality !== 'auto') {
qualityLevels.on('addqualitylevel', ({ qualityLevel }) => {
qualityLevel.enabled = qualityLevel.bitrate === this.selectedBitrate
})
}
// for the DASH formats, videojs-http-streaming takes care of the audio track management for us,
// thanks to the values in the DASH manifest
// so we only need a custom implementation for the audio only formats
if (this.format === 'audio' && this.audioTracks.length > 0) {
/** @type {import('../../views/Watch/Watch.js').AudioTrack[]} */
const audioTracks = this.audioTracks
const audioTrackList = this.player.audioTracks()
audioTracks.forEach(({ id, kind, label, language, isDefault: enabled }) => {
audioTrackList.addTrack(new videojs.AudioTrack({
id, kind, label, language, enabled,
}))
})
audioTrackList.on('change', () => {
let trackId
// doesn't support foreach so we need to use an indexed for loop here
for (let i = 0; i < audioTrackList.length; i++) {
const track = audioTrackList[i]
if (track.enabled) {
trackId = track.id
break
}
}
this.changeAudioTrack(trackId)
})
}
this.player.volume(this.volume)
this.player.muted(this.muted)
this.player.playbackRate(this.defaultPlayback)
this.player.textTrackSettings.setValues(this.defaultCaptionSettings)
// Remove big play button
// https://github.com/videojs/video.js/blob/v7.12.1/docs/guides/components.md#basic-example
if (!this.displayVideoPlayButton) {
this.player.removeChild('BigPlayButton')
}
// Makes the playback rate menu focus the current item on mouse hover
// or the closest item if the playback rate is between two items
// which is likely to be the case when the playback rate is changed by scrolling
const playbackRateMenuButton = this.player.controlBar.getChild('playbackRateMenuButton')
playbackRateMenuButton.on(playbackRateMenuButton.menuButton_, 'mouseenter', () => {
const playbackRate = this.player.playbackRate()
const rates = this.player.playbackRates()
// iterate through the items in reverse order as the highest is displayed first
// `slice` must be used as `reverse` does reversing in place
const targetPlaybackRateMenuItemIndex = rates.slice().reverse().findIndex((rate) => {
return rate === playbackRate || rate < playbackRate
})
// center the selected item in the middle of the visible area
// the first and last items will never be in the center so it can be skipped for them
if (targetPlaybackRateMenuItemIndex !== 0 && targetPlaybackRateMenuItemIndex !== rates.length - 1) {
const playbackRateMenu = playbackRateMenuButton.menu
const menuElement = playbackRateMenu.contentEl()
const itemHeight = playbackRateMenu.children()[targetPlaybackRateMenuItemIndex].contentEl().clientHeight
// clientHeight is the height of the visible part of an element
const centerOfVisibleArea = (menuElement.clientHeight - itemHeight) / 2
const menuScrollOffset = (itemHeight * targetPlaybackRateMenuItemIndex) - centerOfVisibleArea
menuElement.scrollTo({ top: menuScrollOffset })
}
})
2020-09-25 04:35:13 +02:00
if (this.storyboardSrc !== '') {
this.player.vttThumbnails({
src: this.storyboardSrc,
showTimestamp: true
})
}
if (this.autoplayVideos) {
// Calling play() won't happen right away, so a quick timeout will make it function properly.
setTimeout(() => {
// `this.player` can be destroyed before this runs
this.playVideo()
2020-05-24 23:32:02 +02:00
}, 200)
}
// Remove built-in progress bar mouse over current time display
// `MouseTimeDisplay` in
// https://github.com/videojs/video.js/blob/v7.13.3/docs/guides/components.md#default-component-tree
this.player.controlBar.progressControl.seekBar.playProgressBar.removeChild('timeTooltip')
if (this.chapters.length > 0) {
this.chapters.forEach(this.addChapterMarker)
}
if (this.useSponsorBlock) {
this.initializeSponsorBlock()
}
document.removeEventListener('keydown', this.keyboardShortcutHandler)
document.addEventListener('keydown', this.keyboardShortcutHandler)
2020-02-21 19:31:32 +01:00
this.player.on('volumechange', this.updateVolume)
if (this.videoVolumeMouseScroll) {
this.player.on('wheel', this.mouseScrollVolume)
} else {
this.player.controlBar.getChild('volumePanel').on('wheel', this.mouseScrollVolume)
}
2020-02-21 19:31:32 +01:00
if (this.videoPlaybackRateMouseScroll) {
this.player.on('wheel', this.mouseScrollPlaybackRate)
// Removes the 'out-of-the-box' click event and adds a custom click event so that a user can
// ctrl-click (or command+click on a mac) without toggling play/pause
this.player.el_.firstChild.style.pointerEvents = 'none'
this.player.on('click', this.handlePlayerClick)
}
if (this.videoSkipMouseScroll) {
this.player.on('wheel', this.mouseScrollSkip)
}
this.player.on('fullscreenchange', () => {
this.fullscreenOverlay()
this.toggleFullscreenClass()
})
this.player.on('ready', () => {
this.$emit('ready')
this.createStatsModal()
if (this.captionHybridList.length !== 0) {
this.transformAndInsertCaptions()
2021-03-17 02:28:25 +01:00
}
this.toggleScreenshotButton()
})
this.player.one('loadedmetadata', () => {
if (this.useDash) {
// Reserved for switching back to videojs-http-quality-selector if needed
// this.dataSetup.plugins.httpSourceSelector = {
// default: 'auto'
// }
// this.player.httpSourceSelector()
this.createDashQualitySelector(this.player.qualityLevels())
}
this.checkAspectRatio()
})
this.player.on('ended', () => {
this.$emit('ended')
if ('mediaSession' in navigator) {
navigator.mediaSession.playbackState = 'none'
}
this.stopPowerSaveBlocker()
})
this.player.on('error', (error, message) => {
this.$emit('error', error.target.player.error_)
if ('mediaSession' in navigator) {
navigator.mediaSession.playbackState = 'none'
}
this.stopPowerSaveBlocker()
2020-02-21 19:31:32 +01:00
})
this.player.on('play', async () => {
if ('mediaSession' in navigator) {
navigator.mediaSession.playbackState = 'playing'
}
if (process.env.IS_ELECTRON) {
const { ipcRenderer } = require('electron')
this.powerSaveBlocker =
Store Revamp / Full database synchronization across windows (#1833) * History: Refactor history module * Profiles: Refactor profiles module * IPC: Move channel ids to their own file and make them constants * IPC: Replace single sync channel for one channel per sync type * Everywhere: Replace default profile id magic strings with constant ref * Profiles: Refactor `activeProfile` property from store This commit makes it so that `activeProfile`'s getter returns the entire profile, while the related update function only needs the profile id (instead of the previously used array index) to change the currently active profile. This change was made due to inconsistency regarding the active profile when creating new profiles. If a new profile coincidentally landed in the current active profile's array index after sorting, the app would mistakenly change to it without any action from the user apart from the profile's creation. Turning the profile id into the selector instead solves this issue. * Revert "Store: Implement history synchronization between windows" This reverts commit 99b61e617873412eb393d8f4dfccd8f8c172021f. This is necessary for an upcoming improved implementation of the history synchronization. * History: Remove unused mutation * Everywhere: Create abstract database handlers The project now utilizes abstract handlers to fetch, modify or otherwise manipulate data from the database. This facilitates 3 aspects of the app, in addition of making them future proof: - Switching database libraries is now trivial Since most of the app utilizes the abstract handlers, it's incredibly easily to change to a different DB library. Hypothetically, all that would need to be done is to simply replace the the file containing the base handlers, while the rest of the app would go unchanged. - Syncing logic between Electron and web is now properly separated There are now two distinct DB handling APIs: the Electron one and the web one. The app doesn't need to manually choose the API, because it's detected which platform is being utilized on import. - All Electron windows now share the same database instance This provides a single source of truth, improving consistency regarding data manipulation and windows synchronization. As a sidenote, syncing implementation has been left as is (web unimplemented; Electron only syncs settings, remaining datastore syncing will be implemented in the upcoming commits). * Electron/History: Implement history synchronization * Profiles: Implement suplementary profile creation logic * ft-profile-edit: Small fix on profile name missing display * Electron/Profiles: Implement profile synchronization * Electron/Playlists: Implement playlist synchronization
2021-12-15 19:42:24 +01:00
await ipcRenderer.invoke(IpcChannels.START_POWER_SAVE_BLOCKER)
}
})
this.player.on('pause', () => {
if ('mediaSession' in navigator) {
navigator.mediaSession.playbackState = 'paused'
}
this.stopPowerSaveBlocker()
})
this.player.on(this.statsModalEventName, () => {
if (this.showStatsModal) {
this.statsModal.open()
this.player.controls(true)
this.statsModal.contentEl().innerHTML = this.getFormattedStats()
} else {
this.statsModal.close()
}
})
this.player.on('timeupdate', () => {
if (this.format === 'dash') {
this.playerStats = this.player.tech({ IWillNotUseThisInPlugins: true }).vhs.stats
this.updateStatsContent()
}
this.$emit('timeupdate')
})
this.player.textTrackSettings.on('modalclose', (_) => {
const settings = this.player.textTrackSettings.getValues()
this.updateDefaultCaptionSettings(JSON.stringify(settings))
})
// right click menu
if (process.env.IS_ELECTRON) {
const { ipcRenderer } = require('electron')
ipcRenderer.removeAllListeners('showVideoStatistics')
ipcRenderer.on('showVideoStatistics', (event) => {
this.toggleShowStatsModal()
})
}
2020-02-16 19:30:00 +01:00
}
},
initializeSponsorBlock() {
sponsorBlockSkipSegments(this.videoId, this.sponsorSkips.seekBar)
.then((skipSegments) => {
if (skipSegments.length === 0) {
return
}
this.player.ready(() => {
this.player.on('timeupdate', () => {
this.skipSponsorBlocks(skipSegments)
})
this.player.on('seeking', () => {
// disabling sponsors auto skipping when the user manually seeks
this.skipSponsors = false
})
skipSegments.forEach(({
category,
segment: [startTime, endTime]
}) => {
this.addSponsorBlockMarker({
time: startTime,
duration: endTime - startTime,
color: 'var(--primary-color)',
category: category
})
})
})
})
},
skipSponsorBlocks(skipSegments) {
const currentTime = this.player.currentTime()
const duration = this.player.duration()
let newTime = null
let skippedCategory = null
skipSegments.forEach(({ category, segment: [startTime, endTime] }) => {
if (startTime <= currentTime && currentTime < endTime) {
newTime = endTime
skippedCategory = category
}
})
if (this.skipSponsors && newTime !== null && Math.abs(duration - currentTime) > 0.500) {
if (this.sponsorSkips.autoSkip[skippedCategory]) {
if (this.skipCountdown === 0) {
if (this.sponsorBlockShowSkippedToast) {
this.showSkippedSponsorSegmentInformation(skippedCategory)
}
this.player.currentTime(newTime)
} else {
this.skipCountdown--
}
}
}
// restoring sponsors skipping default values
if (newTime === null && !this.skipSponsors) {
this.skipSponsors = true
this.skipCountdown = 1
}
},
showSkippedSponsorSegmentInformation(category) {
const translatedCategory = this.sponsorBlockTranslatedCategory(category)
2022-10-14 07:59:49 +02:00
showToast(`${this.$t('Video.Skipped segment')} ${translatedCategory}`)
},
sponsorBlockTranslatedCategory(category) {
switch (category) {
case 'sponsor':
return this.$t('Video.Sponsor Block category.sponsor')
case 'intro':
return this.$t('Video.Sponsor Block category.intro')
case 'outro':
return this.$t('Video.Sponsor Block category.outro')
case 'selfpromo':
return this.$t('Video.Sponsor Block category.self-promotion')
case 'interaction':
return this.$t('Video.Sponsor Block category.interaction')
case 'music_offtopic':
return this.$t('Video.Sponsor Block category.music offtopic')
case 'filler':
return this.$t('Video.Sponsor Block category.filler')
default:
console.error(`Unknown translation for SponsorBlock category ${category}`)
return category
}
},
addSponsorBlockMarker(marker) {
const markerDiv = videojs.dom.createEl('div')
markerDiv.title = this.sponsorBlockTranslatedCategory(marker.category)
markerDiv.className = `sponsorBlockMarker main${this.sponsorSkips.categoryData[marker.category].color}`
markerDiv.style.width = (marker.duration / this.lengthSeconds) * 100 + '%'
markerDiv.style.marginLeft = (marker.time / this.lengthSeconds) * 100 + '%'
this.player.el().querySelector('.vjs-progress-holder').appendChild(markerDiv)
},
addChapterMarker(chapter) {
const markerDiv = videojs.dom.createEl('div')
markerDiv.title = chapter.title
markerDiv.className = 'chapterMarker'
2022-11-20 22:46:34 +01:00
markerDiv.style.marginLeft = `calc(${(chapter.startSeconds / this.lengthSeconds) * 100}% - 1px)`
this.player.el().querySelector('.vjs-progress-holder').appendChild(markerDiv)
},
checkAspectRatio() {
const videoWidth = this.player.videoWidth()
const videoHeight = this.player.videoHeight()
if ((videoWidth - videoHeight) <= 240) {
this.player.fluid(false)
this.player.aspectRatio('16:9')
}
},
updateVolume: function (_event) {
// https://docs.videojs.com/html5#volume
if (sessionStorage.getItem('muted') === 'false' && this.player.volume() === 0) {
// If video is muted by dragging volume slider, it doesn't change 'muted' in sessionStorage to true
// hence compare it with 'false' and set volume to defaultVolume.
const volume = parseFloat(sessionStorage.getItem('defaultVolume'))
const muted = true
sessionStorage.setItem('volume', volume)
sessionStorage.setItem('muted', muted)
} else {
// If volume isn't muted by dragging the slider, muted and volume values are carried over to next video.
const volume = this.player.volume()
const muted = this.player.muted()
sessionStorage.setItem('volume', volume)
sessionStorage.setItem('muted', muted)
}
},
mouseScrollVolume: function (event) {
if (event.target && !event.currentTarget.querySelector('.vjs-menu:hover')) {
event.preventDefault()
if (this.player.muted() && event.wheelDelta > 0) {
this.player.muted(false)
this.player.volume(0)
}
if (!event.ctrlKey && !event.metaKey) {
if (!this.player.muted()) {
if (event.wheelDelta > 0) {
this.changeVolume(0.05)
} else if (event.wheelDelta < 0) {
this.changeVolume(-0.05)
}
}
}
}
},
/**
* @param {WheelEvent} event
*/
mouseScrollPlaybackRate: function (event) {
if (event.target && !event.currentTarget.querySelector('.vjs-menu:hover')) {
if (event.ctrlKey || event.metaKey) {
// Only stop page scrolling when Cmd/Ctrl pressed
event.preventDefault()
if (event.deltaY > 0) {
this.changePlayBackRate(0.05)
} else if (event.deltaY < 0) {
this.changePlayBackRate(-0.05)
}
}
}
},
mouseScrollSkip: function (event) {
// Avoid doing both
if ((event.ctrlKey || event.metaKey) && this.videoPlaybackRateMouseScroll) {
return
}
// ensure that the mouse is over the player
if (event.target && (event.target.matches('.vjs-tech') || event.target.matches('.ftVideoPlayer'))) {
event.preventDefault()
if (event.wheelDelta > 0) {
this.changeDurationBySeconds(this.defaultSkipInterval * this.player.playbackRate())
}
if (event.wheelDelta < 0) {
this.changeDurationBySeconds(-this.defaultSkipInterval * this.player.playbackRate())
}
}
},
handlePlayerClick: function (event) {
if (event.target.matches('.ftVideoPlayer')) {
if (event.ctrlKey || event.metaKey) {
this.player.playbackRate(this.defaultPlayback)
} else {
if (this.player.paused() || !this.player.hasStarted()) {
this.playVideo()
} else {
this.player.pause()
}
}
}
},
/**
* @param {string} trackId
*/
changeAudioTrack: function (trackId) {
// changing the player sources resets it, so we need to store the values that get reset,
// before we change the sources and restore them afterwards
const isPaused = this.player.paused()
const currentTime = this.player.currentTime()
const playbackRate = this.player.playbackRate()
const selectedQualityIndex = this.player.currentSources().findIndex(quality => quality.selected)
const newSourceList = this.audioTracks.find(audioTrack => audioTrack.id === trackId).sourceList
// video.js doesn't pick up changes to the sources in the HTML after it's initial load
// updating the sources of an existing player requires calling player.src() instead
// which accepts a different object that what use for the html sources
const newSources = newSourceList.map((source, index) => {
return {
src: source.url,
type: source.type,
label: source.qualityLabel,
selected: index === selectedQualityIndex
}
})
this.player.one('canplay', () => {
this.player.currentTime(currentTime)
this.player.playbackRate(playbackRate)
// need to call play to restore the player state, even if we want to pause afterwards
this.playVideo(() => {
if (isPaused) { this.player.pause() }
})
})
this.player.src(newSources)
},
determineFormatType: function () {
if (this.format === 'dash') {
this.enableDashFormat()
} else {
this.enableLegacyFormat()
}
},
2020-09-26 23:03:42 +02:00
determineDefaultQualityLegacy: function () {
2020-05-24 23:46:26 +02:00
if (this.useDash) {
2020-09-25 04:35:13 +02:00
return
}
if (this.sourceList.length === 0) {
return ''
}
if (typeof (this.sourceList[0].qualityLabel) === 'number') {
return ''
2020-05-24 23:46:26 +02:00
}
2020-10-05 03:20:30 +02:00
if (this.sourceList[this.sourceList.length - 1].qualityLabel === this.$t('Video.Audio.Low')) {
this.selectedDefaultQuality = this.sourceList[0].qualityLabel
return
}
2020-09-26 23:03:42 +02:00
let defaultQuality = this.defaultQuality
if (defaultQuality === 'auto') {
defaultQuality = 720
}
2020-09-25 04:35:13 +02:00
let maxAvailableQuality = parseInt(this.sourceList[this.sourceList.length - 1].qualityLabel.replace(/p|k/, ''))
if (maxAvailableQuality === 4) {
maxAvailableQuality = 2160
}
if (maxAvailableQuality === 8) {
maxAvailableQuality = 4320
}
2020-09-26 23:03:42 +02:00
if (maxAvailableQuality < defaultQuality) {
2020-09-25 04:35:13 +02:00
this.selectedDefaultQuality = this.sourceList[this.sourceList.length - 1].qualityLabel
}
const reversedList = [].concat(this.sourceList).reverse()
reversedList.forEach((source, index) => {
let qualityNumber = parseInt(source.qualityLabel.replace(/p|k/, ''))
if (qualityNumber === 4) {
qualityNumber = 2160
}
2020-09-25 04:35:13 +02:00
if (qualityNumber === 8) {
qualityNumber = 4320
}
2020-09-26 23:03:42 +02:00
if (defaultQuality === qualityNumber) {
2020-09-26 18:10:56 +02:00
this.selectedDefaultQuality = source.qualityLabel
}
2020-09-25 04:35:13 +02:00
if (index < (this.sourceList.length - 1)) {
let upperQualityNumber = parseInt(reversedList[index + 1].qualityLabel.replace(/p|k/, ''))
if (upperQualityNumber === 4) {
upperQualityNumber = 2160
}
if (upperQualityNumber === 8) {
upperQualityNumber = 4320
}
2020-09-26 23:03:42 +02:00
if (defaultQuality >= qualityNumber && defaultQuality < upperQualityNumber) {
2020-09-25 04:35:13 +02:00
this.selectedDefaultQuality = source.qualityLabel
}
2020-09-26 23:03:42 +02:00
} else if (qualityNumber <= defaultQuality) {
2020-09-25 04:35:13 +02:00
this.selectedDefaultQuality = source.qualityLabel
}
})
if (this.selectedDefaultQuality === '') {
this.selectedDefaultQuality = this.sourceList[this.sourceList.length - 1].qualityLabel
}
},
2020-09-26 23:03:42 +02:00
determineDefaultQualityDash: function () {
// TODO add settings and filtering for 60fps and HDR
2020-09-26 23:03:42 +02:00
if (this.defaultQuality === 'auto') {
this.selectedResolution = 'auto'
this.selectedFPS = 'auto'
this.selectedBitrate = 'auto'
this.selectedMimeType = 'auto'
} else {
const videoFormats = this.adaptiveFormats.filter(format => {
return (format.mimeType || format.type).startsWith('video') &&
typeof format.height === 'number'
2021-05-15 21:08:41 +02:00
})
// Select the quality that is identical to the users chosen default quality if it's available
// otherwise select the next lowest quality
let formatsToTest = videoFormats.filter(format => {
// For short videos (or vertical videos?)
// Height > width (e.g. H: 1280, W: 720)
if (typeof format.width === 'number' && format.height > format.width) {
return format.width === this.defaultQuality
}
return format.height === this.defaultQuality
})
if (formatsToTest.length === 0) {
formatsToTest = videoFormats.filter(format => {
// For short videos (or vertical videos?)
// Height > width (e.g. H: 1280, W: 720)
if (typeof format.width === 'number' && format.height > format.width) {
return format.width < this.defaultQuality
}
return format.height < this.defaultQuality
})
}
formatsToTest.sort((a, b) => {
if (a.height === b.height) {
// Higher bitrate for video formats with HDR qualities
// `height` and `fps` are the same but `bitrate` would be higher
return b.bitrate - a.bitrate
} else {
return b.height - a.height
}
})
2020-09-26 23:03:42 +02:00
const selectedFormat = formatsToTest[0]
this.currentAdaptiveFormat = selectedFormat
this.selectedBitrate = selectedFormat.bitrate
this.selectedResolution = `${selectedFormat.width}x${selectedFormat.height}`
this.selectedFPS = selectedFormat.fps
this.selectedMimeType = selectedFormat.mimeType || selectedFormat.type
}
},
2021-05-15 21:08:41 +02:00
setDashQualityLevel: function (bitrate) {
if (bitrate === this.selectedBitrate) {
return
}
2021-05-15 21:08:41 +02:00
let adaptiveFormat = null
if (bitrate !== 'auto') {
adaptiveFormat = this.activeAdaptiveFormats.find((format) => {
return format.bitrate === bitrate
})
}
let qualityLabel = adaptiveFormat ? adaptiveFormat.qualityLabel : ''
const qualityLevels = Array.from(this.player.qualityLevels())
if (bitrate === 'auto') {
qualityLevels.forEach(ql => {
2021-05-15 21:08:41 +02:00
ql.enabled = true
})
} else {
const previousBitrate = this.selectedBitrate
// if it was previously set to a specific quality we can disable just that and enable just the new one
// if it was previously set to auto, it means all qualitylevels were enabled, so we need to disable them
if (previousBitrate !== 'auto') {
const qualityLevel = qualityLevels.find(ql => bitrate === ql.bitrate)
qualityLevel.enabled = true
if (qualityLabel === '') {
qualityLabel = qualityLevel.height + 'p'
}
qualityLevels.find(ql => previousBitrate === ql.bitrate).enabled = false
2021-05-15 21:08:41 +02:00
} else {
qualityLevels.forEach(ql => {
if (bitrate === ql.bitrate) {
ql.enabled = true
if (qualityLabel === '') {
qualityLabel = ql.height + 'p'
}
} else {
ql.enabled = false
}
})
2021-05-15 21:08:41 +02:00
}
}
2021-05-15 21:08:41 +02:00
const selectedQuality = bitrate === 'auto' ? 'auto' : qualityLabel
2021-05-15 21:08:41 +02:00
const qualityElement = document.getElementById('vjs-current-quality')
// Include resolution of previous selection
// If new auto resolution is the same as the previous resolution
// the qualityLevels on 'change' event will not be called
qualityElement.innerText = (selectedQuality === 'auto') ? `auto ${this.currentAdaptiveFormat.qualityLabel}` : selectedQuality
2021-05-15 21:08:41 +02:00
this.selectedQuality = selectedQuality
if (selectedQuality !== 'auto') {
this.selectedResolution = `${adaptiveFormat.width}x${adaptiveFormat.height}`
this.selectedFPS = adaptiveFormat.fps
this.selectedBitrate = adaptiveFormat.bitrate
this.selectedMimeType = adaptiveFormat.mimeType
this.currentAdaptiveFormat = adaptiveFormat
} else {
// Default auto values to use previous selected values in case the adaptive format doesn't change
this.autoResolution = this.selectedResolution
this.autoFPS = this.selectedFPS
this.autoBitrate = this.selectedBitrate
this.autoMimeType = this.selectedMimeType
this.selectedResolution = 'auto'
this.selectedFPS = 'auto'
this.selectedBitrate = 'auto'
this.selectedMimeType = 'auto'
}
const qualityItems = document.querySelectorAll('.quality-item')
2021-05-15 21:08:41 +02:00
qualityItems.forEach((item) => {
item.classList.remove('quality-selected')
const qualityText = item.querySelector('.vjs-menu-item-text')
2021-05-15 21:08:41 +02:00
if (qualityText.innerText === selectedQuality.toLowerCase()) {
item.classList.add('quality-selected')
2021-05-15 21:08:41 +02:00
}
})
/* if (this.selectedQuality === qualityLevel && this.using60Fps === is60Fps) {
return
}
let foundSelectedQuality = false
this.using60Fps = is60Fps
this.player.qualityLevels().levels_.sort((a, b) => {
if (a.height === b.height) {
return a.bitrate - b.bitrate
} else {
return a.height - b.height
}
}).forEach((ql, index, arr) => {
if (foundSelectedQuality) {
ql.enabled = false
ql.enabled_(false)
} else if (qualityLevel === 'auto') {
ql.enabled = true
ql.enabled_(true)
} else if (ql.height === qualityLevel) {
2020-09-26 23:03:42 +02:00
ql.enabled = true
ql.enabled_(true)
foundSelectedQuality = true
let lowerQuality
let higherQuality
if ((index - 1) !== -1) {
lowerQuality = arr[index - 1]
}
if ((index + 1) < arr.length) {
higherQuality = arr[index + 1]
}
if (typeof (lowerQuality) !== 'undefined' && lowerQuality.height === ql.height && lowerQuality.bitrate < ql.bitrate && !is60Fps) {
ql.enabled = false
ql.enabled_(false)
foundSelectedQuality = false
}
if (typeof (higherQuality) !== 'undefined' && higherQuality.height === ql.height && higherQuality.bitrate > ql.bitrate && is60Fps) {
ql.enabled = false
ql.enabled_(false)
foundSelectedQuality = false
}
2020-09-26 23:03:42 +02:00
} else {
ql.enabled = false
ql.enabled_(false)
2020-09-26 23:03:42 +02:00
}
})
let selectedQuality = qualityLevel
if (selectedQuality !== 'auto' && is60Fps) {
selectedQuality = selectedQuality + 'p60'
} else if (selectedQuality !== 'auto') {
selectedQuality = selectedQuality + 'p'
}
const qualityElement = document.getElementById('vjs-current-quality')
qualityElement.innerText = selectedQuality
this.selectedQuality = qualityLevel
const qualityItems = $('.quality-item').get()
$('.quality-item').removeClass('quality-selected')
qualityItems.forEach((item) => {
const qualityText = $(item).find('.vjs-menu-item-text').get(0)
if (qualityText.innerText === selectedQuality) {
$(item).addClass('quality-selected')
}
})
// const currentTime = this.player.currentTime()
// this.player.currentTime(0)
2021-05-15 21:08:41 +02:00
// this.player.currentTime(currentTime) */
2020-09-26 23:03:42 +02:00
},
enableDashFormat: function () {
if (this.dashSrc === null) {
console.warn('No dash format available.')
return
}
this.useDash = true
this.useHls = false
this.activeSourceList = this.dashSrc
setTimeout(this.initializePlayer, 100)
},
enableLegacyFormat: function () {
if (this.sourceList.length === 0) {
console.error('No sources available')
return
}
this.useDash = false
this.useHls = false
this.activeSourceList = (this.proxyVideos || !process.env.IS_ELECTRON)
// use map here to return slightly different list without modifying original
? this.sourceList.map((source) => {
return {
...source,
url: getProxyUrl(source.url)
}
})
: this.sourceList
setTimeout(this.initializePlayer, 100)
},
togglePlayPause: function () {
if (this.player.paused()) {
this.playVideo()
} else {
this.player.pause()
}
},
changeDurationBySeconds: function (seconds) {
const currentTime = this.player.currentTime()
const newTime = currentTime + seconds
if (newTime < 0) {
this.player.currentTime(0)
} else if (newTime > this.player.duration) {
this.player.currentTime(this.player.duration)
} else {
this.player.currentTime(newTime)
}
},
changePlayBackRate: function (rate) {
const newPlaybackRate = (this.player.playbackRate() + rate).toFixed(2)
if (newPlaybackRate >= this.videoPlaybackRateInterval && newPlaybackRate <= this.maxVideoPlaybackRate) {
this.player.playbackRate(newPlaybackRate)
}
},
framebyframe: function (step) {
if (this.format === 'audio') {
return
}
this.player.pause()
let fps
if (this.useDash) {
const qualityLevels = this.player.qualityLevels()
fps = qualityLevels[qualityLevels.selectedIndex].frameRate
} else {
// legacy formats
const currentPlayerSourceUrl = this.player.currentSource().src
fps = this.sourceList.find(source => source.url === currentPlayerSourceUrl)?.fps
}
if (isNaN(fps)) {
fps = 30
}
// The 3 lines below were taken from the videojs-framebyframe node module by Helena Rasche
const frameTime = 1 / fps
const dist = frameTime * step
this.player.currentTime(this.player.currentTime() + dist)
},
changeVolume: function (volume) {
const currentVolume = this.player.volume()
const newVolume = currentVolume + volume
if (newVolume < 0) {
this.player.volume(0)
} else if (newVolume > 1) {
this.player.volume(1)
} else {
this.player.volume(newVolume)
}
},
toggleMute: function () {
if (this.player.muted()) {
this.player.muted(false)
} else {
this.player.muted(true)
}
},
toggleCaptions: function () {
// skip videojs-http-streaming's segment-metadata track
// https://github.com/videojs/http-streaming#segment-metadata
const trackIndex = this.useDash ? 1 : 0
const tracks = this.player.textTracks()
// visually and semantically disable any other enabled tracks
for (let i = 0; i < tracks.length; ++i) {
if (i !== trackIndex && tracks[i].mode === 'showing') {
tracks[i].mode = 'disabled'
}
}
if (tracks.length > trackIndex) {
if (tracks[trackIndex].mode === 'showing') {
tracks[trackIndex].mode = 'disabled'
} else {
tracks[trackIndex].mode = 'showing'
}
}
},
createLoopButton: function () {
const toggleVideoLoop = this.toggleVideoLoop
const VjsButton = videojs.getComponent('Button')
class loopButton extends VjsButton {
handleClick() {
toggleVideoLoop()
}
createControlTextEl(button) {
button.title = 'Toggle Loop'
const div = document.createElement('div')
div.id = 'loopButton'
div.className = 'vjs-icon-loop loop-white vjs-button'
button.appendChild(div)
return div
}
}
videojs.registerComponent('loopButton', loopButton)
},
toggleVideoLoop: function () {
const loopButton = document.getElementById('loopButton')
if (!this.player.loop()) {
2022-06-19 20:39:29 +02:00
const currentTheme = this.$store.state.settings.mainColor
const colorValue = colors.find(color => color.name === currentTheme).value
const themeTextColor = calculateColorLuminance(colorValue)
loopButton.classList.add('vjs-icon-loop-active')
if (themeTextColor === '#000000') {
loopButton.classList.add('loop-black')
loopButton.classList.remove('loop-white')
}
this.player.loop(true)
} else {
loopButton.classList.remove('vjs-icon-loop-active')
loopButton.classList.remove('loop-black')
loopButton.classList.add('loop-white')
this.player.loop(false)
}
},
createFullWindowButton: function () {
const toggleFullWindow = this.toggleFullWindow
const VjsButton = videojs.getComponent('Button')
class fullWindowButton extends VjsButton {
handleClick() {
toggleFullWindow()
}
createControlTextEl(button) {
// Add class name to button to be able to target it with CSS selector
button.classList.add('vjs-button-fullwindow')
button.title = 'Full Window'
const div = document.createElement('div')
div.id = 'fullwindow'
div.className = 'vjs-icon-fullwindow-enter vjs-button'
button.appendChild(div)
return div
}
}
videojs.registerComponent('fullWindowButton', fullWindowButton)
},
createToggleTheatreModeButton: function () {
if (!this.theatrePossible) {
return
}
const theatreModeActive = this.useTheatreMode ? ' vjs-icon-theatre-active' : ''
const toggleTheatreMode = this.toggleTheatreMode
const VjsButton = videojs.getComponent('Button')
class toggleTheatreModeButton extends VjsButton {
handleClick() {
toggleTheatreMode()
}
createControlTextEl(button) {
button.classList.add('vjs-button-theatre')
button.title = 'Toggle Theatre Mode'
const div = document.createElement('div')
div.id = 'toggleTheatreModeButton'
div.className = `vjs-icon-theatre-inactive${theatreModeActive} vjs-button`
button.appendChild(div)
return button
}
}
videojs.registerComponent('toggleTheatreModeButton', toggleTheatreModeButton)
},
toggleTheatreMode: function () {
if (!this.player.isFullscreen_) {
const toggleTheatreModeButton = document.getElementById('toggleTheatreModeButton')
if (!this.useTheatreMode) {
toggleTheatreModeButton.classList.add('vjs-icon-theatre-active')
} else {
toggleTheatreModeButton.classList.remove('vjs-icon-theatre-active')
}
}
this.$emit('toggle-theatre-mode')
},
createScreenshotButton: function () {
const takeScreenshot = this.takeScreenshot
const VjsButton = videojs.getComponent('Button')
class screenshotButton extends VjsButton {
handleClick() {
takeScreenshot()
const video = document.getElementsByTagName('video')[0]
video.focus()
video.blur()
}
createControlTextEl(button) {
button.classList.add('vjs-hidden')
button.title = 'Screenshot'
const div = document.createElement('div')
div.id = 'screenshotButton'
div.className = 'vjs-icon-screenshot vjs-button'
button.appendChild(div)
return div
}
}
videojs.registerComponent('screenshotButton', screenshotButton)
},
toggleScreenshotButton: function () {
const button = document.getElementById('screenshotButton').parentNode
if (this.enableScreenshot && this.format !== 'audio') {
button.classList.remove('vjs-hidden')
} else {
button.classList.add('vjs-hidden')
}
},
takeScreenshot: async function () {
if (!this.enableScreenshot || this.format === 'audio') {
return
}
const width = this.player.videoWidth()
const height = this.player.videoHeight()
if (width <= 0) {
return
}
// Need to set crossorigin="anonymous" for LegacyFormat on Invidious
// https://developer.mozilla.org/en-US/docs/Web/HTML/CORS_enabled_image
const video = document.querySelector('video')
const canvas = document.createElement('canvas')
canvas.width = width
canvas.height = height
canvas.getContext('2d').drawImage(video, 0, 0)
const format = this.screenshotFormat
const mimeType = `image/${format === 'jpg' ? 'jpeg' : format}`
const imageQuality = format === 'jpg' ? this.screenshotQuality / 100 : 1
let filename
try {
filename = await this.parseScreenshotCustomFileName({
date: new Date(Date.now()),
playerTime: this.player.currentTime(),
videoId: this.videoId
})
} catch (err) {
console.error(`Parse failed: ${err.message}`)
2022-10-14 07:59:49 +02:00
showToast(this.$t('Screenshot Error', { error: err.message }))
canvas.remove()
return
}
let subDir = ''
if (filename.indexOf(path.sep) !== -1) {
const lastIndex = filename.lastIndexOf(path.sep)
subDir = filename.substring(0, lastIndex)
filename = filename.substring(lastIndex + 1)
}
const filenameWithExtension = `${filename}.${format}`
let dirPath
let filePath
if (this.screenshotAskPath) {
const wasPlaying = !this.player.paused()
if (wasPlaying) {
this.player.pause()
}
if (this.screenshotFolder === '' || !(await pathExists(this.screenshotFolder))) {
dirPath = await getPicturesPath()
} else {
dirPath = this.screenshotFolder
}
const options = {
defaultPath: path.join(dirPath, filenameWithExtension),
filters: [
{
name: format.toUpperCase(),
extensions: [format]
}
]
}
const response = await showSaveDialog(options)
if (wasPlaying) {
this.playVideo()
}
if (response.canceled || response.filePath === '') {
canvas.remove()
return
}
filePath = response.filePath
if (!filePath.endsWith(`.${format}`)) {
filePath = `${filePath}.${format}`
}
dirPath = path.dirname(filePath)
this.updateScreenshotFolderPath(dirPath)
} else {
if (this.screenshotFolder === '') {
dirPath = path.join(await getPicturesPath(), 'Freetube', subDir)
} else {
dirPath = path.join(this.screenshotFolder, subDir)
}
if (!(await pathExists(dirPath))) {
try {
fs.mkdir(dirPath, { recursive: true })
} catch (err) {
console.error(err)
2022-10-14 07:59:49 +02:00
showToast(this.$t('Screenshot Error', { error: err }))
canvas.remove()
return
}
}
filePath = path.join(dirPath, filenameWithExtension)
}
canvas.toBlob((result) => {
result.arrayBuffer().then(ab => {
const arr = new Uint8Array(ab)
fs.writeFile(filePath, arr)
.then(() => {
showToast(this.$t('Screenshot Success', { filePath }))
})
.catch((err) => {
console.error(err)
2022-10-14 07:59:49 +02:00
showToast(this.$t('Screenshot Error', { error: err }))
})
})
}, mimeType, imageQuality)
canvas.remove()
},
createDashQualitySelector: function (levels) {
const currentAdaptiveFormat = this.currentAdaptiveFormat
const adaptiveFormats = this.adaptiveFormats
const activeAdaptiveFormats = this.activeAdaptiveFormats
const setDashQualityLevel = this.setDashQualityLevel
const defaultQuality = this.defaultQuality
const defaultBitrate = this.selectedBitrate
const autoResolution = this.autoResolution
const VjsButton = videojs.getComponent('Button')
class dashQualitySelector extends VjsButton {
handleClick(event) {
if (!event.target.classList.contains('quality-item') && !event.target.classList.contains('vjs-menu-item-text')) {
return
}
const selectedQuality = event.target.innerText
2021-05-15 21:08:41 +02:00
const bitrate = selectedQuality === 'auto' ? 'auto' : parseInt(event.target.attributes.bitrate.value)
setDashQualityLevel(bitrate)
}
createControlTextEl(button) {
const beginningHtml = `<div class="vjs-quality-level-value">
<span id="vjs-current-quality">1080p</span>
</div>
<div class="vjs-quality-level-menu vjs-menu">
<ul class="vjs-menu-content" role="menu">`
const endingHtml = '</ul></div>'
const defaultIsAuto = defaultQuality === 'auto'
let qualityHtml = `<li class="vjs-menu-item quality-item ${defaultIsAuto ? 'quality-selected' : ''}" role="menuitemradio" tabindex="-1" aria-checked="false" aria-disabled="false">
<span class="vjs-menu-item-text">Auto</span>
<span class="vjs-control-text" aria-live="polite"></span>
</li>`
let currentQualityLabel
Array.from(levels).sort((a, b) => {
if (b.height === a.height) {
return b.bitrate - a.bitrate
} else {
return b.height - a.height
}
}).forEach((quality, index, array) => {
let fps
let qualityLabel
let bitrate
2021-05-15 21:08:41 +02:00
if (typeof adaptiveFormats !== 'undefined' && adaptiveFormats.length > 0) {
const adaptiveFormat = adaptiveFormats.find((format) => {
return format.bitrate === quality.bitrate
})
2021-05-15 21:08:41 +02:00
if (typeof adaptiveFormat === 'undefined') {
return
}
activeAdaptiveFormats.push(adaptiveFormat)
fps = adaptiveFormat.fps ? adaptiveFormat.fps : 30
qualityLabel = adaptiveFormat.qualityLabel ? adaptiveFormat.qualityLabel : quality.height + 'p'
bitrate = quality.bitrate
} else {
fps = 30
qualityLabel = quality.height + 'p'
bitrate = quality.bitrate
}
2021-05-15 21:08:41 +02:00
const isSelected = !defaultIsAuto && bitrate === defaultBitrate
if (isSelected) {
currentQualityLabel = qualityLabel
}
qualityHtml += `<li class="vjs-menu-item quality-item ${isSelected ? 'quality-selected' : ''}" role="menuitemradio" tabindex="-1" aria-checked="false" aria-disabled="false" fps="${fps}" bitrate="${bitrate}">
2021-05-15 21:08:41 +02:00
<span class="vjs-menu-item-text" fps="${fps}" bitrate="${bitrate}">${qualityLabel}</span>
<span class="vjs-control-text" aria-live="polite"></span>
</li>`
// Old logic, revert if needed.
/* let is60Fps = false
if (index < array.length - 1 && array[index + 1].height === quality.height) {
if (array[index + 1].bitrate < quality.bitrate) {
is60Fps = true
}
}
const qualityText = is60Fps ? quality.height + 'p60' : quality.height + 'p'
qualityHtml = qualityHtml + `<li class="vjs-menu-item quality-item" role="menuitemradio" tabindex="-1" aria-checked="false aria-disabled="false">
<span class="vjs-menu-item-text">${qualityText}</span>
<span class="vjs-control-text" aria-live="polite"></span>
2021-05-15 21:08:41 +02:00
</li>` */
})
// the default width is 3em which is too narrow for qualitly labels with fps e.g. 1080p60
button.style.width = '4em'
button.title = 'Select Quality'
button.innerHTML = beginningHtml + qualityHtml + endingHtml
let autoQualityLabel = 'auto'
if (currentAdaptiveFormat) {
autoQualityLabel += ` ${currentAdaptiveFormat.qualityLabel}`
} else if (autoResolution !== '') {
autoQualityLabel += ` ${autoResolution.split('x')[1]}p`
}
// For default auto, it may select a resolution before generating the quality buttons
button.querySelector('#vjs-current-quality').innerText = defaultIsAuto ? autoQualityLabel : currentQualityLabel
const vjsMenu = button.querySelector('.vjs-menu')
let isTapping = false
button.addEventListener('touchstart', () => {
isTapping = true
})
button.addEventListener('touchmove', () => {
// if they are moving, they cannot be tapping
isTapping = false
})
button.addEventListener('touchend', (e) => {
if (isTapping) {
button.focus()
// make it easier to toggle the vjs-menu on touch (hover css is inconsistent w/ touch)
if (!e.target.classList.contains('quality-item') && !e.target.classList.contains('vjs-menu-item-text')) {
vjsMenu.classList.toggle('vjs-lock-showing')
} else {
// hide the quality selector on select (just like the other quality selectors do on mobile)
vjsMenu.classList.remove('vjs-lock-showing')
}
this.handleClick(e)
isTapping = false
}
})
button.addEventListener('focusout', () => {
// remove class which shows the selector
vjsMenu.classList.remove('vjs-lock-showing')
})
button.classList.add('dash-selector')
return button.children[0]
}
}
videojs.registerComponent('dashQualitySelector', dashQualitySelector)
this.player.controlBar.addChild('dashQualitySelector', {}, this.player.controlBar.children_.length - 1)
},
sortCaptions: function (captionList) {
return captionList.sort((captionA, captionB) => {
const aCode = captionA.language_code.split('-') // ex. [en,US]
const bCode = captionB.language_code.split('-')
const aName = (captionA.label) // ex: english (auto-generated)
const bName = (captionB.label)
const aIsAutotranslated = captionA.is_autotranslated
const bIsAutotranslated = captionB.is_autotranslated
const userLocale = this.currentLocale.split('-') // ex. [en,US]
if (aCode[0] === userLocale[0]) { // caption a has same language as user's locale
if (bCode[0] === userLocale[0]) { // caption b has same language as user's locale
if (bName.search('auto') !== -1) {
// prefer caption a: b is auto-generated captions
return -1
} else if (aName.search('auto') !== -1) {
// prefer caption b: a is auto-generated captions
return 1
} else if (bIsAutotranslated) {
// prefer caption a: b is auto-translated captions
return -1
} else if (aIsAutotranslated) {
// prefer caption b: a is auto-translated captions
return 1
} else if (aCode[1] === userLocale[1]) {
// prefer caption a: caption a has same county code as user's locale
return -1
} else if (bCode[1] === userLocale[1]) {
// prefer caption b: caption b has same county code as user's locale
return 1
} else if (aCode[1] === undefined) {
// prefer caption a: no country code is better than wrong country code
return -1
} else if (bCode[1] === undefined) {
// prefer caption b: no country code is better than wrong country code
return 1
}
} else {
// prefer caption a: b does not match user's language
return -1
}
} else if (bCode[0] === userLocale[0]) {
// prefer caption b: a does not match user's language
return 1
}
// sort alphabetically
return aName.localeCompare(bName, this.currentLocale)
})
},
transformAndInsertCaptions: async function () {
2021-03-17 02:28:25 +01:00
let captionList
if (this.captionHybridList[0] instanceof Promise) {
captionList = await Promise.all(this.captionHybridList)
this.$emit('store-caption-list', captionList)
} else {
captionList = this.captionHybridList
}
this.sortCaptions(captionList).forEach((caption, i) =>
2021-03-17 02:28:25 +01:00
this.player.addRemoteTextTrack({
kind: 'subtitles',
src: caption.url,
srclang: caption.language_code,
label: caption.label,
type: caption.type,
default: i === 0 && this.enableSubtitlesByDefault
2021-03-17 02:28:25 +01:00
}, true)
)
2021-03-17 02:28:25 +01:00
},
toggleFullWindow: function () {
if (!this.player.isFullscreen_) {
if (this.player.isFullWindow) {
this.player.removeClass('vjs-full-screen')
this.player.isFullWindow = false
document.documentElement.style.overflow = this.player.docOrigOverflow
document.body.classList.remove('vjs-full-window')
document.getElementById('fullwindow').classList.remove('vjs-icon-fullwindow-exit')
this.player.trigger('exitFullWindow')
} else {
this.player.addClass('vjs-full-screen')
this.player.isFullscreen_ = false
this.player.isFullWindow = true
this.player.docOrigOverflow = document.documentElement.style.overflow
document.documentElement.style.overflow = 'hidden'
document.body.classList.add('vjs-full-window')
document.getElementById('fullwindow').classList.add('vjs-icon-fullwindow-exit')
this.player.trigger('enterFullWindow')
}
}
},
exitFullWindow: function () {
if (this.player.isFullWindow) {
this.player.isFullWindow = false
document.documentElement.style.overflow = this.player.docOrigOverflow
this.player.removeClass('vjs-full-screen')
document.body.classList.remove('vjs-full-window')
document.getElementById('fullwindow').classList.remove('vjs-icon-fullwindow-exit')
this.player.trigger('exitFullWindow')
}
},
toggleFullscreen: function () {
if (this.player.isFullscreen()) {
this.player.exitFullscreen()
} else {
this.player.requestFullscreen()
}
},
fullscreenOverlay: function () {
const title = document.title.replace('- FreeTube', '')
if (this.player.isFullscreen()) {
this.player.ready(() => {
this.player.overlay({
overlays: [{
showBackground: false,
content: title,
start: 'mousemove',
end: 'userinactive'
}]
})
})
} else {
this.player.ready(() => {
this.player.overlay({
overlays: [{
showBackground: false,
content: ' ',
start: 'play',
end: 'loadstart'
}]
})
})
}
},
toggleFullscreenClass: function () {
if (this.player.isFullscreen()) {
document.body.classList.add('vjs--full-screen-enabled')
} else {
document.body.classList.remove('vjs--full-screen-enabled')
}
},
Improving touch input by bringing in `videojs-mobile-ui` (#2719) * Adding videojs-mobile-ui as a dependency - Using the beta because it fixes an issue with multiple version of videojs loading at once. This is related to MarmadileManteater#56. * Mapping defaultSkipInterval to seekSeconds * Adding CSS to prevent showing duplicate buttons - Added a new variable `usingTouch` to selectively hide the existing `vjs-big-play-button` when the `touch-overlay` is visible. * Renaming CSS class to something more specific * Adding text-shadow behind play / pause button This should make it more visually distinct against a light or colorful background. * Enabling touch-overlay anytime a touch is detected Disabling it whenever mouse input is detected The default behavior of `videojs-mobile-ui` is to only work in Android and iOS, but by forcing the touch behavior to be enabled and selectively showing it only when touch input is detected, it should work on any device with touch input even if the browser doesn't detect that it is running in Android or iOS. * Removing unnecessary code * Removing unintentionally leftover variable * Removing an unnecessary assignment Adding comments to explain why a flag called `forceForTesting` is set to true Disabling the `lockOnRotate` flag. * Moving this flag and wrapper class * Adding whitespace * Making my comment a little more consistent * Changing the punctuation of a comment * Adjusting the CSS to fix a firefox discrepancy * Adding a check for mobile firefox For whatever reason, mobile firefox sometimes triggers onmouseover when a touch occurs, and this is unwanted behavior. * Adding a drop-shadow to the ff and rw icons This should make them easier to see on top of light videos * Hiding the mobile play button according to setting - Added CSS to hide the videojs-mobile-ui play button when the `Display Play Button In Video Player` setting is disabled. * Replacing long computed string with class binding Co-authored-by: absidue <48293849+absidue@users.noreply.github.com> * Adding newline at the end of videoJS.css Co-authored-by: absidue <48293849+absidue@users.noreply.github.com>
2022-10-24 19:49:52 +02:00
handleTouchStart: function () {
this.usingTouch = true
},
Improving touch input by bringing in `videojs-mobile-ui` (#2719) * Adding videojs-mobile-ui as a dependency - Using the beta because it fixes an issue with multiple version of videojs loading at once. This is related to MarmadileManteater#56. * Mapping defaultSkipInterval to seekSeconds * Adding CSS to prevent showing duplicate buttons - Added a new variable `usingTouch` to selectively hide the existing `vjs-big-play-button` when the `touch-overlay` is visible. * Renaming CSS class to something more specific * Adding text-shadow behind play / pause button This should make it more visually distinct against a light or colorful background. * Enabling touch-overlay anytime a touch is detected Disabling it whenever mouse input is detected The default behavior of `videojs-mobile-ui` is to only work in Android and iOS, but by forcing the touch behavior to be enabled and selectively showing it only when touch input is detected, it should work on any device with touch input even if the browser doesn't detect that it is running in Android or iOS. * Removing unnecessary code * Removing unintentionally leftover variable * Removing an unnecessary assignment Adding comments to explain why a flag called `forceForTesting` is set to true Disabling the `lockOnRotate` flag. * Moving this flag and wrapper class * Adding whitespace * Making my comment a little more consistent * Changing the punctuation of a comment * Adjusting the CSS to fix a firefox discrepancy * Adding a check for mobile firefox For whatever reason, mobile firefox sometimes triggers onmouseover when a touch occurs, and this is unwanted behavior. * Adding a drop-shadow to the ff and rw icons This should make them easier to see on top of light videos * Hiding the mobile play button according to setting - Added CSS to hide the videojs-mobile-ui play button when the `Display Play Button In Video Player` setting is disabled. * Replacing long computed string with class binding Co-authored-by: absidue <48293849+absidue@users.noreply.github.com> * Adding newline at the end of videoJS.css Co-authored-by: absidue <48293849+absidue@users.noreply.github.com>
2022-10-24 19:49:52 +02:00
handleMouseOver: function () {
// This addresses a discrepancy that only seems to occur in the mobile version of firefox
if (navigator.userAgent.search('Firefox') !== -1 && (videojs.browser.IS_ANDROID || videojs.browser.IS_IOS)) { return }
this.usingTouch = false
},
Improving touch input by bringing in `videojs-mobile-ui` (#2719) * Adding videojs-mobile-ui as a dependency - Using the beta because it fixes an issue with multiple version of videojs loading at once. This is related to MarmadileManteater#56. * Mapping defaultSkipInterval to seekSeconds * Adding CSS to prevent showing duplicate buttons - Added a new variable `usingTouch` to selectively hide the existing `vjs-big-play-button` when the `touch-overlay` is visible. * Renaming CSS class to something more specific * Adding text-shadow behind play / pause button This should make it more visually distinct against a light or colorful background. * Enabling touch-overlay anytime a touch is detected Disabling it whenever mouse input is detected The default behavior of `videojs-mobile-ui` is to only work in Android and iOS, but by forcing the touch behavior to be enabled and selectively showing it only when touch input is detected, it should work on any device with touch input even if the browser doesn't detect that it is running in Android or iOS. * Removing unnecessary code * Removing unintentionally leftover variable * Removing an unnecessary assignment Adding comments to explain why a flag called `forceForTesting` is set to true Disabling the `lockOnRotate` flag. * Moving this flag and wrapper class * Adding whitespace * Making my comment a little more consistent * Changing the punctuation of a comment * Adjusting the CSS to fix a firefox discrepancy * Adding a check for mobile firefox For whatever reason, mobile firefox sometimes triggers onmouseover when a touch occurs, and this is unwanted behavior. * Adding a drop-shadow to the ff and rw icons This should make them easier to see on top of light videos * Hiding the mobile play button according to setting - Added CSS to hide the videojs-mobile-ui play button when the `Display Play Button In Video Player` setting is disabled. * Replacing long computed string with class binding Co-authored-by: absidue <48293849+absidue@users.noreply.github.com> * Adding newline at the end of videoJS.css Co-authored-by: absidue <48293849+absidue@users.noreply.github.com>
2022-10-24 19:49:52 +02:00
toggleShowStatsModal: function () {
if (this.format !== 'dash') {
2022-10-14 07:59:49 +02:00
showToast(this.$t('Video.Stats.Video statistics are not available for legacy videos'))
} else {
this.showStatsModal = !this.showStatsModal
}
},
createStatsModal: function () {
const ModalDialog = videojs.getComponent('ModalDialog')
this.statsModal = new ModalDialog(this.player, {
temporary: false,
pauseOnOpen: false
})
this.statsModal.handleKeyDown_ = (event) => {
// the default handler prevents keyboard events propagating beyond the modal
// the modal should only handle the escape and tab key, all others should be handled by the player
if (event.key === 'Escape' || event.key === 'Tab') {
this.statsModal.handleKeyDown(event)
}
}
this.player.addChild(this.statsModal)
this.statsModal.el_.classList.add('statsModal')
this.statsModal.on('modalclose', () => {
this.showStatsModal = false
})
},
updateStatsContent: function () {
if (this.showStatsModal) {
this.statsModal.contentEl().innerHTML = this.getFormattedStats()
}
},
getFormattedStats: function () {
const currentVolume = this.player.muted() ? 0 : this.player.volume()
const volume = `${(currentVolume * 100).toFixed(0)}%`
const bandwidth = `${(this.playerStats.bandwidth / 1000).toFixed(2)}kbps`
const buffered = `${(this.player.bufferedPercent() * 100).toFixed(0)}%`
const droppedFrames = this.playerStats.videoPlaybackQuality.droppedVideoFrames
const totalFrames = this.playerStats.videoPlaybackQuality.totalVideoFrames
const frames = `${droppedFrames} / ${totalFrames}`
const resolution = this.selectedResolution === 'auto' ? `${this.autoResolution}@${this.autoFPS}fps (auto)` : `${this.selectedResolution}@${this.selectedFPS}fps`
const playerDimensions = `${this.playerStats.playerDimensions.width}x${this.playerStats.playerDimensions.height}`
const bitrate = this.selectedBitrate === 'auto' ? `${this.autoBitrate} (auto)` : this.selectedBitrate
const mimeType = this.selectedMimeType === 'auto' ? `${this.autoMimeType} (auto)` : this.selectedMimeType
const statsArray = [
[this.$t('Video.Stats.Video ID'), this.videoId],
[this.$t('Video.Stats.Resolution'), resolution],
[this.$t('Video.Stats.Player Dimensions'), playerDimensions],
[this.$t('Video.Stats.Bitrate'), bitrate],
[this.$t('Video.Stats.Volume'), volume],
[this.$t('Video.Stats.Bandwidth'), bandwidth],
[this.$t('Video.Stats.Buffered'), buffered],
[this.$t('Video.Stats.Dropped / Total Frames'), frames],
[this.$t('Video.Stats.Mimetype'), mimeType]
]
let listContentHTML = ''
statsArray.forEach((stat) => {
const content = `<p>${stat[0]}: ${stat[1]}</p>`
listContentHTML += content
})
return listContentHTML
},
/**
* determines whether the jump to the previous or next chapter
* with the the keyboard shortcuts, should be done
* first it checks whether there are any chapters (the array is also empty if chapters are hidden)
* it also checks that the approprate combination was used ALT/OPTION on macOS and CTRL everywhere else
* @param {KeyboardEvent} event the keyboard event
* @param {string} direction the direction of the jump either previous or next
* @returns {boolean}
*/
canChapterJump: function (event, direction) {
const currentChapter = this.currentChapterIndex
return this.chapters.length > 0 &&
(direction === 'previous' ? currentChapter > 0 : this.chapters.length - 1 !== currentChapter) &&
((process.platform !== 'darwin' && event.ctrlKey) ||
(process.platform === 'darwin' && event.metaKey))
},
playVideo(thenFunc = null) {
// It can be called in `setTimeout` & user can navigate to other pages before it runs
// Which makes `this.player` become `null`
if (this.player == null) { return }
let promise = this.player.play()
if (typeof thenFunc === 'function') {
promise = promise.then(thenFunc)
}
promise
.catch(err => {
if (EXPECTED_PLAY_RELATED_ERROR_MESSAGES.some(msg => err.message.includes(msg))) {
// Ignoring expected exception
// console.debug('Ignoring expected error')
// console.debug(err)
return
}
// Unexpected errors should be reported
console.error(err)
const errorMessage = this.$t('play() request Error (Click to copy)')
showToast(`${errorMessage}: ${err}`, 10000, () => {
copyToClipboard(err)
})
})
},
// This function should always be at the bottom of this file
/**
* @param {KeyboardEvent} event
*/
keyboardShortcutHandler: function (event) {
if (document.activeElement.classList.contains('ft-input') || event.altKey) {
return
}
// allow chapter jump keyboard shortcuts
if (event.ctrlKey && (process.platform === 'darwin' || (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight'))) {
return
}
// allow copying text
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'c') {
return
}
if (this.player !== null) {
switch (event.key) {
case ' ':
case 'Spacebar': // older browsers might return spacebar instead of a space character
// Toggle Play/Pause
event.preventDefault()
this.togglePlayPause()
break
case 'J':
case 'j':
// Rewind by 2x the time-skip interval (in seconds)
event.preventDefault()
this.changeDurationBySeconds(-this.defaultSkipInterval * this.player.playbackRate() * 2)
break
case 'K':
case 'k':
// Toggle Play/Pause
event.preventDefault()
this.togglePlayPause()
break
case 'L':
case 'l':
// Fast-Forward by 2x the time-skip interval (in seconds)
event.preventDefault()
this.changeDurationBySeconds(this.defaultSkipInterval * this.player.playbackRate() * 2)
break
case 'O':
case 'o':
// Decrease playback rate by 0.25x
event.preventDefault()
this.changePlayBackRate(-this.videoPlaybackRateInterval)
break
case 'P':
case 'p':
// Increase playback rate by 0.25x
event.preventDefault()
this.changePlayBackRate(this.videoPlaybackRateInterval)
break
case 'F':
case 'f':
// Toggle Fullscreen Playback
event.preventDefault()
this.toggleFullscreen()
break
case 'M':
case 'm':
// Toggle Mute
if (!event.metaKey) {
event.preventDefault()
this.toggleMute()
}
break
case 'C':
case 'c':
// Toggle Captions
event.preventDefault()
this.toggleCaptions()
break
case 'ArrowUp':
// Increase volume
event.preventDefault()
this.changeVolume(0.05)
break
case 'ArrowDown':
// Decrease Volume
event.preventDefault()
this.changeVolume(-0.05)
break
case 'ArrowLeft':
event.preventDefault()
if (this.canChapterJump(event, 'previous')) {
// Jump to the previous chapter
this.player.currentTime(this.chapters[this.currentChapterIndex - 1].startSeconds)
} else {
// Rewind by the time-skip interval (in seconds)
this.changeDurationBySeconds(-this.defaultSkipInterval * this.player.playbackRate())
}
break
case 'ArrowRight':
event.preventDefault()
if (this.canChapterJump(event, 'next')) {
// Jump to the next chapter
this.player.currentTime(this.chapters[this.currentChapterIndex + 1].startSeconds)
} else {
// Fast-Forward by the time-skip interval (in seconds)
this.changeDurationBySeconds(this.defaultSkipInterval * this.player.playbackRate())
}
break
case 'I':
case 'i':
event.preventDefault()
// Toggle Picture in Picture Mode
if (this.format !== 'audio' && !this.player.isInPictureInPicture()) {
this.player.requestPictureInPicture()
} else if (this.player.isInPictureInPicture()) {
this.player.exitPictureInPicture()
}
break
case '0':
case '1':
case '2':
case '3':
case '4':
case '5':
case '6':
case '7':
case '8':
case '9': {
// Jump to percentage in the video
event.preventDefault()
const percentage = parseInt(event.key) / 10
const duration = this.player.duration()
const newTime = duration * percentage
this.player.currentTime(newTime)
break
}
case ',':
// Return to previous frame
this.framebyframe(-1)
break
case '.':
// Advance to next frame
this.framebyframe(1)
break
case 'D':
case 'd':
event.preventDefault()
this.toggleShowStatsModal()
break
case 'Escape':
// Exit full window
event.preventDefault()
this.exitFullWindow()
break
case 'S':
case 's':
// Toggle Full Window Mode
this.toggleFullWindow()
break
case 'T':
case 't':
2021-09-15 04:00:34 +02:00
// Toggle Theatre Mode
this.toggleTheatreMode()
break
case 'U':
case 'u':
// Take screenshot
this.takeScreenshot()
break
}
}
},
stopPowerSaveBlocker: function () {
if (process.env.IS_ELECTRON && this.powerSaveBlocker !== null) {
const { ipcRenderer } = require('electron')
ipcRenderer.send(IpcChannels.STOP_POWER_SAVE_BLOCKER, this.powerSaveBlocker)
this.powerSaveBlocker = null
}
},
...mapActions([
'updateDefaultCaptionSettings',
'parseScreenshotCustomFileName',
'updateScreenshotFolderPath',
])
2020-02-16 19:30:00 +01:00
}
})