From bc886af7262093b2743116091ae1d23cca583f04 Mon Sep 17 00:00:00 2001
From: absidue <48293849+absidue@users.noreply.github.com>
Date: Thu, 29 Sep 2022 22:01:54 +0200
Subject: [PATCH] Implement chapters (#2224)
* Implement chapters
* Generate chapters locally for the Invidious API backend
* Performance improvements
* More performance improvements
* Improve chapters appearance and add compact mode for Invidious
* Update UI while seeking instead of afterwards
* Invidious extract chapters with range timestamps properly and duplicate chapters
* Minor code improvement
* Add accessibility labels and keyboard navigation
* Add chapter markers
* Fix missing newline at the bottom of ft-video-player.css
* Fix marker placement
---
.../distraction-settings.js | 11 +-
.../distraction-settings.vue | 6 +
.../ft-video-player/ft-video-player.css | 21 ++-
.../ft-video-player/ft-video-player.js | 27 +++-
.../watch-video-chapters.css | 83 +++++++++++
.../watch-video-chapters.js | 72 ++++++++++
.../watch-video-chapters.vue | 66 +++++++++
src/renderer/main.js | 2 +
src/renderer/store/modules/settings.js | 1 +
src/renderer/views/Watch/Watch.js | 134 ++++++++++++++++++
src/renderer/views/Watch/Watch.vue | 11 ++
static/locales/en-US.yaml | 6 +
12 files changed, 431 insertions(+), 9 deletions(-)
create mode 100644 src/renderer/components/watch-video-chapters/watch-video-chapters.css
create mode 100644 src/renderer/components/watch-video-chapters/watch-video-chapters.js
create mode 100644 src/renderer/components/watch-video-chapters/watch-video-chapters.vue
diff --git a/src/renderer/components/distraction-settings/distraction-settings.js b/src/renderer/components/distraction-settings/distraction-settings.js
index de6963ce..2aecd3c0 100644
--- a/src/renderer/components/distraction-settings/distraction-settings.js
+++ b/src/renderer/components/distraction-settings/distraction-settings.js
@@ -57,8 +57,14 @@ export default Vue.extend({
hideLiveStreams: function() {
return this.$store.getters.getHideLiveStreams
},
- hideSharingActions: function() {
+ hideSharingActions: function () {
return this.$store.getters.getHideSharingActions
+ },
+ backendPreference: function () {
+ return this.$store.getters.getBackendPreference
+ },
+ hideChapters: function () {
+ return this.$store.getters.getHideChapters
}
},
methods: {
@@ -86,7 +92,8 @@ export default Vue.extend({
'updateHideVideoDescription',
'updateHideComments',
'updateHideLiveStreams',
- 'updateHideSharingActions'
+ 'updateHideSharingActions',
+ 'updateHideChapters'
])
}
})
diff --git a/src/renderer/components/distraction-settings/distraction-settings.vue b/src/renderer/components/distraction-settings/distraction-settings.vue
index d6f56a07..724a5211 100644
--- a/src/renderer/components/distraction-settings/distraction-settings.vue
+++ b/src/renderer/components/distraction-settings/distraction-settings.vue
@@ -46,6 +46,12 @@
:default-value="hideSharingActions"
@change="updateHideSharingActions"
/>
+
{ return [] }
}
},
data: function () {
@@ -430,6 +434,10 @@ export default Vue.extend({
// https://github.com/videojs/video.js/blob/v7.13.3/docs/guides/components.md#default-component-tree
this.player.controlBar.progressControl.seekBar.playProgressBar.removeChild('timeTooltip')
+ if (this.chapters.length > 0) {
+ this.chapters.forEach(this.addChapterMarker)
+ }
+
if (this.useSponsorBlock) {
this.initializeSponsorBlock()
}
@@ -523,6 +531,7 @@ export default Vue.extend({
this.playerStats = this.player.tech({ IWillNotUseThisInPlugins: true }).vhs.stats
this.updateStatsContent()
}
+ this.$emit('timeupdate')
})
this.player.textTrackSettings.on('modalclose', (_) => {
@@ -621,16 +630,22 @@ export default Vue.extend({
},
addSponsorBlockMarker(marker) {
- const markerDiv = videojs.dom.createEl('div', {}, {})
+ const markerDiv = videojs.dom.createEl('div')
+ markerDiv.title = this.sponsorBlockTranslatedCategory(marker.category)
markerDiv.className = `sponsorBlockMarker main${this.sponsorSkips.categoryData[marker.category].color}`
- markerDiv.style.height = '100%'
- markerDiv.style.position = 'absolute'
- markerDiv.style.opacity = '0.6'
- markerDiv.style['background-color'] = marker.color
markerDiv.style.width = (marker.duration / this.lengthSeconds) * 100 + '%'
markerDiv.style.marginLeft = (marker.time / this.lengthSeconds) * 100 + '%'
- markerDiv.title = this.sponsorBlockTranslatedCategory(marker.category)
+
+ this.player.el().querySelector('.vjs-progress-holder').appendChild(markerDiv)
+ },
+
+ addChapterMarker(chapter) {
+ const markerDiv = videojs.dom.createEl('div')
+
+ markerDiv.title = chapter.title
+ markerDiv.className = 'chapterMarker'
+ markerDiv.style.marginLeft = (chapter.startSeconds / this.lengthSeconds) * 100 - 0.5 + '%'
this.player.el().querySelector('.vjs-progress-holder').appendChild(markerDiv)
},
diff --git a/src/renderer/components/watch-video-chapters/watch-video-chapters.css b/src/renderer/components/watch-video-chapters/watch-video-chapters.css
new file mode 100644
index 00000000..d95bde01
--- /dev/null
+++ b/src/renderer/components/watch-video-chapters/watch-video-chapters.css
@@ -0,0 +1,83 @@
+.videoChapters {
+ overflow-y: hidden;
+}
+
+.chaptersTitle {
+ margin-top: 10px;
+ margin-bottom: 0;
+ cursor: pointer;
+}
+
+.currentChapter {
+ font-size: 15px;
+}
+
+.chaptersWrapper {
+ margin-top: 15px;
+ max-height: 250px;
+ overflow-y: scroll;
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+}
+
+.chaptersWrapper.compact {
+ max-height: 200px;
+}
+
+.chaptersChevron {
+ vertical-align: middle;
+}
+
+.chaptersChevron.open {
+ margin-left: 4px;
+}
+
+.chapter {
+ display: grid;
+ grid-template-areas:
+ 'thumbnail title'
+ 'thumbnail timestamp';
+ grid-template-columns: auto 1fr;
+ grid-template-rows: min(auto, 2fr) 1fr;
+ column-gap: 10px;
+ justify-items: start;
+ cursor: pointer;
+ font-size: 15px;
+}
+
+.chaptersWrapper.compact .chapter {
+ display: flex;
+ flex-direction: row;
+}
+
+.chapterThumbnail {
+ grid-area: thumbnail;
+ width: 130px;
+ height: auto;
+ margin: 3px;
+}
+
+.chapter.current .chapterThumbnail {
+ border: solid 3px var(--accent-color);
+ margin: 0;
+}
+
+.chapterTitle {
+ grid-area: title;
+ align-self: center;
+ margin: 0;
+}
+
+.chapter.current .chapterTitle {
+ font-weight: bold;
+}
+
+.chapterTimestamp {
+ grid-area: timestamp;
+ align-self: flex-start;
+ padding: 3px 4px;
+ border-radius: 5px;
+ background-color: var(--accent-color);
+ color: var(--text-with-accent-color);
+}
diff --git a/src/renderer/components/watch-video-chapters/watch-video-chapters.js b/src/renderer/components/watch-video-chapters/watch-video-chapters.js
new file mode 100644
index 00000000..36a88bc4
--- /dev/null
+++ b/src/renderer/components/watch-video-chapters/watch-video-chapters.js
@@ -0,0 +1,72 @@
+import Vue from 'vue'
+import FtCard from '../ft-card/ft-card.vue'
+
+export default Vue.extend({
+ name: 'WatchVideoChapters',
+ components: {
+ 'ft-card': FtCard
+ },
+ props: {
+ compact: {
+ type: Boolean,
+ default: false
+ },
+ chapters: {
+ type: Array,
+ required: true
+ },
+ currentChapterIndex: {
+ type: Number,
+ required: true
+ }
+ },
+ data: function () {
+ return {
+ showChapters: false,
+ currentIndex: 0
+ }
+ },
+ computed: {
+ currentTitle: function () {
+ return this.chapters[this.currentIndex].title
+ }
+ },
+ watch: {
+ currentChapterIndex: function (value) {
+ if (this.currentIndex !== value) {
+ this.currentIndex = value
+ }
+ }
+ },
+ mounted: function () {
+ this.currentIndex = this.currentChapterIndex
+ },
+ methods: {
+ changeChapter: function(index) {
+ this.currentIndex = index
+ this.$emit('timestamp-event', this.chapters[index].startSeconds)
+ },
+
+ navigateChapters(direction) {
+ const chapterElements = Array.from(this.$refs.chaptersWrapper.children)
+ const focusedIndex = chapterElements.indexOf(document.activeElement)
+
+ let newIndex = focusedIndex
+ if (direction === 'up') {
+ if (focusedIndex === 0) {
+ newIndex = chapterElements.length - 1
+ } else {
+ newIndex--
+ }
+ } else {
+ if (focusedIndex === chapterElements.length - 1) {
+ newIndex = 0
+ } else {
+ newIndex++
+ }
+ }
+
+ chapterElements[newIndex].focus()
+ }
+ }
+})
diff --git a/src/renderer/components/watch-video-chapters/watch-video-chapters.vue b/src/renderer/components/watch-video-chapters/watch-video-chapters.vue
new file mode 100644
index 00000000..7554d78f
--- /dev/null
+++ b/src/renderer/components/watch-video-chapters/watch-video-chapters.vue
@@ -0,0 +1,66 @@
+
+
+
+ {{ $t("Chapters.Chapters") }}
+
+
+ • {{ currentTitle }}
+
+
+
+
+
+
+
+
+ {{ chapter.timestamp }}
+
+
+ {{ chapter.title }}
+
+
+
+
+
+
+
+
diff --git a/src/renderer/main.js b/src/renderer/main.js
index af1301e6..8984f14c 100644
--- a/src/renderer/main.js
+++ b/src/renderer/main.js
@@ -13,6 +13,7 @@ import {
faBars,
faBookmark,
faCheck,
+ faChevronRight,
faClone,
faCommentDots,
faCopy,
@@ -75,6 +76,7 @@ library.add(
faBars,
faBookmark,
faCheck,
+ faChevronRight,
faClone,
faCommentDots,
faCopy,
diff --git a/src/renderer/store/modules/settings.js b/src/renderer/store/modules/settings.js
index 7c7bc0fa..f34665a9 100644
--- a/src/renderer/store/modules/settings.js
+++ b/src/renderer/store/modules/settings.js
@@ -207,6 +207,7 @@ const state = {
hideVideoViews: false,
hideWatchedSubs: false,
hideLabelsSideBar: false,
+ hideChapters: false,
landingPage: 'subscriptions',
listType: 'grid',
maxVideoPlaybackRate: 3,
diff --git a/src/renderer/views/Watch/Watch.js b/src/renderer/views/Watch/Watch.js
index 396757be..8aab664a 100644
--- a/src/renderer/views/Watch/Watch.js
+++ b/src/renderer/views/Watch/Watch.js
@@ -8,6 +8,7 @@ import FtCard from '../../components/ft-card/ft-card.vue'
import FtElementList from '../../components/ft-element-list/ft-element-list.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'
@@ -26,6 +27,7 @@ export default Vue.extend({
'ft-element-list': FtElementList,
'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,
@@ -63,6 +65,8 @@ export default Vue.extend({
videoLikeCount: 0,
videoDislikeCount: 0,
videoLengthSeconds: 0,
+ videoChapters: [],
+ videoCurrentChapterIndex: 0,
channelName: '',
channelThumbnail: '',
channelId: '',
@@ -163,6 +167,9 @@ export default Vue.extend({
},
currentLocale: function () {
return i18n.locale.replace('_', '-')
+ },
+ hideChapters: function () {
+ return this.$store.getters.getHideChapters
}
},
watch: {
@@ -370,6 +377,34 @@ export default Vue.extend({
}
}
+ 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: this.formatSecondsAsTimestamp(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()
@@ -683,6 +718,48 @@ export default Vue.extend({
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
@@ -845,6 +922,30 @@ export default Vue.extend({
}
},
+ 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,
@@ -1098,6 +1199,7 @@ export default Vue.extend({
clearTimeout(this.playNextTimeout)
clearInterval(this.playNextCountDownIntervalId)
+ this.videoChapters = []
this.handleWatchProgress()
@@ -1402,6 +1504,38 @@ export default Vue.extend({
document.title = `${this.videoTitle} - FreeTube`
},
+ formatSecondsAsTimestamp(time) {
+ if (time === 0) {
+ return '0:00'
+ }
+
+ let hours = 0
+
+ if (time >= 3600) {
+ hours = Math.floor(time / 3600)
+ time = time - hours * 3600
+ }
+
+ let minutes = Math.floor(time / 60)
+ if (minutes < 10 && hours > 0) {
+ minutes = '0' + minutes
+ }
+
+ let seconds = time - minutes * 60
+ if (seconds < 10) {
+ seconds = '0' + seconds
+ }
+
+ let timestamp = ''
+ if (hours > 0) {
+ timestamp = hours + ':' + minutes + ':' + seconds
+ } else {
+ timestamp = minutes + ':' + seconds
+ }
+
+ return timestamp
+ },
+
...mapActions([
'showToast',
'buildVTTFileLocally',
diff --git a/src/renderer/views/Watch/Watch.vue b/src/renderer/views/Watch/Watch.vue
index 0298cab9..3b166caf 100644
--- a/src/renderer/views/Watch/Watch.vue
+++ b/src/renderer/views/Watch/Watch.vue
@@ -28,12 +28,14 @@
:thumbnail="thumbnail"
:video-id="videoId"
:length-seconds="videoLengthSeconds"
+ :chapters="videoChapters"
class="videoPlayer"
:class="{ theatrePlayer: useTheatreMode }"
@ready="checkIfWatched"
@ended="handleVideoEnded"
@error="handleVideoError"
@store-caption-list="captionHybridList = $event"
+ v-on="!hideChapters && videoChapters.length > 0 ? { timeupdate: updateCurrentChapter } : {}"
/>
+