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
|
@ -59,6 +59,12 @@ export default Vue.extend({
|
|||
},
|
||||
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
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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