diff --git a/src/renderer/components/ft-video-player/ft-video-player.js b/src/renderer/components/ft-video-player/ft-video-player.js index 3bc85f0762d0..7d1b9122dddf 100644 --- a/src/renderer/components/ft-video-player/ft-video-player.js +++ b/src/renderer/components/ft-video-player/ft-video-player.js @@ -28,6 +28,11 @@ videojs.Vhs.xhr.beforeRequest = (options) => { } } +// 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({ name: 'FtVideoPlayer', props: { @@ -150,7 +155,10 @@ export default defineComponent({ }, defaultQuality: function () { - return parseInt(this.$store.getters.getDefaultQuality) + const valueFromStore = this.$store.getters.getDefaultQuality + if (valueFromStore === 'auto') { return valueFromStore } + + return parseInt(valueFromStore) }, defaultCaptionSettings: function () { @@ -320,6 +328,10 @@ export default defineComponent({ this.dataSetup.playbackRates = this.playbackRates + if (this.format === 'dash') { + this.determineDefaultQualityDash() + } + this.createFullWindowButton() this.createLoopButton() this.createToggleTheatreModeButton() @@ -369,6 +381,18 @@ export default defineComponent({ controlBarItems.splice(index, 1) } + // 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: { preloadTextTracks: false, @@ -376,7 +400,8 @@ export default defineComponent({ limitRenditionByPlayerDimensions: false, smoothQualityChange: false, allowSeeksWithinUnsafeLiveWindow: true, - handlePartialData: true + handlePartialData: true, + ...playerBandwidthOption } } }) @@ -397,6 +422,15 @@ export default defineComponent({ } }) + // 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') { + const qualityLevels = this.player.qualityLevels() + qualityLevels.on('addqualitylevel', ({ qualityLevel }) => { + qualityLevel.enabled = qualityLevel.bitrate === this.selectedBitrate + }) + } + this.player.volume(this.volume) this.player.muted(this.muted) this.player.playbackRate(this.defaultPlayback) @@ -895,92 +929,67 @@ export default defineComponent({ }, determineDefaultQualityDash: function () { - if (this.defaultQuality === 'auto') { - this.setDashQualityLevel('auto') - } + // TODO add settings and filtering for 60fps and HDR - let formatsToTest - - if (typeof this.activeAdaptiveFormats !== 'undefined' && this.activeAdaptiveFormats.length > 0) { - formatsToTest = this.activeAdaptiveFormats.filter((format) => { - return format.height === this.defaultQuality + 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' }) - if (formatsToTest.length === 0) { - formatsToTest = this.activeAdaptiveFormats.filter((format) => { - return format.height < this.defaultQuality - }) - } + // Select the quality that is identical to the users chosen default quality if it's available + // otherwise select the next lowest quality - formatsToTest = formatsToTest.sort((a, b) => { - if (a.height === b.height) { - return b.bitrate - a.bitrate - } else { - return b.height - a.height + 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 } - }) - } else { - formatsToTest = this.player.qualityLevels().levels_.filter((format) => { + return format.height === this.defaultQuality }) if (formatsToTest.length === 0) { - formatsToTest = this.player.qualityLevels().levels_.filter((format) => { + 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 = formatsToTest.sort((a, b) => { + 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 } }) - } - - // TODO: Test formats to determine if HDR / 60 FPS and skip them based on - // User settings - 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 - } - }).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) - } 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) - } - } else if (index === 0 && quality > this.defaultQuality) { - this.setDashQualityLevel(height) - } else if (index === (arr.length - 1) && quality < this.defaultQuality) { - this.setDashQualityLevel(height) - } - }) */ + const selectedFormat = formatsToTest[0] + this.selectedBitrate = selectedFormat.bitrate + this.selectedResolution = `${selectedFormat.width}x${selectedFormat.height}` + this.selectedFPS = selectedFormat.fps + this.selectedMimeType = selectedFormat.mimeType || selectedFormat.type + } }, setDashQualityLevel: function (bitrate) { + if (bitrate === this.selectedBitrate) { + return + } + let adaptiveFormat = null if (bitrate !== 'auto') { @@ -991,24 +1000,39 @@ export default defineComponent({ let qualityLabel = adaptiveFormat ? adaptiveFormat.qualityLabel : '' - 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) { + const qualityLevels = Array.from(this.player.qualityLevels()) + if (bitrate === 'auto') { + qualityLevels.forEach(ql => { ql.enabled = true - ql.enabled_(true) - if (bitrate !== 'auto' && qualityLabel === '') { - qualityLabel = ql.height + 'p' + }) + } 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 } else { - ql.enabled = false - ql.enabled_(false) + qualityLevels.forEach(ql => { + if (bitrate === ql.bitrate) { + ql.enabled = true + if (qualityLabel === '') { + qualityLabel = ql.height + 'p' + } + } else { + ql.enabled = false + } + }) } - }) + } const selectedQuality = bitrate === 'auto' ? 'auto' : qualityLabel @@ -1514,6 +1538,8 @@ export default defineComponent({ const adaptiveFormats = this.adaptiveFormats const activeAdaptiveFormats = this.activeAdaptiveFormats const setDashQualityLevel = this.setDashQualityLevel + const defaultQuality = this.defaultQuality + const defaultBitrate = this.selectedBitrate const VjsButton = videojs.getComponent('Button') class dashQualitySelector extends VjsButton { @@ -1531,12 +1557,16 @@ export default defineComponent({ ' - let qualityHtml = `
  • + const defaultIsAuto = defaultQuality === 'auto' + + let qualityHtml = `
  • Auto
  • ` - levels.levels_.sort((a, b) => { + let currentQualityLabel + + Array.from(levels).sort((a, b) => { if (b.height === a.height) { return b.bitrate - a.bitrate } else { @@ -1567,7 +1597,13 @@ export default defineComponent({ bitrate = quality.bitrate } - qualityHtml += `
  • + const isSelected = !defaultIsAuto && bitrate === defaultBitrate + + if (isSelected) { + currentQualityLabel = qualityLabel + } + + qualityHtml += `
  • ${qualityLabel}
  • ` @@ -1590,13 +1626,14 @@ export default defineComponent({ button.title = 'Select Quality' button.innerHTML = beginningHtml + qualityHtml + endingHtml + button.querySelector('#vjs-current-quality').innerText = defaultIsAuto ? 'auto' : currentQualityLabel + return button.children[0] } } videojs.registerComponent('dashQualitySelector', dashQualitySelector) this.player.controlBar.addChild('dashQualitySelector', {}, this.player.controlBar.children_.length - 1) - this.determineDefaultQualityDash() }, sortCaptions: function (captionList) { diff --git a/src/renderer/helpers/api/invidious.js b/src/renderer/helpers/api/invidious.js index 59c7d0c6d6b1..6f25000568f6 100644 --- a/src/renderer/helpers/api/invidious.js +++ b/src/renderer/helpers/api/invidious.js @@ -226,3 +226,33 @@ function parseInvidiousCommunityAttachments(data) { console.error('New Invidious Community Post Type: ' + data.type) } + +/** + * video.js only supports MP4 DASH not WebM DASH + * so we filter out the WebM DASH formats + * @param {any[]} formats + * @param {boolean} allowAv1 Use the AV1 formats if they are available + */ +export function filterInvidiousFormats(formats, allowAv1 = false) { + const audioFormats = [] + const h264Formats = [] + const av1Formats = [] + + formats.forEach(format => { + const mimeType = format.type + + if (mimeType.startsWith('audio/mp4')) { + audioFormats.push(format) + } else if (allowAv1 && mimeType.startsWith('video/mp4; codecs="av01')) { + av1Formats.push(format) + } else if (mimeType.startsWith('video/mp4; codecs="avc')) { + h264Formats.push(format) + } + }) + + if (allowAv1 && av1Formats.length > 0) { + return [...audioFormats, ...av1Formats] + } else { + return [...audioFormats, ...h264Formats] + } +} diff --git a/src/renderer/helpers/api/local.js b/src/renderer/helpers/api/local.js index 26d7d787427d..0929d8e63fa2 100644 --- a/src/renderer/helpers/api/local.js +++ b/src/renderer/helpers/api/local.js @@ -562,6 +562,7 @@ export function mapLocalFormat(format) { bitrate: format.bitrate, mimeType: format.mime_type, height: format.height, + width: format.width, url: format.url } } @@ -606,7 +607,7 @@ export function parseLocalComment(comment, commentThread = undefined) { * @param {Format[]} formats * @param {boolean} allowAv1 Use the AV1 formats if they are available */ -export function filterFormats(formats, allowAv1 = false) { +export function filterLocalFormats(formats, allowAv1 = false) { const audioFormats = [] const h264Formats = [] const av1Formats = [] diff --git a/src/renderer/views/Watch/Watch.js b/src/renderer/views/Watch/Watch.js index 3c242d5e7302..8e9168769a87 100644 --- a/src/renderer/views/Watch/Watch.js +++ b/src/renderer/views/Watch/Watch.js @@ -22,14 +22,14 @@ import { showToast } from '../../helpers/utils' import { - filterFormats, + filterLocalFormats, getLocalVideoInfo, mapLocalFormat, parseLocalSubscriberCount, parseLocalTextRuns, parseLocalWatchNextVideo } from '../../helpers/api/local' -import { invidiousGetVideoInformation, youtubeImageUrlToInvidious } from '../../helpers/api/invidious' +import { filterInvidiousFormats, invidiousGetVideoInformation, youtubeImageUrlToInvidious } from '../../helpers/api/invidious' export default defineComponent({ name: 'Watch', @@ -486,7 +486,7 @@ export default defineComponent({ if (result.streaming_data.formats.length > 0) { this.videoSourceList = result.streaming_data.formats.map(mapLocalFormat).reverse() } else { - this.videoSourceList = filterFormats(result.streaming_data.adaptive_formats, this.allowDashAv1Formats).map(mapLocalFormat).reverse() + this.videoSourceList = filterLocalFormats(result.streaming_data.adaptive_formats, this.allowDashAv1Formats).map(mapLocalFormat).reverse() } this.adaptiveFormats = this.videoSourceList @@ -584,7 +584,7 @@ export default defineComponent({ }).reverse() // we need to alter the result object so the toDash function uses the filtered formats too - result.streaming_data.adaptive_formats = filterFormats(result.streaming_data.adaptive_formats, this.allowDashAv1Formats) + result.streaming_data.adaptive_formats = filterLocalFormats(result.streaming_data.adaptive_formats, this.allowDashAv1Formats) this.adaptiveFormats = result.streaming_data.adaptive_formats.map(mapLocalFormat) if (this.proxyVideos) { @@ -668,13 +668,14 @@ export default defineComponent({ this.videoPublished = result.published * 1000 this.videoDescriptionHtml = result.descriptionHtml this.recommendedVideos = result.recommendedVideos - this.adaptiveFormats = result.adaptiveFormats.map((format) => { - format.bitrate = parseInt(format.bitrate) - if (typeof format.resolution !== 'undefined') { - format.height = parseInt(format.resolution.replace('p', '')) - } - return format - }) + this.adaptiveFormats = filterInvidiousFormats(result.adaptiveFormats, this.allowDashAv1Formats) + .map((format) => { + format.bitrate = parseInt(format.bitrate) + if (typeof format.resolution !== 'undefined') { + format.height = parseInt(format.resolution.replace('p', '')) + } + return format + }) this.isLive = result.liveNow this.isFamilyFriendly = result.isFamilyFriendly this.captionHybridList = result.captions.map(caption => {