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() { | ||||
|       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' | ||||
|     ]) | ||||
|   } | ||||
| }) | ||||
|  |  | |||
|  | @ -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 | ||||
|  |  | |||
|  | @ -4,4 +4,23 @@ | |||
| 
 | ||||
| .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); | ||||
| } | ||||
|  |  | |||
|  | @ -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) | ||||
|     }, | ||||
|  |  | |||
|  | @ -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, | ||||
|   faBookmark, | ||||
|   faCheck, | ||||
|   faChevronRight, | ||||
|   faClone, | ||||
|   faCommentDots, | ||||
|   faCopy, | ||||
|  | @ -75,6 +76,7 @@ library.add( | |||
|   faBars, | ||||
|   faBookmark, | ||||
|   faCheck, | ||||
|   faChevronRight, | ||||
|   faClone, | ||||
|   faCommentDots, | ||||
|   faCopy, | ||||
|  |  | |||
|  | @ -207,6 +207,7 @@ const state = { | |||
|   hideVideoViews: false, | ||||
|   hideWatchedSubs: false, | ||||
|   hideLabelsSideBar: false, | ||||
|   hideChapters: false, | ||||
|   landingPage: 'subscriptions', | ||||
|   listType: 'grid', | ||||
|   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 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', | ||||
|  |  | |||
|  | @ -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" | ||||
|  |  | |||
|  | @ -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 | ||||
|  |  | |||
		Loading…
	
		Reference in New Issue