import Vue from 'vue'
import { mapActions } from 'vuex'
import fs from 'fs/promises'
import ytDashGen from 'yt-dash-manifest-generator'
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 i18n from '../../i18n/index'
import { pathExists } from '../../helpers/filesystem'
import {
buildVTTFileLocally,
copyToClipboard,
formatDurationAsTimestamp,
getUserDataPath,
showToast
} from '../../helpers/utils'
export default Vue.extend({
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,
showDashPlayer: true,
showLegacyPlayer: false,
showYouTubeNoCookieEmbed: false,
hidePlayer: false,
isFamilyFriendly: false,
isLive: false,
isLiveContent: false,
isUpcoming: 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: '',
audioUrl: '',
dashSrc: [],
activeSourceList: [],
videoSourceList: [],
audioSourceList: [],
adaptiveFormats: [],
captionHybridList: [], // [] -> Promise[] -> string[] (URIs)
recommendedVideos: [],
downloadLinks: [],
watchingPlaylist: false,
playlistId: '',
timestamp: null,
playNextTimeout: null,
playNextCountDownIntervalId: null,
pictureInPictureButtonInverval: null,
infoAreaSticky: true
}
},
computed: {
historyCache: function () {
return this.$store.getters.getHistoryCache
},
rememberHistory: function () {
return this.$store.getters.getRememberHistory
},
removeVideoMetaFiles: function () {
return this.$store.getters.getRemoveVideoMetaFiles
},
saveWatchedProgress: function () {
return this.$store.getters.getSaveWatchedProgress
},
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
},
youtubeNoCookieEmbeddedFrame: function () {
return ``
},
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 i18n.locale.replace('_', '-')
},
hideChapters: function () {
return this.$store.getters.getHideChapters
}
},
watch: {
$route() {
this.handleRouteChange(this.videoId)
// react to route changes...
this.videoId = this.$route.params.id
this.firstLoad = true
this.activeFormat = this.defaultVideoFormat
this.videoStoryboardSrc = ''
this.captionHybridList = []
this.downloadLinks = []
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
}
},
activeFormat: function (format) {
clearInterval(this.pictureInPictureButtonInverval)
// only hide/show the button once the player is available
this.pictureInPictureButtonInverval = setInterval(() => {
if (!this.hidePlayer) {
const pipButton = document.querySelector('.vjs-picture-in-picture-control')
if (pipButton === null) {
return
}
if (format === 'audio') {
pipButton.classList.add('vjs-hidden')
} else {
pipButton.classList.remove('vjs-hidden')
}
clearInterval(this.pictureInPictureButtonInverval)
}
}, 100)
}
},
mounted: function () {
this.videoId = this.$route.params.id
this.activeFormat = this.defaultVideoFormat
this.useTheatreMode = this.defaultTheatreMode
this.checkIfPlaylist()
this.checkIfTimestamp()
if (!process.env.IS_ELECTRON || this.backendPreference === 'invidious') {
this.getVideoInformationInvidious()
} else {
this.getVideoInformationLocal()
}
window.addEventListener('beforeunload', this.handleWatchProgress)
},
methods: {
changeTimestamp: function (timestamp) {
this.$refs.videoPlayer.player.currentTime(timestamp)
},
toggleTheatreMode: function () {
this.useTheatreMode = !this.useTheatreMode
},
getVideoInformationLocal: function () {
if (this.firstLoad) {
this.isLoading = true
}
this.ytGetVideoInformation(this.videoId)
.then(async result => {
const playabilityStatus = result.player_response.playabilityStatus
if (playabilityStatus.status === 'UNPLAYABLE') {
const errorScreen = playabilityStatus.errorScreen.playerErrorMessageRenderer
const reason = errorScreen.reason.simpleText
let subReason
let skipIndex
errorScreen.subreason.runs.forEach((message, index) => {
if (index !== skipIndex) {
if (message.text.match(//)) {
skipIndex = index + 1
} else if (!message.text.match(/<\/a>/)) {
if (typeof subReason === 'undefined') {
subReason = message.text
} else {
subReason = subReason + message.text
}
}
}
})
throw new Error(`${reason}: ${subReason}`)
}
try {
// workaround for title localization
this.videoTitle = result.response.contents.twoColumnWatchNextResults.results.results.contents[0].videoPrimaryInfoRenderer.title.runs.map(run => run.text).join('')
} catch (err) {
console.error('Failed to extract localised video title, falling back to the standard one.', err)
// if the workaround for localization fails, this sets the title to the potentially non-localized value
this.videoTitle = result.videoDetails.title
}
this.videoViewCount = parseInt(
result.player_response.videoDetails.viewCount,
10
)
if ('id' in result.videoDetails.author) {
this.channelId = result.player_response.videoDetails.channelId
this.channelName = result.videoDetails.author.name
if (result.videoDetails.author.thumbnails.length > 0) {
this.channelThumbnail = result.videoDetails.author.thumbnails[0].url
}
} else {
this.channelId = result.player_response.videoDetails.channelId
this.channelName = result.player_response.videoDetails.author
this.channelThumbnail = result.player_response.embedPreview.thumbnailPreviewRenderer.videoDetails.embeddedPlayerOverlayVideoDetailsRenderer.channelThumbnail.thumbnails[0].url
}
this.updateSubscriptionDetails({
channelThumbnailUrl: this.channelThumbnail,
channelName: this.channelName,
channelId: this.channelId
})
this.videoPublished = new Date(result.videoDetails.publishDate.replace('-', '/')).getTime()
try {
// workaround for description localization
const descriptionRuns = result.response.contents.twoColumnWatchNextResults.results.results.contents[1].videoSecondaryInfoRenderer.description?.runs
if (!Array.isArray(descriptionRuns)) {
// eslint-disable-next-line no-throw-literal
throw ['not an array', descriptionRuns]
}
const fallbackDescription = result.player_response.videoDetails.shortDescription
// YouTube truncates links in the localised description
// so we need to fix them here, so that autolinker can do it's job properly later on
this.videoDescription = descriptionRuns
.map(run => this.processDescriptionPart(run, fallbackDescription))
.join('')
} catch (err) {
console.error('Failed to extract localised video description, falling back to the standard one.', err)
// if the workaround for localization fails, this sets the description to the potentially non-localized value
this.videoDescription = result.player_response.videoDetails.shortDescription
}
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.videoDetails.thumbnails[result.videoDetails.thumbnails.length - 1].url
break
}
this.isFamilyFriendly = result.videoDetails.isFamilySafe
this.recommendedVideos = result.related_videos.map((video) => {
video.videoId = video.id
video.authorId = video.author.id
video.viewCount = video.view_count
video.lengthSeconds = video.length_seconds
video.author = video.author.name
video.publishedText = video.published
return video
})
if (this.hideVideoLikesAndDislikes) {
this.videoLikeCount = null
this.videoDislikeCount = null
} else {
this.videoLikeCount = isNaN(result.videoDetails.likes) ? 0 : result.videoDetails.likes
this.videoDislikeCount = isNaN(result.videoDetails.dislikes) ? 0 : result.videoDetails.dislikes
}
this.isLive = result.player_response.videoDetails.isLive
this.isLiveContent = result.player_response.videoDetails.isLiveContent
this.isUpcoming = result.player_response.videoDetails.isUpcoming ? result.player_response.videoDetails.isUpcoming : false
if (this.videoDislikeCount === null && !this.hideVideoLikesAndDislikes) {
this.videoDislikeCount = 0
}
const subCount = result.videoDetails.author.subscriber_count
if (typeof (subCount) !== 'undefined' && !this.hideChannelSubscriptions) {
if (subCount >= 10000) {
this.channelSubscriptionCountText = Intl.NumberFormat([this.currentLocale, 'en'], { notation: 'compact' }).format(subCount)
} else {
this.channelSubscriptionCountText = Intl.NumberFormat([this.currentLocale, 'en']).format(subCount)
}
}
const chapters = []
if (!this.hideChapters) {
const rawChapters = result.response.playerOverlays.playerOverlayRenderer.decoratedPlayerBarRenderer?.decoratedPlayerBarRenderer.playerBar?.multiMarkersPlayerBarRenderer.markersMap?.find(m => m.key === 'DESCRIPTION_CHAPTERS')?.value.chapters
if (rawChapters) {
for (const { chapterRenderer } of rawChapters) {
const start = chapterRenderer.timeRangeStartMillis / 1000
chapters.push({
title: chapterRenderer.title.simpleText,
timestamp: formatDurationAsTimestamp(start),
startSeconds: start,
endSeconds: 0,
thumbnail: chapterRenderer.thumbnail.thumbnails[0].url
})
}
this.addChaptersEndSeconds(chapters, result.videoDetails.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)
}
}
// only set this at the end so that there is only a single update to the view
this.videoChapters = chapters
if ((this.isLive && this.isLiveContent) && !this.isUpcoming) {
this.enableLegacyFormat()
this.videoSourceList = result.formats.filter((format) => {
if (typeof (format.mimeType) !== 'undefined') {
return format.mimeType.includes('video/ts')
}
return format.itag === 300 || format.itag === 301
}).map((format) => {
let qualityLabel
if (format.itag === 300) {
qualityLabel = '720p'
} else if (format.itag === 301) {
qualityLabel = '1080p'
} else {
qualityLabel = format.qualityLabel
}
return {
url: format.url,
type: 'application/x-mpegURL',
label: 'Dash',
qualityLabel: qualityLabel
}
}).sort((a, b) => {
const qualityA = parseInt(a.qualityLabel.replace('p', ''))
const qualityB = parseInt(b.qualityLabel.replace('p', ''))
return qualityA - qualityB
}).reverse()
if (this.videoSourceList.length === 0) {
this.activeSourceList = result.player_response.streamingData.formats
} else {
this.activeSourceList = this.videoSourceList
}
} else if (this.isUpcoming) {
const startTimestamp = result.videoDetails.liveBroadcastDetails.startTimestamp
if (typeof startTimestamp !== 'undefined') {
const upcomingTimestamp = new Date(result.videoDetails.liveBroadcastDetails.startTimestamp)
const timestampOptions = {
month: 'long',
day: 'numeric',
hour: 'numeric',
minute: '2-digit'
}
if (new Date().getFullYear() < upcomingTimestamp.getFullYear()) {
Object.defineProperty(timestampOptions, 'year', {
value: 'numeric'
})
}
this.upcomingTimestamp = Intl.DateTimeFormat(this.currentLocale, timestampOptions).format(upcomingTimestamp)
let upcomingTimeLeft = upcomingTimestamp - new Date()
// 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 = upcomingTimeLeft / 60
timeUnit = 'hour'
}
if (timeUnit === 'hour' && upcomingTimeLeft > 24) {
upcomingTimeLeft = 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 = parseInt(result.videoDetails.lengthSeconds)
if (result.player_response.streamingData !== undefined) {
if (typeof (result.player_response.streamingData.formats) !== 'undefined') {
this.videoSourceList = result.player_response.streamingData.formats.reverse()
} else {
this.videoSourceList = result.player_response.streamingData.adaptiveFormats.reverse()
}
this.adaptiveFormats = this.videoSourceList
this.downloadLinks = result.formats.filter((format) => {
return typeof format.mimeType !== 'undefined'
}).map((format) => {
const qualityLabel = format.qualityLabel || format.bitrate
const itag = format.itag
const fps = format.fps ? (format.fps + 'fps') : 'kbps'
const type = format.mimeType.match(/.*;/)[0].replace(';', '')
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
})
const captionTracks =
result.player_response.captions &&
result.player_response.captions.playerCaptionsTracklistRenderer
.captionTracks
if (typeof captionTracks !== 'undefined') {
const locale = this.$i18n.locale
if (locale !== null) {
const standardLocale = locale.replace('_', '-')
const noLocaleCaption = !captionTracks.some(track =>
track.languageCode === standardLocale && track.kind !== 'asr'
)
if (!standardLocale.startsWith('en') && noLocaleCaption) {
captionTracks.forEach((caption) => {
this.tryAddingTranslatedLocaleCaption(captionTracks, standardLocale, caption.baseUrl)
})
}
}
this.captionHybridList = this.createCaptionPromiseList(captionTracks)
const captionLinks = captionTracks.map((caption) => {
const label = `${caption.name.simpleText} (${caption.languageCode}) - text/vtt`
return {
url: caption.baseUrl,
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 (typeof result.player_response.streamingData.adaptiveFormats !== 'undefined') {
const adaptiveFormats = result.player_response.streamingData.adaptiveFormats
this.adaptiveFormats = adaptiveFormats
if (this.proxyVideos) {
this.dashSrc = await this.createInvidiousDashManifest()
} else {
this.dashSrc = await this.createLocalDashManifest(adaptiveFormats)
}
this.audioSourceList = result.player_response.streamingData.adaptiveFormats.filter((format) => {
return format.mimeType.includes('audio')
}).sort((a, b) => {
return a.bitrate - b.bitrate
}).map((format, index) => {
const label = (x) => {
switch (x) {
case 0:
return this.$t('Video.Audio.Low')
case 1:
return this.$t('Video.Audio.Medium')
case 2:
return this.$t('Video.Audio.High')
case 3:
return this.$t('Video.Audio.Best')
default:
return format.bitrate
}
}
return {
url: format.url,
type: format.mimeType,
label: 'Audio',
qualityLabel: label(index)
}
}).reverse()
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 (typeof result.player_response.storyboards !== 'undefined') {
const templateUrl = result.player_response.storyboards.playerStoryboardSpecRenderer.spec
await this.createLocalStoryboardUrls(templateUrl)
}
}
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'))
this.getVideoInformationInvidious()
} else {
this.isLoading = false
}
})
},
getVideoInformationInvidious: function () {
if (this.firstLoad) {
this.isLoading = true
}
this.dashSrc = this.createInvidiousDashManifest()
this.videoStoryboardSrc = `${this.currentInvidiousInstance}/api/v1/storyboards/${this.videoId}?height=90`
this.invidiousGetVideoInformation(this.videoId)
.then(result => {
if (result.error) {
throw new Error(result.error)
}
this.videoTitle = result.title
this.videoViewCount = result.viewCount
if (this.hideVideoLikesAndDislikes) {
this.videoLikeCount = null
this.videoDislikeCount = null
} else {
this.videoLikeCount = result.likeCount
this.videoDislikeCount = result.dislikeCount
}
if (this.hideChannelSubscriptions) {
this.channelSubscriptionCountText = ''
} else {
this.channelSubscriptionCountText = result.subCountText || 'FT-0'
}
this.channelId = result.authorId
this.channelName = result.author
const channelThumb = result.authorThumbnails[1]
this.channelThumbnail = channelThumb ? channelThumb.url.replace('https://yt3.ggpht.com', `${this.currentInvidiousInstance}/ggpht/`) : ''
this.updateSubscriptionDetails({
channelThumbnailUrl: channelThumb?.url,
channelName: result.author,
channelId: result.authorId
})
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.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
}
const chapters = []
if (!this.hideChapters) {
// 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 = result.description.matchAll(/^(?((?[0-9]+):)?(?[0-9]+):(?[0-9]+))(\s*[-–—]\s*(?:[0-9]+:)?[0-9]+:[0-9]+)?\s+([-–•—]\s*)?(?.+)$/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
})
}
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.showLegacyPlayer = true
this.showDashPlayer = false
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.match(/.*;/)[0].replace(';', '')
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.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 (this.backendPreference === 'invidious' && this.backendFallback) {
showToast(this.$t('Falling back to Local API'))
this.getVideoInformationLocal()
} else {
this.isLoading = false
}
})
},
processDescriptionPart(part, fallbackDescription) {
const timestampRegex = /^([0-9]+:)?[0-9]+:[0-9]+$/
if (typeof part.navigationEndpoint === 'undefined' || part.navigationEndpoint === null || part.text.startsWith('#')) {
return part.text
}
if (part.navigationEndpoint.urlEndpoint) {
const urlWithTracking = part.navigationEndpoint.urlEndpoint.url
const url = new URL(urlWithTracking)
if (url.hostname === 'www.youtube.com' && url.pathname === '/redirect' && url.searchParams.has('q')) {
// remove utm tracking parameters
const realURL = new URL(url.searchParams.get('q'))
realURL.searchParams.delete('utm_source')
realURL.searchParams.delete('utm_medium')
realURL.searchParams.delete('utm_campaign')
realURL.searchParams.delete('utm_term')
realURL.searchParams.delete('utm_content')
return realURL.toString()
} else if (fallbackDescription.includes(urlWithTracking)) {
// this is probably a special YouTube URL like http://www.youtube.com/approachingnirvana
// only use it if it exists in the fallback description
// otherwise assume YouTube has changed it's tracking URLs and throw an error
return urlWithTracking
}
// eslint-disable-next-line no-throw-literal
throw `Failed to extract real URL from tracking URL: ${urlWithTracking}`
} else if (part.navigationEndpoint.watchEndpoint) {
if (timestampRegex.test(part.text)) {
return part.text
}
const watchEndpoint = part.navigationEndpoint.watchEndpoint
let videoURL = `https://www.youtube.com/watch?v=${watchEndpoint.videoId}`
if (watchEndpoint.startTimeSeconds !== 0) {
videoURL += `&t=${watchEndpoint.startTimeSeconds}s`
}
return videoURL
} else {
// Some YouTube URLs don't have the urlEndpoint so we handle them here
const { browseEndpoint, commandMetadata: { webCommandMetadata } } = part.navigationEndpoint
// channel handle
if (webCommandMetadata.webPageType === 'WEB_PAGE_TYPE_CHANNEL' && part.text.startsWith('@')) {
return `${part.text}`
} else {
const path = webCommandMetadata.url
return `https://www.youtube.com${path}`
}
}
},
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
}
}
}
},
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,
paid: 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)
}
}
},
checkIfWatched: function () {
const historyIndex = this.historyCache.findIndex((video) => {
return video.videoId === this.videoId
})
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 (historyIndex !== -1) {
const watchProgress = this.historyCache[historyIndex].watchProgress
if (watchProgress < (this.videoLengthSeconds - 10)) {
this.$refs.videoPlayer.player.currentTime(watchProgress)
}
}
}
if (this.rememberHistory) {
if (this.timestamp) {
this.addToHistory(this.timestamp)
} else if (historyIndex !== -1) {
this.addToHistory(this.historyCache[historyIndex].watchProgress)
} else {
this.addToHistory(0)
}
}
},
checkIfPlaylist: function () {
if (typeof (this.$route.query) !== 'undefined') {
this.playlistId = this.$route.query.playlistId
if (typeof (this.playlistId) !== 'undefined') {
this.watchingPlaylist = true
} else {
this.watchingPlaylist = false
}
} else {
this.watchingPlaylist = false
}
},
checkIfTimestamp: function () {
if (typeof (this.$route.query) !== 'undefined') {
try {
this.timestamp = parseInt(this.$route.query.timestamp)
} catch {
this.timestamp = null
}
}
},
getLegacyFormats: function () {
this.ytGetVideoInformation(this.videoId)
.then(result => {
this.videoSourceList = result.player_response.streamingData.formats
})
.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()
}
})
},
enableDashFormat: function () {
if (this.activeFormat === 'dash' || this.isLive) {
return
}
if (this.dashSrc === null) {
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
}
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 {
const nextVideoId = this.recommendedVideos[0].videoId
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: async function (videoId) {
// if the user navigates to another video, the ipc call for the userdata path
// takes long enough for the video id to have already changed to the new one
// 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.removeVideoMetaFiles) {
if (process.env.NODE_ENV === 'development') {
const dashFileLocation = `static/dashFiles/${videoId}.xml`
const vttFileLocation = `static/storyboards/${videoId}.vtt`
// only delete the file it actually exists
if (await pathExists(dashFileLocation)) {
await fs.rm(dashFileLocation)
}
if (await pathExists(vttFileLocation)) {
await fs.rm(vttFileLocation)
}
} else {
const userData = await getUserDataPath()
const dashFileLocation = `${userData}/dashFiles/${videoId}.xml`
const vttFileLocation = `${userData}/storyboards/${videoId}.vtt`
if (await pathExists(dashFileLocation)) {
await fs.rm(dashFileLocation)
}
if (await pathExists(vttFileLocation)) {
await fs.rm(vttFileLocation)
}
}
}
},
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()
}
}
},
createLocalDashManifest: async function (formats) {
const xmlData = ytDashGen.generate_dash_file_from_formats(formats, this.videoLengthSeconds)
const userData = await getUserDataPath()
let fileLocation
let uriSchema
if (process.env.NODE_ENV === 'development') {
fileLocation = `static/dashFiles/${this.videoId}.xml`
uriSchema = `dashFiles/${this.videoId}.xml`
// if the location does not exist, writeFileSync will not create the directory, so we have to do that manually
if (!(await pathExists('static/dashFiles/'))) {
await fs.mkdir('static/dashFiles/')
}
if (await pathExists(fileLocation)) {
await fs.rm(fileLocation)
}
await fs.writeFile(fileLocation, xmlData)
} else {
fileLocation = `${userData}/dashFiles/${this.videoId}.xml`
uriSchema = `file://${fileLocation}`
if (!(await pathExists(`${userData}/dashFiles/`))) {
await fs.mkdir(`${userData}/dashFiles/`)
}
await fs.writeFile(fileLocation, xmlData)
}
return [
{
url: uriSchema,
type: 'application/dash+xml',
label: 'Dash',
qualityLabel: 'Auto'
}
]
},
createInvidiousDashManifest: function () {
let url = `${this.currentInvidiousInstance}/api/manifest/dash/id/${this.videoId}`
if (this.proxyVideos || !process.env.IS_ELECTRON) {
url += '?local=true'
}
return [
{
url: url,
type: 'application/dash+xml',
label: 'Dash',
qualityLabel: 'Auto'
}
]
},
createLocalStoryboardUrls: async function (templateUrl) {
const storyboards = templateUrl.split('|')
const storyboardArray = []
// Second storyboard: L1/M0 - Third storyboard: L2/M0 - Fourth: L3/M0
const baseUrl = storyboards.shift()
// remove the first link because it does not work
storyboards.splice(0, 1)
storyboards.forEach((storyboard, i) => {
// Not sure why the _ variable is needed, but storyboards don't work unless we initialize it.
/* eslint-disable-next-line */
const [width, height, count, sWidth, sHeight, interval, _, sigh] = storyboard.split('#')
storyboardArray.push({
url: baseUrl.replace('$L', i + 1).replace('$N', 'M0').replace(/<\/?sub>/g, '') + '&sigh=' + sigh,
width: Number(width), // Width of one sub image
height: Number(height), // Height of one sub image
sWidth: Number(sWidth), // Number of images vertically (if full)
sHeight: Number(sHeight), // Number of images horizontally (if full)
count: Number(count), // Number of images total
interval: Number(interval) // How long one image is used
})
})
// TODO: MAKE A VARIABLE WHICH CAN CHOOSE BETWEEN STORYBOARD ARRAY ELEMENTS
const results = buildVTTFileLocally(storyboardArray[1])
const userData = await getUserDataPath()
let fileLocation
let uriSchema
// Dev mode doesn't have access to the file:// schema, so we access
// storyboards differently when run in dev
if (process.env.NODE_ENV === 'development') {
fileLocation = `static/storyboards/${this.videoId}.vtt`
uriSchema = `storyboards/${this.videoId}.vtt`
// if the location does not exist, writeFile will not create the directory, so we have to do that manually
if (!(await pathExists('static/storyboards/'))) {
fs.mkdir('static/storyboards/')
} else if (await pathExists(fileLocation)) {
await fs.rm(fileLocation)
}
await fs.writeFile(fileLocation, results)
} else {
if (!(await pathExists(`${userData}/storyboards/`))) {
await fs.mkdir(`${userData}/storyboards/`)
}
fileLocation = `${userData}/storyboards/${this.videoId}.vtt`
uriSchema = `file://${fileLocation}`
await fs.writeFile(fileLocation, results)
}
this.videoStoryboardSrc = uriSchema
},
tryAddingTranslatedLocaleCaption: function (captionTracks, locale, baseUrl) {
const enCaptionIdx = captionTracks.findIndex(track =>
track.languageCode === '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.name.simpleText === label
})
if (indexTranslated !== -1) {
return
}
if (enCaptionExists) {
url = new URL(captionTracks[enCaptionIdx].baseUrl)
} else {
url = new URL(baseUrl)
url.searchParams.set('lang', 'en')
url.searchParams.set('kind', 'asr')
}
url.searchParams.set('tlang', locale)
captionTracks.unshift({
baseUrl: url.toString(),
name: { simpleText: label },
languageCode: locale
})
}
},
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.baseUrl)
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.replace(/#/g, '%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.replace(/ align:start| position:\d{1,3}%/g, '')
} else {
vtt = vtt.replace(/ position:\d{1,3}%/g, '')
}
caption.baseUrl = `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.currentVideoIndex
: this.$refs.watchVideoPlaylist.currentVideoIndex - 1
: -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
},
updateTitle: function () {
document.title = `${this.videoTitle} - FreeTube`
},
...mapActions([
'updateHistory',
'updateWatchProgress',
'ytGetVideoInformation',
'invidiousGetVideoInformation',
'updateSubscriptionDetails'
])
}
})