Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,10 @@ export default defineComponent({
type: Number,
default: 0
},
chaptersSrc: {
type: String,
default: ''
},
storyboardSrc: {
type: String,
default: ''
Expand Down Expand Up @@ -786,22 +790,18 @@ export default defineComponent({

const uiConfig = computed(() => {
const controlPanelElements = [
'ft_skip_previous',
'play_pause',
'ft_skip_next',
'mute',
'volume',
'time_and_duration',
'spacer'
]
const controlPanelElementsWithSkipButtons = [
...controlPanelElements.slice(0, 1),
'ft_skip_previous',
'ft_skip_next',
...controlPanelElements.slice(1)
]

/** @type {shaka.extern.UIConfiguration} */
const uiConfig = {
controlPanelElements: props.watchingPlaylist ? controlPanelElementsWithSkipButtons : controlPanelElements,
controlPanelElements: controlPanelElements,
overflowMenuButtons: [],

// only set this to label when we actually have labels, so that the warning doesn't show up
Expand All @@ -823,6 +823,7 @@ export default defineComponent({
'playback_rate',
'captions',
'ft_audio_tracks',
'chapter',
'loop',
'ft_screenshot',
'picture_in_picture',
Expand Down Expand Up @@ -850,6 +851,7 @@ export default defineComponent({
'captions',
'playback_rate',
props.format === 'legacy' ? 'ft_legacy_quality' : 'quality',
'chapter',
'loop',
'recenter_vr',
'toggle_stereoscopic',
Expand Down Expand Up @@ -883,8 +885,18 @@ export default defineComponent({
removeFromArrayIfExists(uiConfig.overflowMenuButtons, 'toggle_stereoscopic')
}

if (!props.watchingPlaylist) {
removeFromArrayIfExists(uiConfig.controlPanelElements, 'ft_skip_previous')
removeFromArrayIfExists(uiConfig.controlPanelElements, 'ft_skip_next')
}

if (props.chapters.length === 0) {
removeFromArrayIfExists(uiConfig.overflowMenuButtons, 'chapter')
}

return uiConfig
})
globalThis.FakeEvent = shaka.util.FakeEvent

/**
* For the first call we want to set initial values for options that may change later,
Expand All @@ -903,6 +915,11 @@ export default defineComponent({
contextMenuElements: ['ft_stats'],
enableTooltips: true,
seekBarColors: {
// shaka-player's ones are blurry in FreeTube on longer videos as it uses a linear-gradient
// but still tries to have the lines be fixed sizes which I suspect causes rounding issues.
// Stick with ours for the moment which use the less performant
// but nicer looking approach of using a separate element for each chapter.
chapters: 'transparent',
played: 'var(--primary-color)'
},
showAudioCodec: false,
Expand Down Expand Up @@ -1303,6 +1320,12 @@ export default defineComponent({
null,
sabrAbortController.signal,
)
if (props.chapters.length > 0) {
// Redispatch the chapters updated event to work around the backoff timer screwing with shaka-player's interal chaptersupdated event handling
setTimeout(() => {
ui?.getControls().dispatchEvent(new shaka.util.FakeEvent('chaptersupdated'))
}, backoffMs)
}
}, 1000))
sabrStream.onReloadOnce(() => {
sabrAbortController.abort()
Expand Down Expand Up @@ -2879,7 +2902,7 @@ export default defineComponent({
sabrManifest = player.getManifest()
}

// For SABR we include the thumbnails and subtitles in the manifest
// For SABR we include the thumbnails, chapters and subtitles in the manifest
if (!process.env.SUPPORTS_LOCAL_API || props.format === 'legacy' || props.manifestMimeType !== MANIFEST_TYPE_SABR) {
const promises = []

Expand Down Expand Up @@ -2952,6 +2975,15 @@ export default defineComponent({
)
}

if (!isLive.value && props.chaptersSrc.length > 0) {
promises.push(
// Only log the error, as the chapters are a nice to have (we have our own UI outside of the player too)
// If an error occurs with them, it is not critical
player.addChaptersTrack(props.chaptersSrc, 'und', 'text/vtt')
.catch(error => logShakaError(error, 'addChaptersTrack', props.videoId, props.chaptersSrc))
)
}

await Promise.all(promises)
}

Expand Down
93 changes: 92 additions & 1 deletion src/renderer/helpers/player/SabrManifestParser.js
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,16 @@ import { parseMp4SegmentIndex } from './Mp4SegmentIndexParser'
* thumbnailHeight: number,
* storyboardCount: number,
* interval: number
* }[],
* chapters: {
* title: string,
* startSeconds: number,
* endSeconds: number,
* thumbnail?: {
* url: string,
* width: number,
* height: number
* }
* }[]
* }} SabrManifest
*/
Expand Down Expand Up @@ -237,6 +247,9 @@ class SabrManifestParser {
currentId += textStreams.length

const imageStreams = /** @__NOINLINE__ */ createImageStreams(manifestData.storyboards, presentationTimeline, currentId)
currentId += imageStreams.length

const chapterStreams = /** @__NOINLINE__ */ createChapterStreams(manifestData.chapters, currentId)

/** @type {shaka.extern.Manifest} */
const manifest = {
Expand All @@ -245,7 +258,7 @@ class SabrManifestParser {
variants,
textStreams,
imageStreams,
chapterStreams: [],
chapterStreams,
presentationTimeline,

gapCount: 0,
Expand Down Expand Up @@ -612,6 +625,84 @@ function createImageStreams(storyboards, presentationTimeline, currentId) {
})
}

/**
* @param {SabrManifest['chapters']} chapters
* @param {number} currentId
*/
function createChapterStreams(chapters, currentId) {
if (chapters.length === 0) {
return []
}

/** @type {shaka.media.SegmentReference[]} */
const references = []

for (const chapter of chapters) {
const reference = new shaka.media.SegmentReference(
chapter.startSeconds,
chapter.endSeconds,
() => [],
/* startByte= */ 0,
/* endByte= */ null,
/* initSegmentReference= */ null,
/* timestampOffset= */ 0,
/* appendWindowStart= */ 0,
/* appendWindowEnd= */ Infinity,
)

reference.setMetadata({
title: chapter.title,
images: chapter.thumbnail
? [{
url: chapter.thumbnail.url,
width: chapter.thumbnail.width,
height: chapter.thumbnail.height,
}]
: []
})

references.push(reference)
}

/** @type {shaka.extern.Stream} */
const stream = {
id: currentId,
originalId: null,
groupId: null,
createSegmentIndex: () => Promise.resolve(),
segmentIndex: new shaka.media.SegmentIndex(references),
mimeType: 'text/plain',
codecs: '',
supplementalCodecs: '',
kind: '',
encrypted: false,
drmInfos: [],
keyIds: new Set(),
language: 'und',
originalLanguage: 'und',
label: null,
type: 'chapter',
primary: false,
trickModeVideo: null,
dependencyStream: null,
emsgSchemeIdUris: null,
roles: [],
forced: false,
channelsCount: null,
audioSamplingRate: null,
spatialAudio: false,
closedCaptions: null,
accessibilityPurpose: null,
external: true,
fastSwitching: false,
fullMimeTypes: new Set(['text/plain']),
isAudioMuxedInVideo: false,
baseOriginalId: null
}

return [stream]
}

/**
* @param {SabrManifest['formats'][0]} format
* @param {shaka.extern.Stream} stream
Expand Down
30 changes: 30 additions & 0 deletions src/renderer/helpers/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,36 @@ export function buildVTTFileLocally(storyboard, videoLengthSeconds) {
return vttString
}

/**
* @param {{ startSeconds: number, endSeconds: number, title: string }[]} chapters
*/
export function buildChaptersVttFile(chapters) {
const blocks = ['WEBVTT']

for (const chapter of chapters) {
blocks.push(`\
${secondsToVttTimestamp(chapter.startSeconds)} --> ${secondsToVttTimestamp(chapter.endSeconds)}
${chapter.title.trim()}`)
}

return blocks.join('\n\n') + '\n'
}

/**
* @param {number} seconds
*/
function secondsToVttTimestamp(seconds) {
const formattedHours = Math.trunc(seconds / 3600).toFixed(0).padStart(2, '0')
seconds %= 3600

const formattedMinutes = Math.trunc(seconds / 60).toFixed(0).padStart(2, '0')
seconds %= 60

const formattedSeconds = seconds.toFixed(3).padStart(6, '0')

return `${formattedHours}:${formattedMinutes}:${formattedSeconds}`
}

export const ToastEventBus = new EventTarget()

/**
Expand Down
20 changes: 16 additions & 4 deletions src/renderer/views/Watch/Watch.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import WatchVideoPlaylist from '../../components/WatchVideoPlaylist/WatchVideoPl
import WatchVideoRecommendations from '../../components/WatchVideoRecommendations/WatchVideoRecommendations.vue'
import FtAgeRestricted from '../../components/FtAgeRestricted/FtAgeRestricted.vue'
import {
buildChaptersVttFile,
buildVTTFileLocally,
copyToClipboard,
extractNumberFromString,
Expand Down Expand Up @@ -313,6 +314,16 @@ export default defineComponent({
// `this.$refs.player?.hasLoaded` cannot be used in computed property
return !this.isLoading
},

chaptersSrc() {
if (this.videoChapters.length > 0) {
const vttText = buildChaptersVttFile(this.videoChapters)

return `data:text/vtt,${encodeURIComponent(vttText)}`
} else {
return ''
}
}
},
watch: {
async $route() {
Expand Down Expand Up @@ -531,6 +542,7 @@ export default defineComponent({
}

let chapters = []
let chaptersKind = 'chapters'
if (!this.hideChapters) {
const rawChapters = result.player_overlays?.decorated_player_bar?.player_bar?.markers_map
?.find(marker => marker.marker_key === 'DESCRIPTION_CHAPTERS')?.value.chapters
Expand Down Expand Up @@ -564,7 +576,7 @@ export default defineComponent({
})
}
}
this.videoChaptersKind = 'keyMoments'
chaptersKind = 'keyMoments'
} else {
chapters = this.extractChaptersFromDescription(result.basic_info.short_description ?? result.secondary_info.description.text)
}
Expand All @@ -582,6 +594,7 @@ export default defineComponent({
}

this.videoChapters = chapters
this.videoChaptersKind = chaptersKind

const playabilityStatus = result.playability_status
this.playabilityStatus = playabilityStatus.status
Expand Down Expand Up @@ -978,6 +991,7 @@ export default defineComponent({
}
}
this.videoChapters = chapters
this.videoChaptersKind = 'chapters'

if (this.isLive || this.isPostLiveDvr) {
// The live DASH manifest is currently unusable as it returns 403s after 1 minute of playback
Expand Down Expand Up @@ -1446,9 +1460,6 @@ export default defineComponent({

handleRouteChange: function () {
this.abortAutoplayCountdown(true)
this.videoChapters = []
this.videoChaptersKind = 'chapters'

this.handleWatchProgressAutoSave()
},

Expand Down Expand Up @@ -1596,6 +1607,7 @@ export default defineComponent({
colorPrimaries: format.color_info?.primaries
})),
captions: this.captions,
chapters: this.videoChapters,
storyboards
}

Expand Down
1 change: 1 addition & 0 deletions src/renderer/views/Watch/Watch.vue
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
:video-id="videoId"
:chapters="videoChapters"
:current-chapter-index="videoCurrentChapterIndex"
:chapters-src="chaptersSrc"
:title="videoTitle"
:theatre-possible="theatrePossible"
:use-theatre-mode="useTheatreMode"
Expand Down
Loading