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..cf9927fa8acdb 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 @@ -903,7 +903,10 @@ export default defineComponent({ contextMenuElements: ['ft_stats'], enableTooltips: true, seekBarColors: { - played: 'var(--primary-color)' + played: 'var(--primary-color)', + // Shaka's native chapter marker gradient renders blurry on + // Chromium, so FreeTube keeps drawing the visual markers itself. + chapters: 'transparent' }, showAudioCodec: false, showVideoCodec: false, @@ -2586,6 +2589,10 @@ export default defineComponent({ const { start, end } = player.seekRange() const duration = end - start + if (!Number.isFinite(duration) || duration <= 0) { + return + } + /** * @type {{ * title: string, @@ -2598,41 +2605,115 @@ export default defineComponent({ const chapters = props.chapters addMarkers( - chapters.map(chapter => { + chapters.flatMap(chapter => { + if (!Number.isFinite(chapter.startSeconds)) { + return [] + } + + const startFraction = (chapter.startSeconds - start) / duration + + if (startFraction < 0 || startFraction > 1) { + return [] + } + const markerDiv = document.createElement('div') markerDiv.title = chapter.title markerDiv.className = 'chapterMarker' - markerDiv.style.left = `calc(${(chapter.startSeconds / duration) * 100}% - 1px)` + markerDiv.style.left = `calc(${startFraction * 100}% - 1px)` - return markerDiv - }) + return [markerDiv] + }), + 'chapterMarker' ) } /** * @param {HTMLDivElement[]} markers + * @param {string} [markerClassName] */ - function addMarkers(markers) { + function addMarkers(markers, markerClassName) { const seekBarContainer = container.value.querySelector('.shaka-seek-bar-container') - if (seekBarContainer.firstElementChild?.classList.contains('markerContainer')) { - /** @type {HTMLDivElement} */ - const markerBar = seekBarContainer.firstElementChild + /** @type {HTMLDivElement} */ + let markerBar - markers.forEach(marker => markerBar.appendChild(marker)) + if (seekBarContainer.firstElementChild?.classList.contains('markerContainer')) { + markerBar = seekBarContainer.firstElementChild } else { - const markerBar = document.createElement('div') + markerBar = document.createElement('div') markerBar.className = 'markerContainer' - markers.forEach(marker => markerBar.appendChild(marker)) - seekBarContainer.insertBefore(markerBar, seekBarContainer.firstElementChild) } + + if (markerClassName) { + markerBar.querySelectorAll(`.${markerClassName}`).forEach(marker => marker.remove()) + } + + markers.forEach(marker => markerBar.appendChild(marker)) } // #endregion seek bar markers + // #region chapters + + /** + * @param {number} seconds + * @returns {string} + */ + function formatSecondsForVtt(seconds) { + const milliseconds = Math.round(seconds * 1000) + const hours = Math.floor(milliseconds / 3_600_000) + const minutes = Math.floor((milliseconds % 3_600_000) / 60_000) + const wholeSeconds = Math.floor((milliseconds % 60_000) / 1000) + const millisecondsRemainder = milliseconds % 1000 + + return [ + hours.toString().padStart(2, '0'), + minutes.toString().padStart(2, '0'), + wholeSeconds.toString().padStart(2, '0') + ].join(':') + `.${millisecondsRemainder.toString().padStart(3, '0')}` + } + + /** + * @returns {string|null} + */ + function createChaptersVttUrl() { + let vtt = 'WEBVTT\n\n' + let hasValidChapter = false + + for (const chapter of props.chapters) { + if ( + !Number.isFinite(chapter.startSeconds) || + !Number.isFinite(chapter.endSeconds) || + chapter.endSeconds <= chapter.startSeconds + ) { + continue + } + + const title = String(chapter.title ?? '') + .replaceAll(/\s+/g, ' ') + .trim() + + if (title.length === 0) { + continue + } + + hasValidChapter = true + + vtt += [ + `${formatSecondsForVtt(chapter.startSeconds)} --> ${formatSecondsForVtt(chapter.endSeconds)}`, + title, + '' + ].join('\n') + '\n' + } + + return hasValidChapter ? `data:text/vtt;charset=utf-8,${encodeURIComponent(vtt)}` : null + } + + // #endregion chapters + // #region offline message const isOffline = ref(!navigator.onLine) @@ -2955,6 +3036,22 @@ export default defineComponent({ await Promise.all(promises) } + if (!isLive.value && props.chapters.length > 0) { + const chaptersVttUrl = createChaptersVttUrl() + + if (chaptersVttUrl !== null) { + try { + await player.addChaptersTrack(chaptersVttUrl, 'und', 'text/vtt') + } catch (error) { + logShakaError(error, 'addChaptersTrack', props.videoId, props.chapters) + } + } + } + + if (!isLive.value && props.chapters.length > 0) { + createChapterMarkers() + } + if (restoreCaptionIndex !== null) { const index = restoreCaptionIndex restoreCaptionIndex = null @@ -2966,10 +3063,6 @@ export default defineComponent({ } } - if (props.chapters.length > 0) { - createChapterMarkers() - } - if (startInFullscreen && process.env.IS_ELECTRON) { startInFullscreen = false window.ftElectron.requestFullscreen()