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:
parent
1e62ba9d30
commit
bc886af726
|
@ -57,8 +57,14 @@ export default Vue.extend({
|
||||||
hideLiveStreams: function() {
|
hideLiveStreams: function() {
|
||||||
return this.$store.getters.getHideLiveStreams
|
return this.$store.getters.getHideLiveStreams
|
||||||
},
|
},
|
||||||
hideSharingActions: function() {
|
hideSharingActions: function () {
|
||||||
return this.$store.getters.getHideSharingActions
|
return this.$store.getters.getHideSharingActions
|
||||||
|
},
|
||||||
|
backendPreference: function () {
|
||||||
|
return this.$store.getters.getBackendPreference
|
||||||
|
},
|
||||||
|
hideChapters: function () {
|
||||||
|
return this.$store.getters.getHideChapters
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
@ -86,7 +92,8 @@ export default Vue.extend({
|
||||||
'updateHideVideoDescription',
|
'updateHideVideoDescription',
|
||||||
'updateHideComments',
|
'updateHideComments',
|
||||||
'updateHideLiveStreams',
|
'updateHideLiveStreams',
|
||||||
'updateHideSharingActions'
|
'updateHideSharingActions',
|
||||||
|
'updateHideChapters'
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
|
@ -46,6 +46,12 @@
|
||||||
:default-value="hideSharingActions"
|
:default-value="hideSharingActions"
|
||||||
@change="updateHideSharingActions"
|
@change="updateHideSharingActions"
|
||||||
/>
|
/>
|
||||||
|
<ft-toggle-switch
|
||||||
|
:label="$t('Settings.Distraction Free Settings.Hide Chapters')"
|
||||||
|
:compact="true"
|
||||||
|
:default-value="hideChapters"
|
||||||
|
@change="updateHideChapters"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="switchColumn">
|
<div class="switchColumn">
|
||||||
<ft-toggle-switch
|
<ft-toggle-switch
|
||||||
|
|
|
@ -5,3 +5,22 @@
|
||||||
.ftVideoPlayer {
|
.ftVideoPlayer {
|
||||||
width:100%;
|
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);
|
||||||
|
}
|
||||||
|
|
|
@ -78,6 +78,10 @@ export default Vue.extend({
|
||||||
lengthSeconds: {
|
lengthSeconds: {
|
||||||
type: Number,
|
type: Number,
|
||||||
required: true
|
required: true
|
||||||
|
},
|
||||||
|
chapters: {
|
||||||
|
type: Array,
|
||||||
|
default: () => { return [] }
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
data: function () {
|
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
|
// 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')
|
this.player.controlBar.progressControl.seekBar.playProgressBar.removeChild('timeTooltip')
|
||||||
|
|
||||||
|
if (this.chapters.length > 0) {
|
||||||
|
this.chapters.forEach(this.addChapterMarker)
|
||||||
|
}
|
||||||
|
|
||||||
if (this.useSponsorBlock) {
|
if (this.useSponsorBlock) {
|
||||||
this.initializeSponsorBlock()
|
this.initializeSponsorBlock()
|
||||||
}
|
}
|
||||||
|
@ -523,6 +531,7 @@ export default Vue.extend({
|
||||||
this.playerStats = this.player.tech({ IWillNotUseThisInPlugins: true }).vhs.stats
|
this.playerStats = this.player.tech({ IWillNotUseThisInPlugins: true }).vhs.stats
|
||||||
this.updateStatsContent()
|
this.updateStatsContent()
|
||||||
}
|
}
|
||||||
|
this.$emit('timeupdate')
|
||||||
})
|
})
|
||||||
|
|
||||||
this.player.textTrackSettings.on('modalclose', (_) => {
|
this.player.textTrackSettings.on('modalclose', (_) => {
|
||||||
|
@ -621,16 +630,22 @@ export default Vue.extend({
|
||||||
},
|
},
|
||||||
|
|
||||||
addSponsorBlockMarker(marker) {
|
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.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.width = (marker.duration / this.lengthSeconds) * 100 + '%'
|
||||||
markerDiv.style.marginLeft = (marker.time / 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)
|
this.player.el().querySelector('.vjs-progress-holder').appendChild(markerDiv)
|
||||||
},
|
},
|
||||||
|
|
|
@ -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);
|
||||||
|
}
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
|
@ -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" />
|
|
@ -13,6 +13,7 @@ import {
|
||||||
faBars,
|
faBars,
|
||||||
faBookmark,
|
faBookmark,
|
||||||
faCheck,
|
faCheck,
|
||||||
|
faChevronRight,
|
||||||
faClone,
|
faClone,
|
||||||
faCommentDots,
|
faCommentDots,
|
||||||
faCopy,
|
faCopy,
|
||||||
|
@ -75,6 +76,7 @@ library.add(
|
||||||
faBars,
|
faBars,
|
||||||
faBookmark,
|
faBookmark,
|
||||||
faCheck,
|
faCheck,
|
||||||
|
faChevronRight,
|
||||||
faClone,
|
faClone,
|
||||||
faCommentDots,
|
faCommentDots,
|
||||||
faCopy,
|
faCopy,
|
||||||
|
|
|
@ -207,6 +207,7 @@ const state = {
|
||||||
hideVideoViews: false,
|
hideVideoViews: false,
|
||||||
hideWatchedSubs: false,
|
hideWatchedSubs: false,
|
||||||
hideLabelsSideBar: false,
|
hideLabelsSideBar: false,
|
||||||
|
hideChapters: false,
|
||||||
landingPage: 'subscriptions',
|
landingPage: 'subscriptions',
|
||||||
listType: 'grid',
|
listType: 'grid',
|
||||||
maxVideoPlaybackRate: 3,
|
maxVideoPlaybackRate: 3,
|
||||||
|
|
|
@ -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 FtElementList from '../../components/ft-element-list/ft-element-list.vue'
|
||||||
import FtVideoPlayer from '../../components/ft-video-player/ft-video-player.vue'
|
import FtVideoPlayer from '../../components/ft-video-player/ft-video-player.vue'
|
||||||
import WatchVideoInfo from '../../components/watch-video-info/watch-video-info.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 WatchVideoDescription from '../../components/watch-video-description/watch-video-description.vue'
|
||||||
import WatchVideoComments from '../../components/watch-video-comments/watch-video-comments.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'
|
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-element-list': FtElementList,
|
||||||
'ft-video-player': FtVideoPlayer,
|
'ft-video-player': FtVideoPlayer,
|
||||||
'watch-video-info': WatchVideoInfo,
|
'watch-video-info': WatchVideoInfo,
|
||||||
|
'watch-video-chapters': WatchVideoChapters,
|
||||||
'watch-video-description': WatchVideoDescription,
|
'watch-video-description': WatchVideoDescription,
|
||||||
'watch-video-comments': WatchVideoComments,
|
'watch-video-comments': WatchVideoComments,
|
||||||
'watch-video-live-chat': WatchVideoLiveChat,
|
'watch-video-live-chat': WatchVideoLiveChat,
|
||||||
|
@ -63,6 +65,8 @@ export default Vue.extend({
|
||||||
videoLikeCount: 0,
|
videoLikeCount: 0,
|
||||||
videoDislikeCount: 0,
|
videoDislikeCount: 0,
|
||||||
videoLengthSeconds: 0,
|
videoLengthSeconds: 0,
|
||||||
|
videoChapters: [],
|
||||||
|
videoCurrentChapterIndex: 0,
|
||||||
channelName: '',
|
channelName: '',
|
||||||
channelThumbnail: '',
|
channelThumbnail: '',
|
||||||
channelId: '',
|
channelId: '',
|
||||||
|
@ -163,6 +167,9 @@ export default Vue.extend({
|
||||||
},
|
},
|
||||||
currentLocale: function () {
|
currentLocale: function () {
|
||||||
return i18n.locale.replace('_', '-')
|
return i18n.locale.replace('_', '-')
|
||||||
|
},
|
||||||
|
hideChapters: function () {
|
||||||
|
return this.$store.getters.getHideChapters
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
watch: {
|
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) {
|
if ((this.isLive && this.isLiveContent) && !this.isUpcoming) {
|
||||||
this.enableLegacyFormat()
|
this.enableLegacyFormat()
|
||||||
|
|
||||||
|
@ -683,6 +718,48 @@ export default Vue.extend({
|
||||||
break
|
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) {
|
if (this.isLive) {
|
||||||
this.showLegacyPlayer = true
|
this.showLegacyPlayer = true
|
||||||
this.showDashPlayer = false
|
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) {
|
addToHistory: function (watchProgress) {
|
||||||
const videoData = {
|
const videoData = {
|
||||||
videoId: this.videoId,
|
videoId: this.videoId,
|
||||||
|
@ -1098,6 +1199,7 @@ export default Vue.extend({
|
||||||
|
|
||||||
clearTimeout(this.playNextTimeout)
|
clearTimeout(this.playNextTimeout)
|
||||||
clearInterval(this.playNextCountDownIntervalId)
|
clearInterval(this.playNextCountDownIntervalId)
|
||||||
|
this.videoChapters = []
|
||||||
|
|
||||||
this.handleWatchProgress()
|
this.handleWatchProgress()
|
||||||
|
|
||||||
|
@ -1402,6 +1504,38 @@ export default Vue.extend({
|
||||||
document.title = `${this.videoTitle} - FreeTube`
|
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([
|
...mapActions([
|
||||||
'showToast',
|
'showToast',
|
||||||
'buildVTTFileLocally',
|
'buildVTTFileLocally',
|
||||||
|
|
|
@ -28,12 +28,14 @@
|
||||||
:thumbnail="thumbnail"
|
:thumbnail="thumbnail"
|
||||||
:video-id="videoId"
|
:video-id="videoId"
|
||||||
:length-seconds="videoLengthSeconds"
|
:length-seconds="videoLengthSeconds"
|
||||||
|
:chapters="videoChapters"
|
||||||
class="videoPlayer"
|
class="videoPlayer"
|
||||||
:class="{ theatrePlayer: useTheatreMode }"
|
:class="{ theatrePlayer: useTheatreMode }"
|
||||||
@ready="checkIfWatched"
|
@ready="checkIfWatched"
|
||||||
@ended="handleVideoEnded"
|
@ended="handleVideoEnded"
|
||||||
@error="handleVideoError"
|
@error="handleVideoError"
|
||||||
@store-caption-list="captionHybridList = $event"
|
@store-caption-list="captionHybridList = $event"
|
||||||
|
v-on="!hideChapters && videoChapters.length > 0 ? { timeupdate: updateCurrentChapter } : {}"
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
v-if="!isLoading && isUpcoming"
|
v-if="!isLoading && isUpcoming"
|
||||||
|
@ -117,6 +119,15 @@
|
||||||
:class="{ theatreWatchVideo: useTheatreMode }"
|
:class="{ theatreWatchVideo: useTheatreMode }"
|
||||||
@pause-player="pausePlayer"
|
@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
|
<watch-video-description
|
||||||
v-if="!isLoading && !hideVideoDescription"
|
v-if="!isLoading && !hideVideoDescription"
|
||||||
:published="videoPublished"
|
:published="videoPublished"
|
||||||
|
|
|
@ -320,6 +320,7 @@ Settings:
|
||||||
Hide Comments: Hide Comments
|
Hide Comments: Hide Comments
|
||||||
Hide Live Streams: Hide Live Streams
|
Hide Live Streams: Hide Live Streams
|
||||||
Hide Sharing Actions: Hide Sharing Actions
|
Hide Sharing Actions: Hide Sharing Actions
|
||||||
|
Hide Chapters: Hide Chapters
|
||||||
Data Settings:
|
Data Settings:
|
||||||
Data Settings: Data Settings
|
Data Settings: Data Settings
|
||||||
Select Import Type: Select Import Type
|
Select Import Type: Select Import Type
|
||||||
|
@ -692,6 +693,11 @@ Clipboard:
|
||||||
Copy failed: Copy to clipboard failed
|
Copy failed: Copy to clipboard failed
|
||||||
Cannot access clipboard without a secure connection: Cannot access clipboard without a secure connection
|
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
|
Mini Player: Mini Player
|
||||||
Comments:
|
Comments:
|
||||||
Comments: Comments
|
Comments: Comments
|
||||||
|
|
Loading…
Reference in New Issue