FreeTube/src/renderer/views/Watch/Watch.js

1761 lines
61 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { defineComponent } from 'vue'
import { mapActions } from 'vuex'
import FtLoader from '../../components/ft-loader/ft-loader.vue'
import FtVideoPlayer from '../../components/ft-video-player/ft-video-player.vue'
import WatchVideoInfo from '../../components/watch-video-info/watch-video-info.vue'
import WatchVideoChapters from '../../components/watch-video-chapters/watch-video-chapters.vue'
import WatchVideoDescription from '../../components/watch-video-description/watch-video-description.vue'
import WatchVideoComments from '../../components/watch-video-comments/watch-video-comments.vue'
import WatchVideoLiveChat from '../../components/watch-video-live-chat/watch-video-live-chat.vue'
import WatchVideoPlaylist from '../../components/watch-video-playlist/watch-video-playlist.vue'
import WatchVideoRecommendations from '../../components/watch-video-recommendations/watch-video-recommendations.vue'
import FtAgeRestricted from '../../components/ft-age-restricted/ft-age-restricted.vue'
import packageDetails from '../../../../package.json'
import {
buildVTTFileLocally,
copyToClipboard,
formatDurationAsTimestamp,
formatNumber,
getFormatsFromHLSManifest,
showToast
} from '../../helpers/utils'
import {
filterLocalFormats,
getLocalVideoInfo,
mapLocalFormat,
parseLocalSubscriberCount,
parseLocalTextRuns,
parseLocalWatchNextVideo
} from '../../helpers/api/local'
import {
convertInvidiousToLocalFormat,
filterInvidiousFormats,
generateInvidiousDashManifestLocally,
invidiousGetVideoInformation,
youtubeImageUrlToInvidious
} from '../../helpers/api/invidious'
/**
* @typedef {object} AudioSource
* @property {string} url
* @property {string} type
* @property {string} label
* @property {string} qualityLabel
*
* @typedef {object} AudioTrack
* @property {string} id
* @property {('main'|'translation'|'descriptions'|'alternative')} kind - https://videojs.com/guides/audio-tracks/#kind
* @property {string} label
* @property {string} language
* @property {boolean} isDefault
* @property {AudioSource[]} sourceList
*/
export default defineComponent({
name: 'Watch',
components: {
'ft-loader': FtLoader,
'ft-video-player': FtVideoPlayer,
'watch-video-info': WatchVideoInfo,
'watch-video-chapters': WatchVideoChapters,
'watch-video-description': WatchVideoDescription,
'watch-video-comments': WatchVideoComments,
'watch-video-live-chat': WatchVideoLiveChat,
'watch-video-playlist': WatchVideoPlaylist,
'watch-video-recommendations': WatchVideoRecommendations,
'ft-age-restricted': FtAgeRestricted
},
beforeRouteLeave: function (to, from, next) {
this.handleRouteChange(this.videoId)
window.removeEventListener('beforeunload', this.handleWatchProgress)
next()
},
data: function () {
return {
isLoading: true,
firstLoad: true,
useTheatreMode: false,
videoPlayerReady: false,
hidePlayer: false,
isFamilyFriendly: false,
isLive: false,
liveChat: null,
isLiveContent: false,
isUpcoming: false,
isPostLiveDvr: false,
upcomingTimestamp: null,
upcomingTimeLeft: null,
activeFormat: 'legacy',
thumbnail: '',
videoId: '',
videoTitle: '',
videoDescription: '',
videoDescriptionHtml: '',
videoViewCount: 0,
videoLikeCount: 0,
videoDislikeCount: 0,
videoLengthSeconds: 0,
videoChapters: [],
videoCurrentChapterIndex: 0,
channelName: '',
channelThumbnail: '',
channelId: '',
channelSubscriptionCountText: '',
videoPublished: 0,
videoStoryboardSrc: '',
dashSrc: [],
activeSourceList: [],
videoSourceList: [],
audioSourceList: [],
/**
* @type {AudioTrack[]}
*/
audioTracks: [],
adaptiveFormats: [],
captionHybridList: [], // [] -> Promise[] -> string[] (URIs)
recommendedVideos: [],
downloadLinks: [],
watchingPlaylist: false,
playlistId: '',
playlistType: '',
playlistItemId: null,
timestamp: null,
playNextTimeout: null,
playNextCountDownIntervalId: null,
infoAreaSticky: true,
commentsEnabled: true,
onMountedRun: false,
}
},
computed: {
historyEntry: function () {
return this.$store.getters.getHistoryCacheById[this.videoId]
},
historyEntryExists: function () {
return typeof this.historyEntry !== 'undefined'
},
rememberHistory: function () {
return this.$store.getters.getRememberHistory
},
saveWatchedProgress: function () {
return this.$store.getters.getSaveWatchedProgress
},
saveVideoHistoryWithLastViewedPlaylist: function () {
return this.$store.getters.getSaveVideoHistoryWithLastViewedPlaylist
},
backendPreference: function () {
return this.$store.getters.getBackendPreference
},
backendFallback: function () {
return this.$store.getters.getBackendFallback
},
currentInvidiousInstance: function () {
return this.$store.getters.getCurrentInvidiousInstance
},
proxyVideos: function () {
return this.$store.getters.getProxyVideos
},
defaultInterval: function () {
return this.$store.getters.getDefaultInterval
},
defaultTheatreMode: function () {
return this.$store.getters.getDefaultTheatreMode
},
defaultVideoFormat: function () {
return this.$store.getters.getDefaultVideoFormat
},
forceLocalBackendForLegacy: function () {
return this.$store.getters.getForceLocalBackendForLegacy
},
thumbnailPreference: function () {
return this.$store.getters.getThumbnailPreference
},
playNextVideo: function () {
return this.$store.getters.getPlayNextVideo
},
autoplayPlaylists: function () {
return this.$store.getters.getAutoplayPlaylists
},
hideRecommendedVideos: function () {
return this.$store.getters.getHideRecommendedVideos
},
hideLiveChat: function () {
return this.$store.getters.getHideLiveChat
},
hideComments: function () {
return this.$store.getters.getHideComments
},
hideVideoDescription: function () {
return this.$store.getters.getHideVideoDescription
},
showFamilyFriendlyOnly: function () {
return this.$store.getters.getShowFamilyFriendlyOnly
},
hideChannelSubscriptions: function () {
return this.$store.getters.getHideChannelSubscriptions
},
hideVideoLikesAndDislikes: function () {
return this.$store.getters.getHideVideoLikesAndDislikes
},
theatrePossible: function () {
return !this.hideRecommendedVideos || (!this.hideLiveChat && this.isLive) || this.watchingPlaylist
},
currentLocale: function () {
return this.$i18n.locale.replace('_', '-')
},
hideChapters: function () {
return this.$store.getters.getHideChapters
},
allowDashAv1Formats: function () {
return this.$store.getters.getAllowDashAv1Formats
},
channelsHidden() {
return JSON.parse(this.$store.getters.getChannelsHidden).map((ch) => {
// Legacy support
if (typeof ch === 'string') {
return { name: ch, preferredName: '', icon: '' }
}
return ch
})
},
forbiddenTitles() {
return JSON.parse(this.$store.getters.getForbiddenTitles)
},
isUserPlaylistRequested: function () {
return this.$route.query.playlistType === 'user'
},
userPlaylistsReady: function () {
return this.$store.getters.getPlaylistsReady
},
selectedUserPlaylist: function () {
if (this.playlistId == null || this.playlistId === '') { return null }
if (!this.isUserPlaylistRequested) { return null }
return this.$store.getters.getPlaylist(this.playlistId)
},
},
watch: {
$route() {
this.handleRouteChange(this.videoId)
// react to route changes...
this.videoId = this.$route.params.id
this.firstLoad = true
this.videoPlayerReady = false
this.activeFormat = this.defaultVideoFormat
this.videoStoryboardSrc = ''
this.captionHybridList = []
this.downloadLinks = []
this.videoCurrentChapterIndex = 0
this.audioTracks = []
this.checkIfPlaylist()
this.checkIfTimestamp()
switch (this.backendPreference) {
case 'local':
this.getVideoInformationLocal(this.videoId)
break
case 'invidious':
this.getVideoInformationInvidious(this.videoId)
if (this.forceLocalBackendForLegacy) {
this.getVideoInformationLocal(this.videoId)
}
break
}
},
userPlaylistsReady() {
this.onMountedDependOnLocalStateLoading()
},
},
mounted: function () {
this.videoId = this.$route.params.id
this.activeFormat = this.defaultVideoFormat
this.useTheatreMode = this.defaultTheatreMode && this.theatrePossible
this.onMountedDependOnLocalStateLoading()
},
methods: {
onMountedDependOnLocalStateLoading() {
// Prevent running twice
if (this.onMountedRun) { return }
// Stuff that require user playlists to be ready
if (this.isUserPlaylistRequested && !this.userPlaylistsReady) { return }
this.onMountedRun = true
this.checkIfPlaylist()
this.checkIfTimestamp()
if (!process.env.IS_ELECTRON || this.backendPreference === 'invidious') {
this.getVideoInformationInvidious()
} else {
this.getVideoInformationLocal()
}
window.addEventListener('beforeunload', this.handleWatchProgress)
},
changeTimestamp: function (timestamp) {
this.$refs.videoPlayer.player.currentTime(timestamp)
},
getVideoInformationLocal: async function () {
if (this.firstLoad) {
this.isLoading = true
}
try {
let result = await getLocalVideoInfo(this.videoId)
this.isFamilyFriendly = result.basic_info.is_family_safe
const recommendedVideos = result.watch_next_feed
?.filter((item) => item.type === 'CompactVideo' || item.type === 'CompactMovie')
.map(parseLocalWatchNextVideo) ?? []
// place watched recommended videos last
this.recommendedVideos = [
...recommendedVideos.filter((video) => !this.isRecommendedVideoWatched(video.videoId)),
...recommendedVideos.filter((video) => this.isRecommendedVideoWatched(video.videoId))
]
if (this.showFamilyFriendlyOnly && !this.isFamilyFriendly) {
this.isLoading = false
this.handleVideoEnded()
return
}
let playabilityStatus = result.playability_status
let bypassedResult = null
let streamingVideoId = this.videoId
let trailerIsNull = false
// if widevine support is added then we should check if playabilityStatus.status is UNPLAYABLE too
if (result.has_trailer) {
bypassedResult = result.getTrailerInfo()
/**
* @type {import ('youtubei.js').YTNodes.PlayerLegacyDesktopYpcTrailer}
*/
const trailerScreen = result.playability_status.error_screen
streamingVideoId = trailerScreen.video_id
// if the trailer is null then it is likely age restricted.
trailerIsNull = bypassedResult == null
if (!trailerIsNull) {
playabilityStatus = bypassedResult.playability_status
}
}
if (playabilityStatus.status === 'LOGIN_REQUIRED' || trailerIsNull) {
// try to bypass the age restriction
bypassedResult = await getLocalVideoInfo(streamingVideoId, true)
playabilityStatus = bypassedResult.playability_status
}
if (playabilityStatus.status === 'UNPLAYABLE') {
/**
* @type {import ('youtubei.js').YTNodes.PlayerErrorMessage}
*/
const errorScreen = playabilityStatus.error_screen
throw new Error(`[${playabilityStatus.status}] ${errorScreen.reason.text}: ${errorScreen.subreason.text}`)
}
// extract localised title first and fall back to the not localised one
this.videoTitle = result.primary_info?.title.text ?? result.basic_info.title
this.videoViewCount = result.basic_info.view_count
this.channelId = result.basic_info.channel_id
this.channelName = result.basic_info.author
if (result.secondary_info.owner?.author) {
this.channelThumbnail = result.secondary_info.owner.author.best_thumbnail?.url ?? ''
} else {
this.channelThumbnail = ''
}
this.updateSubscriptionDetails({
channelThumbnailUrl: this.channelThumbnail.length === 0 ? null : this.channelThumbnail,
channelName: this.channelName,
channelId: this.channelId
})
// `result.page[0].microformat.publish_date` example value: `2023-08-12T08:59:59-07:00`
this.videoPublished = new Date(result.page[0].microformat.publish_date).getTime()
if (result.secondary_info?.description.runs) {
try {
this.videoDescription = parseLocalTextRuns(result.secondary_info.description.runs)
} catch (error) {
console.error('Failed to extract the localised description, falling back to the standard one.', error, JSON.stringify(result.secondary_info.description.runs))
this.videoDescription = result.basic_info.short_description
}
} else {
this.videoDescription = result.basic_info.short_description
}
switch (this.thumbnailPreference) {
case 'start':
this.thumbnail = `https://i.ytimg.com/vi/${this.videoId}/maxres1.jpg`
break
case 'middle':
this.thumbnail = `https://i.ytimg.com/vi/${this.videoId}/maxres2.jpg`
break
case 'end':
this.thumbnail = `https://i.ytimg.com/vi/${this.videoId}/maxres3.jpg`
break
default:
this.thumbnail = result.basic_info.thumbnail[0].url
break
}
if (this.hideVideoLikesAndDislikes) {
this.videoLikeCount = null
this.videoDislikeCount = null
} else {
this.videoLikeCount = isNaN(result.basic_info.like_count) ? 0 : result.basic_info.like_count
// YouTube doesn't return dislikes anymore
this.videoDislikeCount = 0
}
this.isLive = !!result.basic_info.is_live
this.isUpcoming = !!result.basic_info.is_upcoming
this.isLiveContent = !!result.basic_info.is_live_content
this.isPostLiveDvr = !!result.basic_info.is_post_live_dvr
const subCount = !result.secondary_info.owner.subscriber_count.isEmpty() ? parseLocalSubscriberCount(result.secondary_info.owner.subscriber_count.text) : NaN
if (!isNaN(subCount)) {
this.channelSubscriptionCountText = formatNumber(subCount, subCount >= 10000 ? { notation: 'compact' } : undefined)
} else {
this.channelSubscriptionCountText = ''
}
let chapters = []
if (!this.hideChapters) {
const rawChapters = result.player_overlays?.decorated_player_bar?.player_bar?.markers_map?.get({ marker_key: 'DESCRIPTION_CHAPTERS' })?.value.chapters
if (rawChapters) {
for (const chapter of rawChapters) {
const start = chapter.time_range_start_millis / 1000
chapters.push({
title: chapter.title.text,
timestamp: formatDurationAsTimestamp(start),
startSeconds: start,
endSeconds: 0,
thumbnail: chapter.thumbnail[0]
})
}
} else {
chapters = this.extractChaptersFromDescription(result.basic_info.short_description)
}
if (chapters.length > 0) {
this.addChaptersEndSeconds(chapters, result.basic_info.duration)
// prevent vue from adding reactivity which isn't needed
// as the chapter objects are read-only after this anyway
// the chapters are checked for every timeupdate event that the player emits
// this should lessen the performance and memory impact of the chapters
chapters.forEach(Object.freeze)
}
}
this.videoChapters = chapters
if (!this.hideLiveChat && this.isLive && result.livechat) {
this.liveChat = result.getLiveChat()
} else {
this.liveChat = null
}
// region No comment detection
// For videos without any comment (comment disabled?)
// e.g. https://youtu.be/8NBSwDEf8a8
//
// `comments_entry_point_header` is null probably when comment disabled
// e.g. https://youtu.be/8NBSwDEf8a8
// However videos with comments enabled but have no comment
// are different (which is not detected here)
this.commentsEnabled = result.comments_entry_point_header != null
// endregion No comment detection
// the bypassed result is missing some of the info that we extract in the code above
// so we only overwrite the result here
// we need the bypassed result for the streaming data and the subtitles
if (bypassedResult) {
result = bypassedResult
}
if ((this.isLive || this.isPostLiveDvr) && !this.isUpcoming) {
try {
const formats = await getFormatsFromHLSManifest(result.streaming_data.hls_manifest_url)
this.videoSourceList = formats
.sort((formatA, formatB) => {
return formatB.height - formatA.height
})
.map((format) => {
return {
url: format.url,
fps: format.fps,
type: 'application/x-mpegURL',
label: 'Dash',
qualityLabel: `${format.height}p`
}
})
} catch (e) {
console.error('Failed to extract formats form HLS manifest, falling back to passing it directly to video.js', e)
this.videoSourceList = [
{
url: result.streaming_data.hls_manifest_url,
type: 'application/x-mpegURL',
label: 'Dash',
qualityLabel: 'Live'
}
]
}
this.activeFormat = 'legacy'
this.activeSourceList = this.videoSourceList
this.audioSourceList = null
this.dashSrc = null
} else if (this.isUpcoming) {
const upcomingTimestamp = result.basic_info.start_timestamp
if (upcomingTimestamp) {
const timestampOptions = {
month: 'long',
day: 'numeric',
hour: 'numeric',
minute: '2-digit'
}
const now = new Date()
if (now.getFullYear() < upcomingTimestamp.getFullYear()) {
Object.defineProperty(timestampOptions, 'year', {
value: 'numeric'
})
}
this.upcomingTimestamp = Intl.DateTimeFormat(this.currentLocale, timestampOptions).format(upcomingTimestamp)
let upcomingTimeLeft = upcomingTimestamp - now
// Convert from ms to second to minute
upcomingTimeLeft = (upcomingTimeLeft / 1000) / 60
let timeUnit = 'minute'
// Youtube switches to showing time left in minutes at 120 minutes remaining
if (upcomingTimeLeft > 120) {
upcomingTimeLeft /= 60
timeUnit = 'hour'
}
if (timeUnit === 'hour' && upcomingTimeLeft > 24) {
upcomingTimeLeft /= 24
timeUnit = 'day'
}
// Value after decimal not to be displayed
// e.g. > 2 days = display as `2 days`
upcomingTimeLeft = Math.floor(upcomingTimeLeft)
// Displays when less than a minute remains
// Looks better than `Premieres in x seconds`
if (upcomingTimeLeft < 1) {
this.upcomingTimeLeft = this.$t('Video.Published.In less than a minute').toLowerCase()
} else {
// TODO a I18n entry for time format might be needed here
this.upcomingTimeLeft = new Intl.RelativeTimeFormat(this.currentLocale).format(upcomingTimeLeft, timeUnit)
}
} else {
this.upcomingTimestamp = null
this.upcomingTimeLeft = null
}
} else {
this.videoLengthSeconds = result.basic_info.duration
if (result.streaming_data) {
if (result.streaming_data.formats.length > 0) {
this.videoSourceList = result.streaming_data.formats.map(mapLocalFormat).reverse()
} else {
this.videoSourceList = filterLocalFormats(result.streaming_data.adaptive_formats, this.allowDashAv1Formats).map(mapLocalFormat).reverse()
}
this.adaptiveFormats = this.videoSourceList
/** @type {import('../../helpers/api/local').LocalFormat[]} */
const formats = [...result.streaming_data.formats, ...result.streaming_data.adaptive_formats]
this.downloadLinks = formats.map((format) => {
const qualityLabel = format.quality_label ?? format.bitrate
const fps = format.fps ? `${format.fps}fps` : 'kbps'
const type = format.mime_type.split(';')[0]
let label = `${qualityLabel} ${fps} - ${type}`
if (format.has_audio !== format.has_video) {
if (format.has_video) {
label += ` ${this.$t('Video.video only')}`
} else {
label += ` ${this.$t('Video.audio only')}`
}
}
return {
url: format.freeTubeUrl,
label: label
}
})
if (result.captions) {
const captionTracks = result.captions.caption_tracks.map((caption) => {
return {
url: caption.base_url,
label: caption.name.text,
language_code: caption.language_code,
kind: caption.kind
}
})
if (this.currentLocale) {
const noLocaleCaption = !captionTracks.some(track =>
track.language_code === this.currentLocale && track.kind !== 'asr'
)
if (!this.currentLocale.startsWith('en') && noLocaleCaption) {
captionTracks.forEach((caption) => {
this.tryAddingTranslatedLocaleCaption(captionTracks, this.currentLocale, caption.url)
})
}
}
this.captionHybridList = this.createCaptionPromiseList(captionTracks)
const captionLinks = captionTracks.map((caption) => {
const label = `${caption.label} (${caption.language_code}) - text/vtt`
return {
url: caption.url,
label: label
}
})
this.downloadLinks = this.downloadLinks.concat(captionLinks)
}
} else {
// video might be region locked or something else. This leads to no formats being available
showToast(
this.$t('This video is unavailable because of missing formats. This can happen due to country unavailability.'),
7000
)
this.handleVideoEnded()
return
}
if (result.streaming_data?.adaptive_formats.length > 0) {
const audioFormats = result.streaming_data.adaptive_formats.filter((format) => {
return format.has_audio
})
const hasMultipleAudioTracks = audioFormats.some(format => format.audio_track)
if (hasMultipleAudioTracks) {
const audioTracks = this.createAudioTracksFromLocalFormats(audioFormats)
this.audioTracks = audioTracks
this.audioSourceList = audioTracks.find(track => track.isDefault).sourceList
} else {
this.audioTracks = []
this.audioSourceList = this.createLocalAudioSourceList(audioFormats)
}
// we need to alter the result object so the toDash function uses the filtered formats too
result.streaming_data.adaptive_formats = filterLocalFormats(result.streaming_data.adaptive_formats, this.allowDashAv1Formats)
// When `this.proxyVideos` is true
// It's possible that the Invidious instance used, only supports a subset of the formats from Local API
// i.e. the value passed into `adaptiveFormats`
// e.g. Supports 720p60, but not 720p - https://[DOMAIN_NAME]/api/manifest/dash/id/v3wm83zoSSY?local=true
if (this.proxyVideos) {
this.adaptiveFormats = await this.getAdaptiveFormatsInvidious()
this.dashSrc = await this.createInvidiousDashManifest()
} else {
this.adaptiveFormats = result.streaming_data.adaptive_formats.map(mapLocalFormat)
this.dashSrc = await this.createLocalDashManifest(result)
}
if (this.activeFormat === 'audio') {
this.activeSourceList = this.audioSourceList
} else {
this.activeSourceList = this.videoSourceList
}
} else {
this.activeSourceList = this.videoSourceList
this.audioSourceList = null
this.dashSrc = null
this.enableLegacyFormat()
}
if (result.storyboards?.type === 'PlayerStoryboardSpec') {
this.createLocalStoryboardUrls(result.storyboards.boards.at(-1))
}
}
this.isLoading = false
this.updateTitle()
} catch (err) {
const errorMessage = this.$t('Local API Error (Click to copy)')
showToast(`${errorMessage}: ${err}`, 10000, () => {
copyToClipboard(err)
})
console.error(err)
if (this.backendPreference === 'local' && this.backendFallback && !err.toString().includes('private')) {
showToast(this.$t('Falling back to Invidious API'))
// Invidious doesn't support multiple audio tracks, so we need to clear this to prevent the player getting confused
this.audioTracks = []
this.getVideoInformationInvidious()
} else {
this.isLoading = false
}
}
},
getVideoInformationInvidious: function () {
if (this.firstLoad) {
this.isLoading = true
}
this.videoStoryboardSrc = `${this.currentInvidiousInstance}/api/v1/storyboards/${this.videoId}?height=90`
invidiousGetVideoInformation(this.videoId)
.then(async result => {
if (result.error) {
throw new Error(result.error)
}
this.videoTitle = result.title
this.videoViewCount = result.viewCount
this.channelSubscriptionCountText = isNaN(result.subCountText) ? '' : result.subCountText
if (this.hideVideoLikesAndDislikes) {
this.videoLikeCount = null
this.videoDislikeCount = null
} else {
this.videoLikeCount = result.likeCount
this.videoDislikeCount = result.dislikeCount
}
this.channelId = result.authorId
this.channelName = result.author
const channelThumb = result.authorThumbnails[1]
this.channelThumbnail = channelThumb ? youtubeImageUrlToInvidious(channelThumb.url, this.currentInvidiousInstance) : ''
this.updateSubscriptionDetails({
channelThumbnailUrl: channelThumb?.url,
channelName: result.author,
channelId: result.authorId
})
this.videoPublished = result.published * 1000
this.videoDescriptionHtml = result.descriptionHtml
const recommendedVideos = result.recommendedVideos
// place watched recommended videos last
this.recommendedVideos = [
...recommendedVideos.filter((video) => !this.isRecommendedVideoWatched(video.videoId)),
...recommendedVideos.filter((video) => this.isRecommendedVideoWatched(video.videoId))
]
this.adaptiveFormats = await this.getAdaptiveFormatsInvidious(result)
this.isLive = result.liveNow
this.isFamilyFriendly = result.isFamilyFriendly
this.captionHybridList = result.captions.map(caption => {
caption.url = this.currentInvidiousInstance + caption.url
caption.type = ''
caption.dataSource = 'invidious'
return caption
})
switch (this.thumbnailPreference) {
case 'start':
this.thumbnail = `${this.currentInvidiousInstance}/vi/${this.videoId}/maxres1.jpg`
break
case 'middle':
this.thumbnail = `${this.currentInvidiousInstance}/vi/${this.videoId}/maxres2.jpg`
break
case 'end':
this.thumbnail = `${this.currentInvidiousInstance}/vi/${this.videoId}/maxres3.jpg`
break
default:
this.thumbnail = result.videoThumbnails[0].url
break
}
let chapters = []
if (!this.hideChapters) {
chapters = this.extractChaptersFromDescription(result.description)
if (chapters.length > 0) {
this.addChaptersEndSeconds(chapters, result.lengthSeconds)
// prevent vue from adding reactivity which isn't needed
// as the chapter objects are read-only after this anyway
// the chapters are checked for every timeupdate event that the player emits
// this should lessen the performance and memory impact of the chapters
chapters.forEach(Object.freeze)
}
}
this.videoChapters = chapters
if (this.isLive) {
this.activeFormat = 'legacy'
this.videoSourceList = [
{
url: result.hlsUrl,
type: 'application/x-mpegURL',
label: 'Dash',
qualityLabel: 'Live'
}
]
// Grabs the adaptive formats from Invidious. Might be worth making these work.
// The type likely needs to be changed in order for these to be played properly.
// this.videoSourceList = result.adaptiveFormats.filter((format) => {
// if (typeof (format.type) !== 'undefined') {
// return format.type.includes('video/mp4')
// }
// }).map((format) => {
// return {
// url: format.url,
// type: 'application/x-mpegURL',
// label: 'Dash',
// qualityLabel: format.qualityLabel
// }
// })
this.activeSourceList = this.videoSourceList
} else if (this.forceLocalBackendForLegacy) {
this.getLegacyFormats()
} else {
this.videoLengthSeconds = result.lengthSeconds
this.videoSourceList = result.formatStreams.reverse()
this.downloadLinks = result.adaptiveFormats.concat(this.videoSourceList).map((format) => {
const qualityLabel = format.qualityLabel || format.bitrate
const itag = parseInt(format.itag)
const fps = format.fps ? (format.fps + 'fps') : 'kbps'
const type = format.type.split(';')[0]
let label = `${qualityLabel} ${fps} - ${type}`
if (itag !== 18 && itag !== 22) {
if (type.includes('video')) {
label += ` ${this.$t('Video.video only')}`
} else {
label += ` ${this.$t('Video.audio only')}`
}
}
const object = {
url: format.url,
label: label
}
return object
}).reverse().concat(result.captions.map((caption) => {
const label = `${caption.label} (${caption.languageCode}) - text/vtt`
const object = {
url: caption.url,
label: label
}
return object
}))
this.audioTracks = []
this.dashSrc = await this.createInvidiousDashManifest()
if (process.env.IS_ELECTRON && this.audioTracks.length > 0) {
// when we are in Electron and the video has multiple audio tracks,
// we populate the list inside createInvidiousDashManifest
// as we need to work out the different audio tracks for the DASH manifest anyway
this.audioSourceList = this.audioTracks.find(track => track.isDefault).sourceList
} else {
this.audioSourceList = result.adaptiveFormats.filter((format) => {
return format.type.includes('audio')
}).map((format) => {
return {
url: format.url,
type: format.type,
label: 'Audio',
qualityLabel: parseInt(format.bitrate)
}
}).sort((a, b) => {
return a.qualityLabel - b.qualityLabel
})
}
if (this.activeFormat === 'audio') {
this.activeSourceList = this.audioSourceList
} else {
this.activeSourceList = this.videoSourceList
}
}
this.updateTitle()
this.isLoading = false
})
.catch(err => {
console.error(err)
const errorMessage = this.$t('Invidious API Error (Click to copy)')
showToast(`${errorMessage}: ${err.responseText}`, 10000, () => {
copyToClipboard(err.responseText)
})
console.error(err)
if (process.env.IS_ELECTRON && this.backendPreference === 'invidious' && this.backendFallback) {
showToast(this.$t('Falling back to Local API'))
this.getVideoInformationLocal()
} else {
this.isLoading = false
}
})
},
/**
* @param {string} description
*/
extractChaptersFromDescription: function (description) {
const chapters = []
// HH:MM:SS Text
// MM:SS Text
// HH:MM:SS - Text // separator is one of '-', '', '•', '—'
// MM:SS - Text
// HH:MM:SS - HH:MM:SS - Text // end timestamp is ignored, separator is one of '-', '', '—'
// HH:MM - HH:MM - Text // end timestamp is ignored
const chapterMatches = description.matchAll(/^(?<timestamp>((?<hours>\d+):)?(?<minutes>\d+):(?<seconds>\d+))(\s*[–—-]\s*(?:\d+:){1,2}\d+)?\s+([–—•-]\s*)?(?<title>.+)$/gm)
for (const { groups } of chapterMatches) {
let start = 60 * Number(groups.minutes) + Number(groups.seconds)
if (groups.hours) {
start += 3600 * Number(groups.hours)
}
// replace previous chapter with current one if they have an identical start time
if (chapters.length > 0 && chapters[chapters.length - 1].startSeconds === start) {
chapters.pop()
}
chapters.push({
title: groups.title.trim(),
timestamp: groups.timestamp,
startSeconds: start,
endSeconds: 0
})
}
return chapters
},
addChaptersEndSeconds: function (chapters, videoLengthSeconds) {
for (let i = 0; i < chapters.length - 1; i++) {
chapters[i].endSeconds = chapters[i + 1].startSeconds
}
chapters.at(-1).endSeconds = videoLengthSeconds
},
updateCurrentChapter: function () {
const chapters = this.videoChapters
const currentSeconds = this.getTimestamp()
const currentChapterStart = chapters[this.videoCurrentChapterIndex].startSeconds
if (currentSeconds !== currentChapterStart) {
let i = currentSeconds < currentChapterStart ? 0 : this.videoCurrentChapterIndex
for (; i < chapters.length; i++) {
if (currentSeconds < chapters[i].endSeconds) {
this.videoCurrentChapterIndex = i
break
}
}
}
},
/**
* @param {import('../../helpers/api/local').LocalFormat[]} audioFormats
* @returns {AudioTrack[]}
*/
createAudioTracksFromLocalFormats: function (audioFormats) {
/** @type {string[]} */
const ids = []
/** @type {AudioTrack[]} */
const audioTracks = []
/** @type {import('youtubei.js').Misc.Format[][]} */
const sourceLists = []
for (const format of audioFormats) {
// Some videos with multiple audio tracks, have a broken one, that doesn't have any audio track information
// It seems to be the same as default audio track but broken
// At the time of writing, this video has a broken audio track: https://youtu.be/UJeSWbR6W04
if (!format.audio_track) {
continue
}
const index = ids.indexOf(format.audio_track.id)
if (index === -1) {
ids.push(format.audio_track.id)
let kind
if (format.audio_track.audio_is_default) {
kind = 'main'
} else if (format.is_dubbed) {
kind = 'translation'
} else if (format.is_descriptive) {
kind = 'descriptions'
} else {
kind = 'alternative'
}
audioTracks.push({
id: format.audio_track.id,
kind,
label: format.audio_track.display_name,
language: format.language,
isDefault: format.audio_track.audio_is_default,
sourceList: []
})
sourceLists.push([
format
])
} else {
sourceLists[index].push(format)
}
}
for (let i = 0; i < audioTracks.length; i++) {
audioTracks[i].sourceList = this.createLocalAudioSourceList(sourceLists[i])
}
return audioTracks
},
/**
* @param {import('../../helpers/api/local').LocalFormat[]} audioFormats
* @returns {AudioSource[]}
*/
createLocalAudioSourceList: function (audioFormats) {
return audioFormats.sort((a, b) => {
return a.bitrate - b.bitrate
}).map((format, index) => {
let label
switch (index) {
case 0:
label = this.$t('Video.Audio.Low')
break
case 1:
label = this.$t('Video.Audio.Medium')
break
case 2:
label = this.$t('Video.Audio.High')
break
case 3:
label = this.$t('Video.Audio.Best')
break
default:
label = format.bitrate.toString()
}
return {
url: format.freeTubeUrl,
type: format.mime_type,
label: 'Audio',
qualityLabel: label
}
}).reverse()
},
addToHistory: function (watchProgress) {
const videoData = {
videoId: this.videoId,
title: this.videoTitle,
author: this.channelName,
authorId: this.channelId,
published: this.videoPublished,
description: this.videoDescription,
viewCount: this.videoViewCount,
lengthSeconds: this.videoLengthSeconds,
watchProgress: watchProgress,
timeWatched: new Date().getTime(),
isLive: false,
type: 'video',
}
this.updateHistory(videoData)
},
handleWatchProgress: function () {
if (this.rememberHistory && !this.isUpcoming && !this.isLoading && !this.isLive) {
const player = this.$refs.videoPlayer.player
if (player !== null && this.saveWatchedProgress) {
const currentTime = this.getWatchedProgress()
const payload = {
videoId: this.videoId,
watchProgress: currentTime
}
this.updateWatchProgress(payload)
}
}
},
handlePlaylistPersisting: function () {
// Only save playlist ID if enabled, and it's not special video types
if (!(this.rememberHistory && this.saveVideoHistoryWithLastViewedPlaylist)) { return }
if (this.isUpcoming || this.isLive) { return }
this.updateLastViewedPlaylist({
videoId: this.videoId,
// Whether there is a playlist ID or not, save it
lastViewedPlaylistId: this.playlistId,
lastViewedPlaylistType: this.playlistType,
lastViewedPlaylistItemId: this.playlistItemId,
})
},
handleVideoReady: function () {
this.videoPlayerReady = true
this.checkIfWatched()
this.updateLocalPlaylistLastPlayedAtSometimes()
},
isRecommendedVideoWatched: function (videoId) {
return !!this.$store.getters.getHistoryCacheById[videoId]
},
checkIfWatched: function () {
if (!this.isLive) {
if (this.timestamp) {
if (this.timestamp < 0) {
this.$refs.videoPlayer.player.currentTime(0)
} else if (this.timestamp > (this.videoLengthSeconds - 10)) {
this.$refs.videoPlayer.player.currentTime(this.videoLengthSeconds - 10)
} else {
this.$refs.videoPlayer.player.currentTime(this.timestamp)
}
} else if (this.saveWatchedProgress && this.historyEntryExists) {
// For UX consistency, no progress reading if writing disabled
const watchProgress = this.historyEntry.watchProgress
if (watchProgress < (this.videoLengthSeconds - 10)) {
this.$refs.videoPlayer.player.currentTime(watchProgress)
}
}
}
if (this.rememberHistory) {
if (this.timestamp) {
this.addToHistory(this.timestamp)
} else if (this.historyEntryExists) {
this.addToHistory(this.historyEntry.watchProgress)
} else {
this.addToHistory(0)
}
// Must be called AFTER history entry inserted
// Otherwise the value is not saved for first time watched videos
this.handlePlaylistPersisting()
}
},
checkIfPlaylist: function () {
// On the off chance that user selected pause on current video
// Then clicks on another video in the playlist
this.disablePlaylistPauseOnCurrent()
if (this.$route.query == null) {
this.watchingPlaylist = false
return
}
this.playlistId = this.$route.query.playlistId
this.playlistItemId = this.$route.query.playlistItemId
if (this.playlistId == null || this.playlistId.length === 0) {
this.playlistType = ''
this.playlistItemId = null
this.watchingPlaylist = false
return
}
// `playlistId` present
if (this.selectedUserPlaylist != null) {
// If playlist ID matches a user playlist, it must be user playlist
this.playlistType = 'user'
this.watchingPlaylist = true
return
}
// Still possible to be a user playlist from history
// (but user playlist could be already removed)
this.playlistType = this.$route.query.playlistType
if (this.playlistType !== 'user') {
// Remote playlist
this.playlistItemId = null
this.watchingPlaylist = true
return
}
// At this point `playlistType === 'user'`
// But the playlist might be already removed
if (this.selectedUserPlaylist == null) {
// Clear playlist data so that watch history will be properly updated
this.playlistId = ''
this.playlistType = ''
this.playlistItemId = null
}
this.watchingPlaylist = this.selectedUserPlaylist != null
},
checkIfTimestamp: function () {
const timestamp = parseInt(this.$route.query.timestamp)
this.timestamp = isNaN(timestamp) || timestamp < 0 ? null : timestamp
},
getLegacyFormats: function () {
getLocalVideoInfo(this.videoId)
.then(result => {
this.videoSourceList = result.streaming_data.formats.map(mapLocalFormat)
})
.catch(err => {
const errorMessage = this.$t('Local API Error (Click to copy)')
showToast(`${errorMessage}: ${err}`, 10000, () => {
copyToClipboard(err)
})
console.error(err)
if (!process.env.IS_ELECTRON || (this.backendPreference === 'local' && this.backendFallback)) {
showToast(this.$t('Falling back to Invidious API'))
this.getVideoInformationInvidious()
}
})
},
handleFormatChange: function (format) {
switch (format) {
case 'dash':
this.enableDashFormat()
break
case 'legacy':
this.enableLegacyFormat()
break
case 'audio':
this.enableAudioFormat()
break
}
},
enableDashFormat: function () {
if (this.activeFormat === 'dash') {
return
}
if (this.dashSrc === null || this.isLive || this.isPostLiveDvr) {
showToast(this.$t('Change Format.Dash formats are not available for this video'))
return
}
const watchedProgress = this.getWatchedProgress()
this.activeFormat = 'dash'
this.hidePlayer = true
setTimeout(() => {
this.hidePlayer = false
setTimeout(() => {
const player = this.$refs.videoPlayer.player
if (player !== null) {
player.currentTime(watchedProgress)
}
}, 500)
}, 100)
},
enableLegacyFormat: function () {
if (this.activeFormat === 'legacy') {
return
}
const watchedProgress = this.getWatchedProgress()
this.activeFormat = 'legacy'
this.activeSourceList = this.videoSourceList
this.hidePlayer = true
setTimeout(() => {
this.hidePlayer = false
setTimeout(() => {
const player = this.$refs.videoPlayer.player
if (player !== null) {
player.currentTime(watchedProgress)
}
}, 500)
}, 100)
},
enableAudioFormat: function () {
if (this.activeFormat === 'audio') {
return
}
if (this.audioSourceList === null) {
showToast(this.$t('Change Format.Audio formats are not available for this video'))
return
}
const watchedProgress = this.getWatchedProgress()
this.activeFormat = 'audio'
this.activeSourceList = this.audioSourceList
this.hidePlayer = true
setTimeout(() => {
this.hidePlayer = false
setTimeout(() => {
const player = this.$refs.videoPlayer.player
if (player !== null) {
player.currentTime(watchedProgress)
}
}, 500)
}, 100)
},
handleVideoEnded: function () {
if ((!this.watchingPlaylist || !this.autoplayPlaylists) && !this.playNextVideo) {
return
}
if (this.watchingPlaylist && this.getPlaylistPauseOnCurrent()) {
this.disablePlaylistPauseOnCurrent()
return
}
if (this.watchingPlaylist && this.$refs.watchVideoPlaylist.shouldStopDueToPlaylistEnd) {
// Let `watchVideoPlaylist` handle end of playlist, no countdown needed
this.$refs.watchVideoPlaylist.playNextVideo()
return
}
let nextVideoId = null
if (!this.watchingPlaylist) {
const forbiddenTitles = this.forbiddenTitles
const channelsHidden = this.channelsHidden
nextVideoId = this.recommendedVideos.find((video) =>
!this.isHiddenVideo(forbiddenTitles, channelsHidden, video)
)?.videoId
if (!nextVideoId) {
return
}
}
const nextVideoInterval = this.defaultInterval
this.playNextTimeout = setTimeout(() => {
const player = this.$refs.videoPlayer.player
if (player !== null && player.paused()) {
if (this.watchingPlaylist) {
this.$refs.watchVideoPlaylist.playNextVideo()
} else {
this.$router.push({
path: `/watch/${nextVideoId}`
})
showToast(this.$t('Playing Next Video'))
}
}
}, nextVideoInterval * 1000)
let countDownTimeLeftInSecond = nextVideoInterval
const showCountDownMessage = () => {
// Will not display "Playing next video in no time" as it's too late to cancel
// Also there is a separate message when playing next video
if (countDownTimeLeftInSecond <= 0) {
clearInterval(this.playNextCountDownIntervalId)
return
}
// To avoid message flashing
// `time` is manually tested to be 700
const message = this.$tc('Playing Next Video Interval', countDownTimeLeftInSecond, { nextVideoInterval: countDownTimeLeftInSecond })
showToast(message, 700, () => {
clearTimeout(this.playNextTimeout)
clearInterval(this.playNextCountDownIntervalId)
showToast(this.$t('Canceled next video autoplay'))
})
// At least this var should be updated AFTER showing the message
countDownTimeLeftInSecond = countDownTimeLeftInSecond - 1
}
// Execute once before scheduling it
showCountDownMessage()
this.playNextCountDownIntervalId = setInterval(showCountDownMessage, 1000)
},
handleRouteChange: function (videoId) {
// receiving it as an arg instead of accessing it ourselves means we always have the right one
clearTimeout(this.playNextTimeout)
clearInterval(this.playNextCountDownIntervalId)
this.videoChapters = []
this.handleWatchProgress()
if (!this.isUpcoming && !this.isLoading) {
const player = this.$refs.videoPlayer.player
if (player !== null && !player.paused() && player.isInPictureInPicture()) {
setTimeout(() => {
player.play()
player.on('leavepictureinpicture', (event) => {
const watchTime = player.currentTime()
if (this.$route.fullPath.includes('/watch')) {
const routeId = this.$route.params.id
if (routeId === videoId) {
this.$refs.videoPlayer.$refs.video.currentTime = watchTime
}
}
player.pause()
player.dispose()
})
}, 200)
}
}
if (this.videoStoryboardSrc.startsWith('blob:')) {
URL.revokeObjectURL(this.videoStoryboardSrc)
this.videoStoryboardSrc = ''
}
},
handleVideoError: function (error) {
console.error(error)
if (this.isLive) {
return
}
if (error.code === 4) {
if (this.activeFormat === 'dash') {
console.warn(
'Unable to play dash formats. Reverting to legacy formats...'
)
this.enableLegacyFormat()
} else {
this.enableDashFormat()
}
}
},
/**
* @param {import('youtubei.js').YT.VideoInfo} videoInfo
*/
createLocalDashManifest: async function (videoInfo) {
const xmlData = await videoInfo.toDash()
return [
{
url: `data:application/dash+xml;charset=UTF-8,${encodeURIComponent(xmlData)}`,
type: 'application/dash+xml',
label: 'Dash',
qualityLabel: 'Auto'
}
]
},
createInvidiousDashManifest: async function () {
let url = `${this.currentInvidiousInstance}/api/manifest/dash/id/${this.videoId}`
// If we are in Electron,
// we can use YouTube.js' DASH manifest generator to generate the manifest.
// Using YouTube.js' gives us support for multiple audio tracks (currently not supported by Invidious)
if (process.env.IS_ELECTRON) {
// Invidious' API response doesn't include the height and width (and fps and qualityLabel for AV1) of video streams
// so we need to extract them from Invidious' manifest
const response = await fetch(url)
const originalText = await response.text()
const parsedManifest = new DOMParser().parseFromString(originalText, 'application/xml')
/** @type {import('youtubei.js').Misc.Format[]} */
const formats = []
/** @type {import('youtubei.js').Misc.Format[]} */
const audioFormats = []
let hasMultipleAudioTracks = false
for (const format of this.adaptiveFormats) {
if (format.type.startsWith('video/')) {
const representation = parsedManifest.querySelector(`Representation[id="${format.itag}"][bandwidth="${format.bitrate}"]`)
format.height = parseInt(representation.getAttribute('height'))
format.width = parseInt(representation.getAttribute('width'))
format.fps = parseInt(representation.getAttribute('frameRate'))
// the quality label is missing for AV1 formats
if (!format.qualityLabel) {
format.qualityLabel = format.width > format.height ? `${format.height}p` : `${format.width}p`
}
}
const localFormat = convertInvidiousToLocalFormat(format)
if (localFormat.has_audio) {
audioFormats.push(localFormat)
if (localFormat.is_dubbed || localFormat.is_descriptive) {
hasMultipleAudioTracks = true
}
}
formats.push(localFormat)
}
if (hasMultipleAudioTracks) {
// match YouTube's local API response with English
const languageNames = new Intl.DisplayNames('en-US', { type: 'language' })
for (const format of audioFormats) {
this.generateAudioTrackFieldInvidious(format, languageNames)
}
this.audioTracks = this.createAudioTracksFromLocalFormats(audioFormats)
}
const manifest = await generateInvidiousDashManifestLocally(formats)
url = `data:application/dash+xml;charset=UTF-8,${encodeURIComponent(manifest)}`
} else if (this.proxyVideos) {
url += '?local=true'
}
return [
{
url: url,
type: 'application/dash+xml',
label: 'Dash',
qualityLabel: 'Auto'
}
]
},
/**
* @param {import('youtubei.js').Misc.Format} format
* @param {Intl.DisplayNames} languageNames
*/
generateAudioTrackFieldInvidious: function (format, languageNames) {
let type = ''
// use the same id numbers as YouTube (except -1, when we aren't sure what it is)
let idNumber = ''
if (format.is_descriptive) {
type = ' descriptive'
idNumber = 2
} else if (format.is_dubbed) {
type = ''
idNumber = 3
} else if (format.is_original) {
type = ' original'
idNumber = 4
} else {
type = ' alternative'
idNumber = -1
}
const languageName = languageNames.of(format.language)
format.audio_track = {
audio_is_default: !!format.is_original,
id: `${format.language}.${idNumber}`,
display_name: `${languageName}${type}`
}
},
getAdaptiveFormatsInvidious: async function (existingInfoResult = null) {
let result
if (existingInfoResult) {
result = existingInfoResult
} else {
result = await invidiousGetVideoInformation(this.videoId)
}
return filterInvidiousFormats(result.adaptiveFormats, this.allowDashAv1Formats)
.map((format) => {
format.bitrate = parseInt(format.bitrate)
if (typeof format.resolution === 'string') {
format.height = parseInt(format.resolution.replace('p', ''))
}
return format
})
},
createLocalStoryboardUrls: function (storyboardInfo) {
const results = buildVTTFileLocally(storyboardInfo, this.videoLengthSeconds)
// after the player migration, switch to using a data URI, as those don't need to be revoked
const blob = new Blob([results], { type: 'text/vtt;charset=UTF-8' })
this.videoStoryboardSrc = URL.createObjectURL(blob)
},
tryAddingTranslatedLocaleCaption: function (captionTracks, locale, baseUrl) {
const enCaptionIdx = captionTracks.findIndex(track =>
track.language_code === 'en' && track.kind !== 'asr'
)
const enCaptionExists = enCaptionIdx !== -1
const asrEnabled = captionTracks.some(track => track.kind === 'asr')
if (enCaptionExists || asrEnabled) {
let label
let url
if (this.$te('Video.translated from English') && this.$t('Video.translated from English') !== '') {
label = `${this.$t('Locale Name')} (${this.$t('Video.translated from English')})`
} else {
label = `${this.$t('Locale Name')} (translated from English)`
}
const indexTranslated = captionTracks.findIndex((item) => {
return item.label === label
})
if (indexTranslated !== -1) {
return
}
if (enCaptionExists) {
url = new URL(captionTracks[enCaptionIdx].url)
} else {
url = new URL(baseUrl)
url.searchParams.set('lang', 'en')
url.searchParams.set('kind', 'asr')
}
url.searchParams.set('tlang', locale)
captionTracks.unshift({
url: url.toString(),
label,
language_code: locale,
is_autotranslated: true
})
}
},
createCaptionPromiseList: function (captionTracks) {
return captionTracks.map(caption => new Promise((resolve, reject) => {
caption.type = 'text/vtt'
caption.charset = 'charset=utf-8'
caption.dataSource = 'local'
const url = new URL(caption.url)
url.searchParams.set('fmt', 'vtt')
fetch(url)
.then((response) => response.text())
.then((text) => {
// The character '#' needs to be percent-encoded in a (data) URI
// because it signals an identifier, which means anything after it
// is automatically removed when the URI is used as a source
let vtt = text.replaceAll('#', '%23')
// A lot of videos have messed up caption positions that need to be removed
// This can be either because this format isn't really used by YouTube
// or because it's expected for the player to be able to somehow
// wrap the captions so that they won't step outside its boundaries
//
// Auto-generated captions are also all aligned to the start
// so those instances must also be removed
// In addition, all aligns seem to be fixed to "start" when they do pop up in normal captions
// If it's prominent enough that people start to notice, it can be removed then
if (caption.kind === 'asr') {
vtt = vtt.replaceAll(/ align:start| position:\d{1,3}%/g, '')
} else {
vtt = vtt.replaceAll(/ position:\d{1,3}%/g, '')
}
caption.url = `data:${caption.type};${caption.charset},${vtt}`
resolve(caption)
})
.catch((error) => {
console.error(error)
reject(error)
})
}))
},
pausePlayer: function () {
const player = this.$refs.videoPlayer.player
if (player && !player.paused()) {
player.pause()
}
},
getWatchedProgress: function () {
return this.$refs.videoPlayer && this.$refs.videoPlayer.player ? this.$refs.videoPlayer.player.currentTime() : 0
},
getTimestamp: function () {
return Math.floor(this.getWatchedProgress())
},
getPlaylistIndex: function () {
return this.$refs.watchVideoPlaylist
? this.getPlaylistReverse()
? this.$refs.watchVideoPlaylist.playlistItems.length - this.$refs.watchVideoPlaylist.currentVideoIndexOneBased
: this.$refs.watchVideoPlaylist.currentVideoIndexZeroBased
: -1
},
getPlaylistReverse: function () {
return this.$refs.watchVideoPlaylist ? this.$refs.watchVideoPlaylist.reversePlaylist : false
},
getPlaylistShuffle: function () {
return this.$refs.watchVideoPlaylist ? this.$refs.watchVideoPlaylist.shuffleEnabled : false
},
getPlaylistLoop: function () {
return this.$refs.watchVideoPlaylist ? this.$refs.watchVideoPlaylist.loopEnabled : false
},
getPlaylistPauseOnCurrent: function () {
return this.$refs.watchVideoPlaylist ? this.$refs.watchVideoPlaylist.pauseOnCurrentVideo : false
},
disablePlaylistPauseOnCurrent: function () {
if (this.$refs.watchVideoPlaylist) {
this.$refs.watchVideoPlaylist.pauseOnCurrentVideo = false
}
},
updateTitle: function () {
document.title = `${this.videoTitle} - ${packageDetails.productName}`
},
isHiddenVideo: function (forbiddenTitles, channelsHidden, video) {
return channelsHidden.some(ch => ch.name === video.authorId) ||
channelsHidden.some(ch => ch.name === video.author) ||
forbiddenTitles.some((text) => video.title?.toLowerCase().includes(text.toLowerCase()))
},
updateLocalPlaylistLastPlayedAtSometimes() {
if (this.selectedUserPlaylist == null) { return }
const playlist = this.selectedUserPlaylist
this.updatePlaylistLastPlayedAt({ _id: playlist._id })
},
...mapActions([
'updateHistory',
'updateWatchProgress',
'updateLastViewedPlaylist',
'updatePlaylistLastPlayedAt',
'updateSubscriptionDetails',
])
}
})