diff --git a/client/components/app/MediaPlayerContainer.vue b/client/components/app/MediaPlayerContainer.vue index 1a2b1d30a..3564a5670 100644 --- a/client/components/app/MediaPlayerContainer.vue +++ b/client/components/app/MediaPlayerContainer.vue @@ -280,9 +280,11 @@ export default { this.playerHandler.playPause() }, jumpForward() { + this._manualSeekTime = Date.now() this.playerHandler.jumpForward() }, jumpBackward() { + this._manualSeekTime = Date.now() this.playerHandler.jumpBackward() }, setVolume(volume) { @@ -293,6 +295,7 @@ export default { this.playerHandler.setPlaybackRate(playbackRate) }, seek(time) { + this._manualSeekTime = Date.now() this.playerHandler.seek(time) }, playbackTimeUpdate(time) { @@ -308,6 +311,9 @@ export default { if (this.sleepTimerType === this.$constants.SleepTimerTypes.CHAPTER && this.sleepTimerSet) { this.checkChapterEnd() } + + // check for intro/outro and skip if needed + this.checkAndSkipIntroOutro(time) }, setDuration(duration) { this.totalDuration = duration @@ -543,6 +549,87 @@ export default { this.playerHandler.resetPlayer() // Closes player without reporting to server this.$store.commit('setMediaPlaying', null) } + }, + + // get skip settings + getSkipSettings() { + return { + skipIntro: this.$store.getters['user/getUserSetting']('skipIntro'), + introDuration: this.$store.getters['user/getUserSetting']('introDuration'), + skipOutro: this.$store.getters['user/getUserSetting']('skipOutro'), + outroDuration: this.$store.getters['user/getUserSetting']('outroDuration') + } + }, + + // check and skip intro/outro + checkAndSkipIntroOutro(currentTime) { + const skipSettings = this.getSkipSettings() + if (!skipSettings) return + + const doSkipIntro = skipSettings.skipIntro && skipSettings.introDuration > 0 + const doSkipOutro = skipSettings.skipOutro && skipSettings.outroDuration > 0 + if (!doSkipIntro && !doSkipOutro) return + if (!this.isPlaying || !this.chapters.length) return + + // The skip function is not triggered within 2 seconds after the user manually seeks + if (this._manualSeekTime && Date.now() - this._manualSeekTime < 2000) return + + // Reentry guard: When skipping, wait until reaching the target position before cancelling + if (this._isSkipping) { + if (this._skipTarget != null && currentTime >= this._skipTarget - 0.5) { + this._isSkipping = false + this._skipTarget = null + } + return + } + + const chapter = this.chapters.find((ch) => ch.start <= currentTime && currentTime < ch.end) + if (!chapter) return + + const introDuration = doSkipIntro ? skipSettings.introDuration : 0 + const outroDuration = doSkipOutro ? skipSettings.outroDuration : 0 + + const introEndTime = Math.min(chapter.start + introDuration, chapter.end) + const outroStartTime = Math.max(chapter.end - outroDuration, chapter.start) + + // Short chapter: If the intro and outro intervals overlap, do not skip + if (doSkipIntro && doSkipOutro && introEndTime > outroStartTime) return + + // Check whether it is within the intro interval + if (doSkipIntro && currentTime < introEndTime) { + const target = introEndTime + 0.5 + this._isSkipping = true + this._skipTarget = target + this.seek(target) + return + } + + // check whether it is within the outro interval + if (doSkipOutro && currentTime >= outroStartTime) { + const chapterIndex = this.chapters.indexOf(chapter) + const nextChapter = this.chapters[chapterIndex + 1] + + if (nextChapter) { + // has next chapter: skip to next chapter start (if skipIntro is on, skip intro) + let target = nextChapter.start + if (doSkipIntro) { + const nextIntroEnd = Math.min(nextChapter.start + introDuration, nextChapter.end) + const nextOutroStart = Math.max(nextChapter.end - outroDuration, nextChapter.start) + // ensure that the next chapter intro/outro does not overlap + if (nextIntroEnd <= nextOutroStart) { + target = nextIntroEnd + 0.5 + } + } + this._isSkipping = true + this._skipTarget = target + this.seek(target) + } else { + // last chapter: skip to end + this._isSkipping = true + this._skipTarget = chapter.end + this.seek(chapter.end) + } + } } }, mounted() { diff --git a/client/components/modals/PlayerSettingsModal.vue b/client/components/modals/PlayerSettingsModal.vue index dfac28cfd..4f7ca30f1 100644 --- a/client/components/modals/PlayerSettingsModal.vue +++ b/client/components/modals/PlayerSettingsModal.vue @@ -17,6 +17,28 @@
+ +
+

{{ $strings.HeaderChapterIntroOutroSkipSettings }}

+ +
+ +
+ {{ $strings.LabelSkipChapterIntro }} +
+ + {{ $strings.LabelSeconds }} +
+ +
+ +
+ {{ $strings.LabelSkipChapterOutro }} +
+ + {{ $strings.LabelSeconds }} +
+
@@ -40,7 +62,11 @@ export default { jumpForwardAmount: 10, jumpBackwardAmount: 10, playbackRateIncrementDecrementValues: [0.1, 0.05], - playbackRateIncrementDecrement: 0.1 + playbackRateIncrementDecrement: 0.1, + skipIntro: false, + introDuration: 10, + skipOutro: false, + outroDuration: 10 } }, computed: { @@ -69,11 +95,29 @@ export default { this.playbackRateIncrementDecrement = val this.$store.dispatch('user/updateUserSettings', { playbackRateIncrementDecrement: val }) }, + setSkipIntro() { + this.$store.dispatch('user/updateUserSettings', { skipIntro: this.skipIntro }) + }, + setIntroDuration() { + this.introDuration = Math.max(0, Math.min(60, parseInt(this.introDuration) || 0)) + this.$store.dispatch('user/updateUserSettings', { introDuration: this.introDuration }) + }, + setSkipOutro() { + this.$store.dispatch('user/updateUserSettings', { skipOutro: this.skipOutro }) + }, + setOutroDuration() { + this.outroDuration = Math.max(0, Math.min(60, parseInt(this.outroDuration) || 0)) + this.$store.dispatch('user/updateUserSettings', { outroDuration: this.outroDuration }) + }, settingsUpdated() { this.useChapterTrack = this.$store.getters['user/getUserSetting']('useChapterTrack') this.jumpForwardAmount = this.$store.getters['user/getUserSetting']('jumpForwardAmount') this.jumpBackwardAmount = this.$store.getters['user/getUserSetting']('jumpBackwardAmount') this.playbackRateIncrementDecrement = this.$store.getters['user/getUserSetting']('playbackRateIncrementDecrement') + this.skipIntro = this.$store.getters['user/getUserSetting']('skipIntro') || false + this.introDuration = this.$store.getters['user/getUserSetting']('introDuration') || 10 + this.skipOutro = this.$store.getters['user/getUserSetting']('skipOutro') || false + this.outroDuration = this.$store.getters['user/getUserSetting']('outroDuration') || 10 } }, mounted() { diff --git a/client/store/user.js b/client/store/user.js index 96e79d12f..d702dd386 100644 --- a/client/store/user.js +++ b/client/store/user.js @@ -18,7 +18,11 @@ export const state = () => ({ authorSortBy: 'name', authorSortDesc: false, jumpForwardAmount: 10, - jumpBackwardAmount: 10 + jumpBackwardAmount: 10, + skipIntro: false, + introDuration: 10, + skipOutro: false, + outroDuration: 10 } }) diff --git a/client/strings/en-us.json b/client/strings/en-us.json index fb2bcb281..a636d41b9 100644 --- a/client/strings/en-us.json +++ b/client/strings/en-us.json @@ -129,6 +129,7 @@ "HeaderBackups": "Backups", "HeaderBulkChapterModal": "Add Multiple Chapters", "HeaderChangePassword": "Change Password", + "HeaderChapterIntroOutroSkipSettings": "Intro/Outro Skip Settings", "HeaderChapters": "Chapters", "HeaderChooseAFolder": "Choose a Folder", "HeaderCollection": "Collection", @@ -567,6 +568,7 @@ "LabelSearchTitleOrASIN": "Search Title or ASIN", "LabelSeason": "Season", "LabelSeasonNumber": "Season #{0}", + "LabelSeconds": "seconds", "LabelSelectAll": "Select all", "LabelSelectAllEpisodes": "Select all episodes", "LabelSelectEpisodesShowing": "Select {0} episodes showing", @@ -629,6 +631,8 @@ "LabelShowSeconds": "Show seconds", "LabelShowSubtitles": "Show Subtitles", "LabelSize": "Size", + "LabelSkipChapterIntro": "Skip Chapter Intro", + "LabelSkipChapterOutro": "Skip Chapter Outro", "LabelSleepTimer": "Sleep timer", "LabelSlug": "Slug", "LabelSortAscending": "Ascending", diff --git a/client/strings/zh-cn.json b/client/strings/zh-cn.json index 14c70cb10..4b32c0fb2 100644 --- a/client/strings/zh-cn.json +++ b/client/strings/zh-cn.json @@ -129,6 +129,7 @@ "HeaderBackups": "备份", "HeaderBulkChapterModal": "添加多个章节", "HeaderChangePassword": "更改密码", + "HeaderChapterIntroOutroSkipSettings": "片头 / 片尾跳过设置", "HeaderChapters": "章节", "HeaderChooseAFolder": "选择文件夹", "HeaderCollection": "收藏", @@ -567,6 +568,7 @@ "LabelSearchTitleOrASIN": "搜索标题或 ASIN", "LabelSeason": "季", "LabelSeasonNumber": "第 {0} 季", + "LabelSeconds": "秒", "LabelSelectAll": "全选", "LabelSelectAllEpisodes": "选择所有剧集", "LabelSelectEpisodesShowing": "选择正在播放的 {0} 剧集", @@ -629,6 +631,8 @@ "LabelShowSeconds": "显示秒数", "LabelShowSubtitles": "显示标题", "LabelSize": "文件大小", + "LabelSkipChapterIntro": "跳过章节片头", + "LabelSkipChapterOutro": "跳过章节片尾", "LabelSleepTimer": "睡眠定时", "LabelSlug": "Slug", "LabelSortAscending": "升序",