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
This commit is contained in:
absidue 2022-09-29 22:01:54 +02:00 committed by GitHub
parent 1e62ba9d30
commit bc886af726
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 431 additions and 9 deletions

View File

@ -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'
])
}
})

View File

@ -46,6 +46,12 @@
:default-value="hideSharingActions"
@change="updateHideSharingActions"
/>
<ft-toggle-switch
:label="$t('Settings.Distraction Free Settings.Hide Chapters')"
:compact="true"
:default-value="hideChapters"
@change="updateHideChapters"
/>
</div>
<div class="switchColumn">
<ft-toggle-switch

View File

@ -5,3 +5,22 @@
.ftVideoPlayer {
width:100%;
}
:deep(.sponsorBlockMarker), :deep(.chapterMarker) {
position: absolute;
opacity: 0.6;
}
:deep(.sponsorBlockMarker) {
height: 100%;
background-color: var(--primary-color);
}
:deep(.chapterMarker) {
height: 120%;
top: -10%;
width: 1%;
border-radius: 999px; /* make sure they are rounded properly*/
z-index: 2;
background-color: var(--accent-color);
}

View File

@ -78,6 +78,10 @@ export default Vue.extend({
lengthSeconds: {
type: Number,
required: true
},
chapters: {
type: Array,
default: () => { 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)
},

View File

@ -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);
}

View File

@ -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()
}
}
})

View File

@ -0,0 +1,66 @@
<template>
<ft-card class="videoChapters">
<h3
class="chaptersTitle"
tabindex="0"
:aria-label="showChapters
? $t('Chapters.Chapters list visible, current chapter: {chapterName}', null, { chapterName: currentTitle })
: $t('Chapters.Chapters list hidden, current chapter: {chapterName}', null, { chapterName: currentTitle })
"
:aria-pressed="showChapters"
@click="showChapters = !showChapters"
@keydown.space.stop.prevent="showChapters = !showChapters"
@keydown.enter.stop.prevent="showChapters = !showChapters"
>
{{ $t("Chapters.Chapters") }}
<span class="currentChapter">
{{ currentTitle }}
</span>
<font-awesome-icon
class="chaptersChevron"
:icon="['fas', 'chevron-right']"
:rotation="showChapters ? 90 : null"
:class="{ open: showChapters }"
/>
</h3>
<div
v-show="showChapters"
ref="chaptersWrapper"
class="chaptersWrapper"
:class="{ compact }"
@keydown.arrow-up.stop.prevent="navigateChapters('up')"
@keydown.arrow-down.stop.prevent="navigateChapters('down')"
>
<div
v-for="(chapter, index) in chapters"
:key="index"
class="chapter"
role="button"
tabindex="0"
:aria-selected="index === currentIndex"
:class="{ current: index === currentIndex }"
@click="changeChapter(index)"
@keydown.space.stop.prevent="changeChapter(index)"
@keydown.enter.stop.prevent="changeChapter(index)"
>
<img
v-if="!compact"
aria-hidden="true"
class="chapterThumbnail"
:src="chapter.thumbnail"
>
<div class="chapterTimestamp">
{{ chapter.timestamp }}
</div>
<p class="chapterTitle">
{{ chapter.title }}
</p>
</div>
</div>
</ft-card>
</template>
<script src="./watch-video-chapters.js" />
<style scoped src="./watch-video-chapters.css" />

View File

@ -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,

View File

@ -207,6 +207,7 @@ const state = {
hideVideoViews: false,
hideWatchedSubs: false,
hideLabelsSideBar: false,
hideChapters: false,
landingPage: 'subscriptions',
listType: 'grid',
maxVideoPlaybackRate: 3,

View File

@ -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(/^(?<timestamp>((?<hours>[0-9]+):)?(?<minutes>[0-9]+):(?<seconds>[0-9]+))(\s*[-–—]\s*(?:[0-9]+:)?[0-9]+:[0-9]+)?\s+([-–•—]\s*)?(?<title>.+)$/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',

View File

@ -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 } : {}"
/>
<div
v-if="!isLoading && isUpcoming"
@ -117,6 +119,15 @@
:class="{ theatreWatchVideo: useTheatreMode }"
@pause-player="pausePlayer"
/>
<watch-video-chapters
v-if="!hideChapters && !isLoading && videoChapters.length > 0"
:compact="backendPreference === 'invidious'"
:chapters="videoChapters"
:current-chapter-index="videoCurrentChapterIndex"
class="watchVideo"
:class="{ theatreWatchVideo: useTheatreMode }"
@timestamp-event="changeTimestamp"
/>
<watch-video-description
v-if="!isLoading && !hideVideoDescription"
:published="videoPublished"

View File

@ -320,6 +320,7 @@ Settings:
Hide Comments: Hide Comments
Hide Live Streams: Hide Live Streams
Hide Sharing Actions: Hide Sharing Actions
Hide Chapters: Hide Chapters
Data Settings:
Data Settings: Data Settings
Select Import Type: Select Import Type
@ -692,6 +693,11 @@ Clipboard:
Copy failed: Copy to clipboard failed
Cannot access clipboard without a secure connection: Cannot access clipboard without a secure connection
Chapters:
Chapters: Chapters
'Chapters list visible, current chapter: {chapterName}': 'Chapters list visible, current chapter: {chapterName}'
'Chapters list hidden, current chapter: {chapterName}': 'Chapters list hidden, current chapter: {chapterName}'
Mini Player: Mini Player
Comments:
Comments: Comments