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