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

1965 lines
62 KiB
JavaScript
Raw Normal View History

2020-02-16 19:30:00 +01:00
import Vue from 'vue'
import { mapActions } from 'vuex'
2020-02-16 19:30:00 +01:00
import FtCard from '../ft-card/ft-card.vue'
import $ from 'jquery'
2020-02-16 19:30:00 +01:00
import videojs from 'video.js'
import qualitySelector from '@silvermine/videojs-quality-selector'
import fs from 'fs'
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'
2020-02-16 19:30:00 +01:00
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'
2020-02-16 19:30:00 +01:00
export default Vue.extend({
name: 'FtVideoPlayer',
components: {
'ft-card': FtCard
},
beforeRouteLeave: function () {
if (this.player !== null) {
this.exitFullWindow()
}
if (this.player !== null && !this.player.isInPictureInPicture()) {
this.player.dispose()
this.player = null
clearTimeout(this.mouseTimeout)
} else if (this.player.isInPictureInPicture()) {
this.player.play()
}
2021-01-13 21:56:25 +01:00
if (this.usingElectron && this.powerSaveBlocker !== null) {
const { ipcRenderer } = require('electron')
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
ipcRenderer.send(IpcChannels.STOP_POWER_SAVE_BLOCKER, this.powerSaveBlocker)
2021-01-13 21:56:25 +01:00
}
},
2020-02-16 19:30:00 +01:00
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
},
hlsSrc: {
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
2020-02-16 19:30:00 +01:00
}
},
data: function () {
return {
id: '',
powerSaveBlocker: null,
volume: 1,
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,
using60Fps: false,
maxFramerate: 0,
activeSourceList: [],
2021-05-15 21:08:41 +02:00
activeAdaptiveFormats: [],
2020-02-21 19:31:32 +01:00
mouseTimeout: null,
touchTimeout: null,
lastTouchTime: null,
playerStats: null,
statsModal: null,
showStatsModal: false,
statsModalEventName: 'updateStats',
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',
'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: {
usingElectron: function () {
return this.$store.getters.getUsingElectron
},
currentLocale: function () {
return this.$store.getters.getCurrentLocale
},
defaultPlayback: function () {
return this.$store.getters.getDefaultPlayback
},
defaultSkipInterval: function () {
return this.$store.getters.getDefaultSkipInterval
},
defaultQuality: function () {
2020-09-26 18:10:56 +02:00
return parseInt(this.$store.getters.getDefaultQuality)
},
defaultCaptionSettings: function () {
try {
return JSON.parse(this.$store.getters.getDefaultCaptionSettings)
} catch (e) {
console.log(e)
return {}
}
},
defaultVideoFormat: function () {
return this.$store.getters.getDefaultVideoFormat
},
autoplayVideos: function () {
return this.$store.getters.getAutoplayVideos
},
videoVolumeMouseScroll: function () {
return this.$store.getters.getVideoVolumeMouseScroll
},
videoPlaybackRateMouseScroll: function () {
return this.$store.getters.getVideoPlaybackRateMouseScroll
},
useSponsorBlock: function () {
return this.$store.getters.getUseSponsorBlock
},
sponsorBlockShowSkippedToast: function () {
return this.$store.getters.getSponsorBlockShowSkippedToast
},
displayVideoPlayButton: function() {
return this.$store.getters.getDisplayVideoPlayButton
},
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
},
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
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
}
},
mounted: function () {
this.id = this._uid
const volume = sessionStorage.getItem('volume')
if (volume !== null) {
this.volume = volume
}
this.dataSetup.playbackRates = this.playbackRates
2020-12-15 00:25:51 +01:00
this.createFullWindowButton()
this.createLoopButton()
this.createToggleTheatreModeButton()
this.createScreenshotButton()
this.determineFormatType()
this.determineMaxFramerate()
if ('mediaSession' in navigator) {
navigator.mediaSession.setActionHandler('play', () => this.player.play())
navigator.mediaSession.setActionHandler('pause', () => this.player.pause())
}
2020-02-16 19:30:00 +01:00
},
beforeDestroy: function () {
if (this.player !== null) {
this.exitFullWindow()
if (!this.player.isInPictureInPicture()) {
this.player.dispose()
this.player = null
clearTimeout(this.mouseTimeout)
}
}
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'
}
2021-01-13 21:56:25 +01:00
if (this.usingElectron && this.powerSaveBlocker !== null) {
const { ipcRenderer } = require('electron')
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
ipcRenderer.send(IpcChannels.STOP_POWER_SAVE_BLOCKER, this.powerSaveBlocker)
2021-01-13 21:56:25 +01:00
}
},
2020-02-16 19:30:00 +01:00
methods: {
2020-09-25 04:35:13 +02:00
initializePlayer: async function () {
2021-05-15 21:08:41 +02:00
console.log(this.adaptiveFormats)
2020-02-16 19:30:00 +01:00
const videoPlayer = document.getElementById(this.id)
if (videoPlayer !== null) {
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()
}
this.player = videojs(videoPlayer, {
html5: {
2021-03-17 02:28:25 +01:00
preloadTextTracks: false,
vhs: {
limitRenditionByPlayerDimensions: false,
smoothQualityChange: false,
allowSeeksWithinUnsafeLiveWindow: true,
handlePartialData: true
}
}
})
2020-12-15 00:25:51 +01:00
this.player.volume(this.volume)
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
})
}
2020-05-24 23:32:02 +02:00
if (this.useDash) {
// this.dataSetup.plugins.httpSourceSelector = {
// default: 'auto'
// }
// this.player.httpSourceSelector()
this.createDashQualitySelector(this.player.qualityLevels())
}
if (this.autoplayVideos) {
// Calling play() won't happen right away, so a quick timeout will make it function properly.
setTimeout(() => {
this.player.play()
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.useSponsorBlock) {
this.initializeSponsorBlock()
}
2020-12-15 00:25:51 +01:00
$(document).on('keydown', this.keyboardShortcutHandler)
2020-02-21 19:31:32 +01:00
this.player.on('mousemove', this.hideMouseTimeout)
this.player.on('mouseleave', this.removeMouseTimeout)
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)
}
this.player.on('fullscreenchange', this.fullscreenOverlay)
this.player.on('fullscreenchange', this.toggleFullscreenClass)
this.player.on('ready', () => {
this.$emit('ready')
this.checkAspectRatio()
this.createStatsModal()
if (this.captionHybridList.length !== 0) {
this.transformAndInsertCaptions()
2021-03-17 02:28:25 +01:00
}
this.toggleScreenshotButton()
})
this.player.on('ended', () => {
this.$emit('ended')
if ('mediaSession' in navigator) {
navigator.mediaSession.playbackState = 'none'
}
})
this.player.on('error', (error, message) => {
this.$emit('error', error.target.player.error_)
if ('mediaSession' in navigator) {
navigator.mediaSession.playbackState = 'none'
}
2020-02-21 19:31:32 +01:00
})
this.player.on('play', async function () {
if ('mediaSession' in navigator) {
navigator.mediaSession.playbackState = 'playing'
}
if (this.usingElectron) {
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', function () {
if ('mediaSession' in navigator) {
navigator.mediaSession.playbackState = 'paused'
}
if (this.usingElectron && this.powerSaveBlocker !== null) {
const { ipcRenderer } = require('electron')
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
ipcRenderer.send(IpcChannels.STOP_POWER_SAVE_BLOCKER, this.powerSaveBlocker)
this.powerSaveBlocker = null
}
})
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.player.textTrackSettings.on('modalclose', (_) => {
const settings = this.player.textTrackSettings.getValues()
this.updateDefaultCaptionSettings(JSON.stringify(settings))
})
// right click menu
if (this.usingElectron) {
const { ipcRenderer } = require('electron')
ipcRenderer.removeAllListeners('showVideoStatistics')
ipcRenderer.on('showVideoStatistics', (event) => {
this.toggleShowStatsModal()
})
}
2020-02-16 19:30:00 +01:00
}
},
initializeSponsorBlock() {
this.sponsorBlockSkipSegments({
videoId: this.videoId,
categories: this.sponsorSkips.seekBar
}).then((skipSegments) => {
if (skipSegments.length === 0) {
return
}
this.player.ready(() => {
this.player.on('timeupdate', () => {
this.skipSponsorBlocks(skipSegments)
})
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 (newTime !== null && Math.abs(duration - currentTime) > 0.500) {
if (this.sponsorSkips.autoSkip[skippedCategory]) {
if (this.sponsorBlockShowSkippedToast) {
this.showSkippedSponsorSegmentInformation(skippedCategory)
}
this.player.currentTime(newTime)
}
}
},
showSkippedSponsorSegmentInformation(category) {
const translatedCategory = this.sponsorBlockTranslatedCategory(category)
this.showToast({
message: `${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.className = `sponsorBlockMarker main${this.sponsorSkips.categoryData[marker.category].color}`
markerDiv.style.height = '100%'
markerDiv.style.position = 'absolute'
markerDiv.style.opacity = '0.6'
markerDiv.style['background-color'] = marker.color
markerDiv.style.width = (marker.duration / this.player.duration()) * 100 + '%'
markerDiv.style.marginLeft = (marker.time / this.player.duration()) * 100 + '%'
markerDiv.title = this.sponsorBlockTranslatedCategory(marker.category)
this.player.el().querySelector('.vjs-progress-holder').appendChild(markerDiv)
},
checkAspectRatio() {
const videoWidth = this.player.videoWidth()
const videoHeight = this.player.videoHeight()
if (videoWidth === 0 || videoHeight === 0) {
setTimeout(() => {
this.checkAspectRatio()
}, 200)
return
}
if ((videoWidth - videoHeight) <= 240) {
this.player.fluid(false)
this.player.aspectRatio('16:9')
}
},
updateVolume: function (_event) {
// 0 means muted
// https://docs.videojs.com/html5#volume
const volume = this.player.muted() ? 0 : this.player.volume()
sessionStorage.setItem('volume', volume)
},
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)
}
}
}
}
},
mouseScrollPlaybackRate: function (event) {
if (event.target && !event.currentTarget.querySelector('.vjs-menu:hover')) {
event.preventDefault()
if (event.ctrlKey || event.metaKey) {
if (event.wheelDelta > 0) {
this.changePlayBackRate(0.05)
} else if (event.wheelDelta < 0) {
this.changePlayBackRate(-0.05)
}
}
}
},
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.player.play()
} else {
this.player.pause()
}
}
}
},
determineFormatType: function () {
if (this.format === 'dash') {
this.enableDashFormat()
} else {
this.enableLegacyFormat()
}
},
determineMaxFramerate: function() {
if (this.dashSrc.length === 0) {
this.maxFramerate = 60
return
}
fs.readFile(this.dashSrc[0].url, (err, data) => {
if (err) {
this.maxFramerate = 60
return
}
if (data.includes('frameRate="60"')) {
this.maxFramerate = 60
} else {
this.maxFramerate = 30
}
})
},
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 () {
if (this.defaultQuality === 'auto') {
this.setDashQualityLevel('auto')
2020-09-26 23:03:42 +02:00
}
let formatsToTest
2021-05-15 21:08:41 +02:00
if (typeof this.activeAdaptiveFormats !== 'undefined' && this.activeAdaptiveFormats.length > 0) {
2021-05-15 21:08:41 +02:00
formatsToTest = this.activeAdaptiveFormats.filter((format) => {
return format.height === this.defaultQuality
2021-05-15 21:08:41 +02:00
})
if (formatsToTest.length === 0) {
formatsToTest = this.activeAdaptiveFormats.filter((format) => {
return format.height < this.defaultQuality
})
2021-05-15 21:08:41 +02:00
}
formatsToTest = formatsToTest.sort((a, b) => {
if (a.height === b.height) {
return b.bitrate - a.bitrate
} else {
return b.height - a.height
}
})
} else {
formatsToTest = this.player.qualityLevels().levels_.filter((format) => {
return format.height === this.defaultQuality
})
if (formatsToTest.length === 0) {
formatsToTest = this.player.qualityLevels().levels_.filter((format) => {
return format.height < this.defaultQuality
})
}
formatsToTest = formatsToTest.sort((a, b) => {
if (a.height === b.height) {
return b.bitrate - a.bitrate
} else {
return b.height - a.height
}
})
}
2021-05-15 21:08:41 +02:00
2021-05-15 21:13:17 +02:00
// TODO: Test formats to determine if HDR / 60 FPS and skip them based on
// User settings
2021-05-15 21:08:41 +02:00
this.setDashQualityLevel(formatsToTest[0].bitrate)
// Old logic. Revert if needed
/* this.player.qualityLevels().levels_.sort((a, b) => {
if (a.height === b.height) {
return a.bitrate - b.bitrate
} else {
return a.height - b.height
}
2020-09-26 23:03:42 +02:00
}).forEach((ql, index, arr) => {
const height = ql.height
const width = ql.width
const quality = width < height ? width : height
let upperLevel = null
if (index < arr.length - 1) {
upperLevel = arr[index + 1]
}
if (this.defaultQuality === quality && upperLevel === null) {
this.setDashQualityLevel(height, true)
2020-09-26 23:03:42 +02:00
} else if (upperLevel !== null) {
const upperHeight = upperLevel.height
const upperWidth = upperLevel.width
const upperQuality = upperWidth < upperHeight ? upperWidth : upperHeight
if (this.defaultQuality >= quality && this.defaultQuality === upperQuality) {
this.setDashQualityLevel(height, true)
} else if (this.defaultQuality >= quality && this.defaultQuality < upperQuality) {
this.setDashQualityLevel(height)
2020-09-26 23:03:42 +02:00
}
} else if (index === 0 && quality > this.defaultQuality) {
this.setDashQualityLevel(height)
2020-09-26 23:03:42 +02:00
} else if (index === (arr.length - 1) && quality < this.defaultQuality) {
this.setDashQualityLevel(height)
}
2021-05-15 21:08:41 +02:00
}) */
},
2021-05-15 21:08:41 +02:00
setDashQualityLevel: function (bitrate) {
let adaptiveFormat = null
if (bitrate !== 'auto') {
adaptiveFormat = this.activeAdaptiveFormats.find((format) => {
return format.bitrate === bitrate
})
}
let qualityLabel = adaptiveFormat ? adaptiveFormat.qualityLabel : ''
2021-05-15 21:08:41 +02:00
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 (bitrate === 'auto' || bitrate === ql.bitrate) {
ql.enabled = true
ql.enabled_(true)
if (bitrate !== 'auto' && qualityLabel === '') {
qualityLabel = ql.height + 'p'
}
2021-05-15 21:08:41 +02:00
} else {
ql.enabled = false
ql.enabled_(false)
}
})
const selectedQuality = bitrate === 'auto' ? 'auto' : qualityLabel
2021-05-15 21:08:41 +02:00
const qualityElement = document.getElementById('vjs-current-quality')
qualityElement.innerText = selectedQuality
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
} else {
this.selectedResolution = 'auto'
this.selectedFPS = 'auto'
this.selectedBitrate = 'auto'
this.selectedMimeType = 'auto'
}
2021-05-15 21:08:41 +02:00
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.toLowerCase()) {
$(item).addClass('quality-selected')
}
})
/* 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.log('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.log('No sources available')
return
}
this.useDash = false
this.useHls = false
this.activeSourceList = this.sourceList
setTimeout(this.initializePlayer, 100)
},
togglePlayPause: function () {
if (this.player.paused()) {
this.player.play()
} 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)
}
},
changeDurationByPercentage: function (percentage) {
const duration = this.player.duration()
const newTime = duration * percentage
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) {
this.player.pause()
const quality = this.useDash ? this.player.qualityLevels()[this.player.qualityLevels().selectedIndex] : {}
let fps = 30
// Non-Dash formats are 30fps only
if (this.maxFramerate === 60 && quality.height >= 480) {
for (let i = 0; i < this.adaptiveFormats.length; i++) {
if (this.adaptiveFormats[i].bitrate === quality.bitrate) {
fps = this.adaptiveFormats[i].fps ? this.adaptiveFormats[i].fps : 30
break
}
}
}
// 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)
console.log(fps)
},
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 () {
const tracks = this.player.textTracks().tracks_
if (tracks.length > 1) {
if (tracks[1].mode === 'showing') {
tracks[1].mode = 'disabled'
} else {
tracks[1].mode = 'showing'
}
}
},
createLoopButton: function () {
const VjsButton = videojs.getComponent('Button')
const loopButton = videojs.extend(VjsButton, {
constructor: function(player, options) {
VjsButton.call(this, player, options)
},
handleClick: () => {
this.toggleVideoLoop()
},
createControlTextEl: function (button) {
return $(button).html($('<div id="loopButton" class="vjs-icon-loop loop-white vjs-button loopWhite"></div>')
.attr('title', 'Toggle Loop'))
}
})
videojs.registerComponent('loopButton', loopButton)
},
toggleVideoLoop: async function () {
if (!this.player.loop()) {
2022-06-19 20:39:42 +02:00
const currentTheme = this.$store.state.settings.mainColor
const colorNames = this.$store.state.utils.colorNames
const colorValues = this.$store.state.utils.colorValues
const nameIndex = colorNames.findIndex((color) => {
return color === currentTheme
})
const themeTextColor = await this.calculateColorLuminance(colorValues[nameIndex])
$('#loopButton').addClass('vjs-icon-loop-active')
if (themeTextColor === '#000000') {
$('#loopButton').addClass('loop-black')
$('#loopButton').removeClass('loop-white')
}
this.player.loop(true)
} else {
$('#loopButton').removeClass('vjs-icon-loop-active')
$('#loopButton').removeClass('loop-black')
$('#loopButton').addClass('loop-white')
this.player.loop(false)
}
},
createFullWindowButton: function () {
const VjsButton = videojs.getComponent('Button')
const fullWindowButton = videojs.extend(VjsButton, {
constructor: function(player, options) {
VjsButton.call(this, player, options)
},
handleClick: () => {
this.toggleFullWindow()
},
createControlTextEl: function (button) {
// Add class name to button to be able to target it with CSS selector
return $(button)
.addClass('vjs-button-fullwindow')
.html($('<div id="fullwindow" class="vjs-icon-fullwindow-enter vjs-button"></div>')
.attr('title', 'Full Window'))
}
})
videojs.registerComponent('fullWindowButton', fullWindowButton)
},
createToggleTheatreModeButton: function() {
if (!this.$parent.theatrePossible) {
return
}
const theatreModeActive = this.$parent.useTheatreMode ? ' vjs-icon-theatre-active' : ''
const VjsButton = videojs.getComponent('Button')
const toggleTheatreModeButton = videojs.extend(VjsButton, {
constructor: function(player, options) {
VjsButton.call(this, player, options)
},
handleClick: () => {
this.toggleTheatreMode()
},
createControlTextEl: function (button) {
return $(button)
.addClass('vjs-button-theatre')
.html($(`<div id="toggleTheatreModeButton" class="vjs-icon-theatre-inactive${theatreModeActive} vjs-button"></div>`))
.attr('title', 'Toggle Theatre Mode')
}
})
videojs.registerComponent('toggleTheatreModeButton', toggleTheatreModeButton)
},
toggleTheatreMode: function() {
if (!this.player.isFullscreen_) {
const toggleTheatreModeButton = $('#toggleTheatreModeButton')
if (!this.$parent.useTheatreMode) {
toggleTheatreModeButton.addClass('vjs-icon-theatre-active')
} else {
toggleTheatreModeButton.removeClass('vjs-icon-theatre-active')
}
}
this.$parent.toggleTheatreMode()
},
createScreenshotButton: function() {
const VjsButton = videojs.getComponent('Button')
const screenshotButton = videojs.extend(VjsButton, {
constructor: function(player, options) {
VjsButton.call(this, player, options)
},
handleClick: () => {
this.takeScreenshot()
const video = document.getElementsByTagName('video')[0]
video.focus()
video.blur()
},
createControlTextEl: function (button) {
return $(button)
.html('<div id="screenshotButton" class="vjs-icon-screenshot vjs-button vjs-hidden"></div>')
.attr('title', 'Screenshot')
}
})
videojs.registerComponent('screenshotButton', screenshotButton)
},
toggleScreenshotButton: function() {
const button = document.getElementById('screenshotButton')
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}`)
this.showToast({
message: this.$t('Screenshot Error').replace('$', err.message)
})
canvas.remove()
return
}
const dirChar = process.platform === 'win32' ? '\\' : '/'
let subDir = ''
if (filename.indexOf(dirChar) !== -1) {
const lastIndex = filename.lastIndexOf(dirChar)
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 === '' || !fs.existsSync(this.screenshotFolder)) {
dirPath = await this.getPicturesPath()
} else {
dirPath = this.screenshotFolder
}
const options = {
defaultPath: path.join(dirPath, filenameWithExtension),
filters: [
{
name: format.toUpperCase(),
extensions: [format]
}
]
}
const response = await this.showSaveDialog({ options, useModal: true })
if (wasPlaying) {
this.player.play()
}
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 this.getPicturesPath(), 'Freetube', subDir)
} else {
dirPath = path.join(this.screenshotFolder, subDir)
}
if (!fs.existsSync(dirPath)) {
try {
fs.mkdirSync(dirPath, { recursive: true })
} catch (err) {
console.error(err)
this.showToast({
message: this.$t('Screenshot Error').replace('$', 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, (err) => {
if (err) {
console.error(err)
this.showToast({
message: this.$t('Screenshot Error').replace('$', err)
})
} else {
this.showToast({
message: this.$t('Screenshot Success').replace('$', filePath)
})
}
})
})
}, mimeType, imageQuality)
canvas.remove()
},
createDashQualitySelector: function (levels) {
if (levels.levels_.length === 0) {
setTimeout(() => {
this.createDashQualitySelector(this.player.qualityLevels())
}, 200)
return
}
const VjsButton = videojs.getComponent('Button')
const dashQualitySelector = videojs.extend(VjsButton, {
constructor: function(player, options) {
VjsButton.call(this, player, options)
},
handleClick: (event) => {
2021-05-15 21:08:41 +02:00
console.log(event)
const selectedQuality = event.target.innerText
2021-05-15 21:08:41 +02:00
const bitrate = selectedQuality === 'auto' ? 'auto' : parseInt(event.target.attributes.bitrate.value)
this.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>'
2021-05-15 21:08:41 +02:00
let qualityHtml = `<li class="vjs-menu-item quality-item" 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>`
levels.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 this.adaptiveFormats !== 'undefined' && this.adaptiveFormats.length > 0) {
const adaptiveFormat = this.adaptiveFormats.find((format) => {
return format.bitrate === quality.bitrate
})
2021-05-15 21:08:41 +02:00
if (typeof adaptiveFormat === 'undefined') {
return
}
this.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
qualityHtml = qualityHtml + `<li class="vjs-menu-item quality-item" role="menuitemradio" tabindex="-1" aria-checked="false" aria-disabled="false" fps="${fps}" bitrate="${bitrate}">
<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>` */
})
return $(button).html(
$(beginningHtml + qualityHtml + endingHtml).attr(
'title',
'Select Quality'
))
}
})
videojs.registerComponent('dashQualitySelector', dashQualitySelector)
this.player.controlBar.addChild('dashQualitySelector', {}, this.player.controlBar.children_.length - 1)
this.determineDefaultQualityDash()
},
sortCaptions: function (captionList) {
return captionList.sort((captionA, captionB) => {
const aCode = captionA.languageCode.split('-') // ex. [en,US]
const bCode = captionB.languageCode.split('-')
const aName = (captionA.label || captionA.name.simpleText) // ex: english (auto-generated)
const bName = (captionB.label || captionB.name.simpleText)
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 (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)
})
},
2021-03-17 02:28:25 +01:00
transformAndInsertCaptions: async function() {
let captionList
if (this.captionHybridList[0] instanceof Promise) {
captionList = await Promise.all(this.captionHybridList)
this.$emit('store-caption-list', captionList)
} else {
captionList = this.captionHybridList
}
for (const caption of this.sortCaptions(captionList)) {
2021-03-17 02:28:25 +01:00
this.player.addRemoteTextTrack({
kind: 'subtitles',
src: caption.baseUrl || caption.url,
srclang: caption.languageCode,
label: caption.label || caption.name.simpleText,
type: caption.type
}, true)
}
},
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
$('body').removeClass('vjs-full-window')
$('#fullwindow').removeClass('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'
$('body').addClass('vjs-full-window')
$('#fullwindow').addClass('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')
$('body').removeClass('vjs-full-window')
$('#fullwindow').removeClass('vjs-icon-fullwindow-exit')
this.player.trigger('exitFullWindow')
}
},
toggleFullscreen: function () {
if (this.player.isFullscreen()) {
this.player.exitFullscreen()
} else {
this.player.requestFullscreen()
}
},
2020-02-21 19:31:32 +01:00
hideMouseTimeout: function () {
if (this.id === '') {
return
}
const videoPlayer = $(`#${this.id} video`).get(0)
if (typeof (videoPlayer) !== 'undefined') {
videoPlayer.style.cursor = 'default'
clearTimeout(this.mouseTimeout)
this.mouseTimeout = window.setTimeout(function () {
videoPlayer.style.cursor = 'none'
}, 2650)
}
},
removeMouseTimeout: function () {
if (this.mouseTimeout !== null) {
clearTimeout(this.mouseTimeout)
}
},
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()) {
$('body').addClass('vjs--full-screen-enabled')
} else {
$('body').removeClass('vjs--full-screen-enabled')
}
},
handleTouchStart: function (event) {
this.touchPauseTimeout = setTimeout(() => {
this.togglePlayPause()
}, 1000)
const touchTime = new Date()
if (this.lastTouchTime !== null && (touchTime.getTime() - this.lastTouchTime.getTime()) < 250) {
this.toggleFullscreen()
}
this.lastTouchTime = touchTime
},
handleTouchEnd: function (event) {
clearTimeout(this.touchPauseTimeout)
},
toggleShowStatsModal: function() {
console.log(this.format)
if (this.format !== 'dash') {
this.showToast({
message: 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' ? 'auto' : `${this.selectedResolution}@${this.selectedFPS}fps`
const playerDimensions = `${this.playerStats.playerDimensions.width}x${this.playerStats.playerDimensions.height}`
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'), this.selectedBitrate],
[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'), this.selectedMimeType]
]
let listContentHTML = ''
statsArray.forEach((stat) => {
const content = `<p>${stat[0]}: ${stat[1]}</p>`
listContentHTML += content
})
return listContentHTML
},
// This function should always be at the bottom of this file
keyboardShortcutHandler: function (event) {
const activeInputs = $('.ft-input')
for (let i = 0; i < activeInputs.length; i++) {
if (activeInputs[i] === document.activeElement) {
return
}
}
if (event.ctrlKey) {
return
}
if (this.player !== null) {
switch (event.which) {
case 32:
// Space Bar
// Toggle Play/Pause
event.preventDefault()
this.togglePlayPause()
break
case 74:
// J Key
// Rewind by 2x the time-skip interval (in seconds)
event.preventDefault()
this.changeDurationBySeconds(-this.defaultSkipInterval * this.player.playbackRate() * 2)
break
case 75:
// K Key
// Toggle Play/Pause
event.preventDefault()
this.togglePlayPause()
break
case 76:
// L Key
// Fast-Forward by 2x the time-skip interval (in seconds)
event.preventDefault()
this.changeDurationBySeconds(this.defaultSkipInterval * this.player.playbackRate() * 2)
break
case 79:
// O Key
// Decrease playback rate by 0.25x
event.preventDefault()
this.changePlayBackRate(-this.videoPlaybackRateInterval)
break
case 80:
// P Key
// Increase playback rate by 0.25x
event.preventDefault()
this.changePlayBackRate(this.videoPlaybackRateInterval)
break
case 70:
// F Key
// Toggle Fullscreen Playback
event.preventDefault()
this.toggleFullscreen()
break
case 77:
// M Key
// Toggle Mute
event.preventDefault()
this.toggleMute()
break
case 67:
// C Key
// Toggle Captions
event.preventDefault()
this.toggleCaptions()
break
case 38:
// Up Arrow Key
// Increase volume
event.preventDefault()
this.changeVolume(0.05)
break
case 40:
// Down Arrow Key
// Decrease Volume
event.preventDefault()
this.changeVolume(-0.05)
break
case 37:
// Left Arrow Key
// Rewind by the time-skip interval (in seconds)
event.preventDefault()
this.changeDurationBySeconds(-this.defaultSkipInterval * this.player.playbackRate())
break
case 39:
// Right Arrow Key
// Fast-Forward by the time-skip interval (in seconds)
event.preventDefault()
this.changeDurationBySeconds(this.defaultSkipInterval * this.player.playbackRate())
break
case 73:
// I Key
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 49:
// 1 Key
// Jump to 10% in the video
event.preventDefault()
this.changeDurationByPercentage(0.1)
break
case 50:
// 2 Key
// Jump to 20% in the video
event.preventDefault()
this.changeDurationByPercentage(0.2)
break
case 51:
// 3 Key
// Jump to 30% in the video
event.preventDefault()
this.changeDurationByPercentage(0.3)
break
case 52:
// 4 Key
// Jump to 40% in the video
event.preventDefault()
this.changeDurationByPercentage(0.4)
break
case 53:
// 5 Key
// Jump to 50% in the video
event.preventDefault()
this.changeDurationByPercentage(0.5)
break
case 54:
// 6 Key
// Jump to 60% in the video
event.preventDefault()
this.changeDurationByPercentage(0.6)
break
case 55:
// 7 Key
// Jump to 70% in the video
event.preventDefault()
this.changeDurationByPercentage(0.7)
break
case 56:
// 8 Key
// Jump to 80% in the video
event.preventDefault()
this.changeDurationByPercentage(0.8)
break
case 57:
// 9 Key
// Jump to 90% in the video
event.preventDefault()
this.changeDurationByPercentage(0.9)
break
case 48:
// 0 Key
// Jump to 0% in the video (The beginning)
event.preventDefault()
this.changeDurationByPercentage(0)
break
case 188:
// , Key
// Return to previous frame
this.framebyframe(-1)
break
case 190:
// . Key
// Advance to next frame
this.framebyframe(1)
break
case 68:
// D Key
event.preventDefault()
this.toggleShowStatsModal()
break
case 27:
// esc Key
// Exit full window
event.preventDefault()
this.exitFullWindow()
break
case 83:
// S Key
// Toggle Full Window Mode
this.toggleFullWindow()
break
2021-09-15 04:00:34 +02:00
case 84:
// T Key
// Toggle Theatre Mode
this.toggleTheatreMode()
break
case 85:
// U Key
// Take screenshot
this.takeScreenshot()
break
}
}
},
...mapActions([
'calculateColorLuminance',
'updateDefaultCaptionSettings',
'showToast',
'sponsorBlockSkipSegments',
'parseScreenshotCustomFileName',
'updateScreenshotFolderPath',
'getPicturesPath',
'showSaveDialog'
])
2020-02-16 19:30:00 +01:00
}
})