diff --git a/src/renderer/components/ft-shaka-video-player/ft-shaka-video-player.js b/src/renderer/components/ft-shaka-video-player/ft-shaka-video-player.js index 02ee2146bf928..f511e1257d8fe 100644 --- a/src/renderer/components/ft-shaka-video-player/ft-shaka-video-player.js +++ b/src/renderer/components/ft-shaka-video-player/ft-shaka-video-player.js @@ -102,6 +102,10 @@ export default defineComponent({ type: Number, default: 0 }, + chaptersSrc: { + type: String, + default: '' + }, storyboardSrc: { type: String, default: '' @@ -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 @@ -823,6 +823,7 @@ export default defineComponent({ 'playback_rate', 'captions', 'ft_audio_tracks', + 'chapter', 'loop', 'ft_screenshot', 'picture_in_picture', @@ -850,6 +851,7 @@ export default defineComponent({ 'captions', 'playback_rate', props.format === 'legacy' ? 'ft_legacy_quality' : 'quality', + 'chapter', 'loop', 'recenter_vr', 'toggle_stereoscopic', @@ -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, @@ -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, @@ -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() @@ -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 = [] @@ -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) } diff --git a/src/renderer/helpers/player/SabrManifestParser.js b/src/renderer/helpers/player/SabrManifestParser.js index b032420f2ef79..e9661ae77b583 100644 --- a/src/renderer/helpers/player/SabrManifestParser.js +++ b/src/renderer/helpers/player/SabrManifestParser.js @@ -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 */ @@ -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 = { @@ -245,7 +258,7 @@ class SabrManifestParser { variants, textStreams, imageStreams, - chapterStreams: [], + chapterStreams, presentationTimeline, gapCount: 0, @@ -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 diff --git a/src/renderer/helpers/utils.js b/src/renderer/helpers/utils.js index 11963ec4bdb12..7c273466c1b8a 100644 --- a/src/renderer/helpers/utils.js +++ b/src/renderer/helpers/utils.js @@ -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() /** diff --git a/src/renderer/views/Watch/Watch.js b/src/renderer/views/Watch/Watch.js index 1f2458b9c91ef..104385eebe55a 100644 --- a/src/renderer/views/Watch/Watch.js +++ b/src/renderer/views/Watch/Watch.js @@ -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, @@ -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() { @@ -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 @@ -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) } @@ -582,6 +594,7 @@ export default defineComponent({ } this.videoChapters = chapters + this.videoChaptersKind = chaptersKind const playabilityStatus = result.playability_status this.playabilityStatus = playabilityStatus.status @@ -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 @@ -1446,9 +1460,6 @@ export default defineComponent({ handleRouteChange: function () { this.abortAutoplayCountdown(true) - this.videoChapters = [] - this.videoChaptersKind = 'chapters' - this.handleWatchProgressAutoSave() }, @@ -1596,6 +1607,7 @@ export default defineComponent({ colorPrimaries: format.color_info?.primaries })), captions: this.captions, + chapters: this.videoChapters, storyboards } diff --git a/src/renderer/views/Watch/Watch.vue b/src/renderer/views/Watch/Watch.vue index 7f3865f64d424..4b127bacd9962 100644 --- a/src/renderer/views/Watch/Watch.vue +++ b/src/renderer/views/Watch/Watch.vue @@ -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"