Skip to content

Commit ab08b78

Browse files
committed
feat: add configurable skip controls for audiobooks and podcasts
Adds skip forward/backward buttons to the player controls and fullscreen view when playing audiobooks or podcasts. The skip amount is configurable via frontend settings (default 30s) and is also used by the browser media session seek actions.
1 parent a1d7937 commit ab08b78

8 files changed

Lines changed: 281 additions & 6 deletions

File tree

src/helpers/skipControls.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import api from "@/plugins/api";
2+
import { store } from "@/plugins/store";
3+
4+
// Shared skip control logic with throttling for smooth multiple clicks
5+
class SkipControlManager {
6+
private lastSeekPos: number | undefined = undefined;
7+
private lastSeekPosTimeoutHandle: any = undefined;
8+
private readonly TIMEOUT_MS = 2000;
9+
10+
private lastSeekPosTimeout() {
11+
clearTimeout(this.lastSeekPosTimeoutHandle);
12+
this.lastSeekPosTimeoutHandle = setTimeout(() => {
13+
this.lastSeekPos = undefined;
14+
this.lastSeekPosTimeoutHandle = undefined;
15+
}, this.TIMEOUT_MS);
16+
}
17+
18+
public skip(queueId: string, skipSeconds: number) {
19+
const currentTime =
20+
this.lastSeekPos || store.activePlayerQueue?.elapsed_time || 0;
21+
const newTime = Math.max(0, currentTime + skipSeconds);
22+
23+
this.lastSeekPos = newTime;
24+
this.lastSeekPosTimeout();
25+
26+
// Send the seek command immediately for the accumulated position
27+
api.playerCommandSeek(
28+
store.activePlayer?.player_id || "",
29+
Math.round(newTime),
30+
);
31+
}
32+
}
33+
34+
export const skipControlManager = new SkipControlManager();

src/layouts/default/PlayerOSD/PlayerBrowserMediaControls.vue

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
import audio from "@/assets/almost_silent.mp3";
1515
import { useMediaBrowserMetaData } from "@/helpers/useMediaBrowserMetaData";
1616
import api from "@/plugins/api";
17-
import { PlaybackState } from "@/plugins/api/interfaces";
17+
import { MediaType, PlaybackState } from "@/plugins/api/interfaces";
1818
import { store } from "@/plugins/store";
1919
import { onMounted, ref, watch } from "vue";
2020
@@ -106,7 +106,20 @@ const seekHandler = function (
106106
if (evt.action === "seekto" && evt.seekTime) {
107107
to = evt.seekTime;
108108
} else if (evt.action === "seekforward" || evt.action === "seekbackward") {
109-
const offset = evt.seekOffset || 10;
109+
// Use configurable skip amount for audiobooks/podcasts, fallback to 10 seconds for music
110+
const mediaType = store.curQueueItem?.media_item?.media_type;
111+
const isAudiobookOrPodcast =
112+
mediaType === MediaType.AUDIOBOOK ||
113+
mediaType === MediaType.PODCAST ||
114+
mediaType === MediaType.PODCAST_EPISODE;
115+
const defaultSkipAmount = isAudiobookOrPodcast
116+
? parseInt(
117+
localStorage.getItem("frontend.settings.audiobook_skip_seconds") ||
118+
"30",
119+
)
120+
: 10;
121+
122+
const offset = evt.seekOffset || defaultSkipAmount;
110123
const elapsed_time =
111124
lastSeekPos != null ? lastSeekPos : store.activePlayerQueue?.elapsed_time;
112125
if (elapsed_time == null) return;
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
<template>
2+
<!-- skip back button -->
3+
<ResponsiveIcon
4+
v-if="isVisible && skipAmount > 0"
5+
v-bind="icon"
6+
:disabled="!playerQueue?.active || !curQueueItem"
7+
icon="mdi-rewind"
8+
:type="'btn'"
9+
:title="$t('skip_backward_seconds', [skipAmount])"
10+
@click="
11+
playerQueue && skipControlManager.skip(playerQueue.queue_id, -skipAmount)
12+
"
13+
>
14+
<template #default>
15+
<div class="skip-button-content">
16+
<v-icon>mdi-rewind</v-icon>
17+
<span class="skip-amount">{{ skipAmount }}</span>
18+
</div>
19+
</template>
20+
</ResponsiveIcon>
21+
</template>
22+
23+
<script setup lang="ts">
24+
import { PlayerQueue, QueueItem } from "@/plugins/api/interfaces";
25+
import ResponsiveIcon, {
26+
ResponsiveIconProps,
27+
} from "@/components/mods/ResponsiveIcon.vue";
28+
import { skipControlManager } from "@/helpers/skipControls";
29+
30+
// properties
31+
export interface Props {
32+
playerQueue: PlayerQueue | undefined;
33+
curQueueItem: QueueItem | undefined;
34+
skipAmount: number;
35+
isVisible?: boolean;
36+
icon?: ResponsiveIconProps;
37+
}
38+
withDefaults(defineProps<Props>(), {
39+
isVisible: true,
40+
icon: undefined,
41+
});
42+
</script>
43+
44+
<style scoped>
45+
.skip-button-content {
46+
position: relative;
47+
display: flex;
48+
align-items: center;
49+
justify-content: center;
50+
}
51+
52+
.skip-amount {
53+
position: absolute;
54+
font-size: 10px;
55+
font-weight: bold;
56+
bottom: -2px;
57+
right: -2px;
58+
background: rgba(0, 0, 0, 0.7);
59+
border-radius: 8px;
60+
padding: 1px 3px;
61+
min-width: 16px;
62+
text-align: center;
63+
}
64+
</style>
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
<template>
2+
<!-- skip forward button -->
3+
<ResponsiveIcon
4+
v-if="isVisible && skipAmount > 0"
5+
v-bind="icon"
6+
:disabled="!playerQueue?.active || !curQueueItem"
7+
icon="mdi-fast-forward"
8+
:type="'btn'"
9+
:title="$t('skip_forward_seconds', [skipAmount])"
10+
@click="
11+
playerQueue && skipControlManager.skip(playerQueue.queue_id, skipAmount)
12+
"
13+
>
14+
<template #default>
15+
<div class="skip-button-content">
16+
<v-icon>mdi-fast-forward</v-icon>
17+
<span class="skip-amount">{{ skipAmount }}</span>
18+
</div>
19+
</template>
20+
</ResponsiveIcon>
21+
</template>
22+
23+
<script setup lang="ts">
24+
import { PlayerQueue, QueueItem } from "@/plugins/api/interfaces";
25+
import ResponsiveIcon, {
26+
ResponsiveIconProps,
27+
} from "@/components/mods/ResponsiveIcon.vue";
28+
import { skipControlManager } from "@/helpers/skipControls";
29+
30+
// properties
31+
export interface Props {
32+
playerQueue: PlayerQueue | undefined;
33+
curQueueItem: QueueItem | undefined;
34+
skipAmount: number;
35+
isVisible?: boolean;
36+
icon?: ResponsiveIconProps;
37+
}
38+
withDefaults(defineProps<Props>(), {
39+
isVisible: true,
40+
icon: undefined,
41+
});
42+
</script>
43+
44+
<style scoped>
45+
.skip-button-content {
46+
position: relative;
47+
display: flex;
48+
align-items: center;
49+
justify-content: center;
50+
}
51+
52+
.skip-amount {
53+
position: absolute;
54+
font-size: 10px;
55+
font-weight: bold;
56+
bottom: -2px;
57+
right: -2px;
58+
background: rgba(0, 0, 0, 0.7);
59+
border-radius: 8px;
60+
padding: 1px 3px;
61+
min-width: 16px;
62+
text-align: center;
63+
}
64+
</style>

src/layouts/default/PlayerOSD/PlayerControls.vue

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,15 @@
2323
:icon="visibleComponents.previous.icon"
2424
/>
2525
</div>
26+
<!-- skip back button for audiobooks/podcasts -->
27+
<div v-if="isAudiobookOrPodcast" class="player-controls-elements">
28+
<SkipBackBtn
29+
:player-queue="store.activePlayerQueue"
30+
:cur-queue-item="store.curQueueItem"
31+
:skip-amount="skipAmount"
32+
class="media-controls-item"
33+
/>
34+
</div>
2635
<!-- play/pause button -->
2736
<div v-if="visibleComponents && visibleComponents.play?.isVisible">
2837
<PlayBtn
@@ -33,6 +42,15 @@
3342
:icon="visibleComponents.play.icon"
3443
/>
3544
</div>
45+
<!-- skip forward button for audiobooks/podcasts -->
46+
<div v-if="isAudiobookOrPodcast" class="player-controls-elements">
47+
<SkipForwardBtn
48+
:player-queue="store.activePlayerQueue"
49+
:cur-queue-item="store.curQueueItem"
50+
:skip-amount="skipAmount"
51+
class="media-controls-item"
52+
/>
53+
</div>
3654
<!-- next button -->
3755
<div
3856
v-if="visibleComponents && visibleComponents.next?.isVisible"
@@ -69,6 +87,10 @@ import NextBtn from "./PlayerControlBtn/NextBtn.vue";
6987
import PlayBtn from "./PlayerControlBtn/PlayBtn.vue";
7088
import PreviousBtn from "./PlayerControlBtn/PreviousBtn.vue";
7189
import ShuffleBtn from "./PlayerControlBtn/ShuffleBtn.vue";
90+
import SkipForwardBtn from "./PlayerControlBtn/SkipForwardBtn.vue";
91+
import SkipBackBtn from "./PlayerControlBtn/SkipBackBtn.vue";
92+
import { MediaType } from "@/plugins/api/interfaces";
93+
import { computed } from "vue";
7294
7395
// properties
7496
export interface Props {
@@ -105,6 +127,23 @@ withDefaults(defineProps<Props>(), {
105127
next: { isVisible: true },
106128
}),
107129
});
130+
131+
// Check if current media is audiobook or podcast
132+
const isAudiobookOrPodcast = computed(() => {
133+
const mediaType = store.curQueueItem?.media_item?.media_type;
134+
return (
135+
mediaType === MediaType.AUDIOBOOK ||
136+
mediaType === MediaType.PODCAST ||
137+
mediaType === MediaType.PODCAST_EPISODE
138+
);
139+
});
140+
141+
// Get configured skip amount from settings
142+
const skipAmount = computed(() => {
143+
return parseInt(
144+
localStorage.getItem("frontend.settings.audiobook_skip_seconds") || "30",
145+
);
146+
});
108147
</script>
109148

110149
<style>

src/layouts/default/PlayerOSD/PlayerFullscreen.vue

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -415,12 +415,28 @@
415415
class="media-controls-item"
416416
max-height="45px"
417417
/>
418+
<SkipBackBtn
419+
v-if="isAudiobookOrPodcast"
420+
:player-queue="store.activePlayerQueue"
421+
:cur-queue-item="store.curQueueItem"
422+
:skip-amount="skipAmount"
423+
class="media-controls-item"
424+
max-height="45px"
425+
/>
418426
<PlayBtn
419427
:player="store.activePlayer"
420428
:player-queue="store.activePlayerQueue"
421429
class="media-controls-item"
422430
max-height="70px"
423431
/>
432+
<SkipForwardBtn
433+
v-if="isAudiobookOrPodcast"
434+
:player-queue="store.activePlayerQueue"
435+
:cur-queue-item="store.curQueueItem"
436+
:skip-amount="skipAmount"
437+
class="media-controls-item"
438+
max-height="45px"
439+
/>
424440
<NextBtn
425441
:player="store.activePlayer"
426442
:player-queue="store.activePlayerQueue"
@@ -553,6 +569,10 @@ import {
553569
Track,
554570
} from "@/plugins/api/interfaces";
555571
import { getBreakpointValue } from "@/plugins/breakpoint";
572+
import SkipForwardBtn from "@/layouts/default/PlayerOSD/PlayerControlBtn/SkipForwardBtn.vue";
573+
import SkipBackBtn from "@/layouts/default/PlayerOSD/PlayerControlBtn/SkipBackBtn.vue";
574+
import QueueBtn from "./PlayerControlBtn/QueueBtn.vue";
575+
import SpeakerBtn from "./PlayerControlBtn/SpeakerBtn.vue";
556576
import { eventbus } from "@/plugins/eventbus";
557577
import { $t } from "@/plugins/i18n";
558578
import router from "@/plugins/router";
@@ -569,8 +589,6 @@ import {
569589
} from "vue";
570590
import { useDisplay } from "vuetify";
571591
import { ContextMenuItem } from "../ItemContextMenu.vue";
572-
import QueueBtn from "./PlayerControlBtn/QueueBtn.vue";
573-
import SpeakerBtn from "./PlayerControlBtn/SpeakerBtn.vue";
574592
import PlayerTimeline from "./PlayerTimeline.vue";
575593
import { getSourceName } from "@/plugins/api/helpers";
576594
import computeElapsedTime from "@/helpers/elapsed";
@@ -727,6 +745,23 @@ watch(
727745
},
728746
);
729747
748+
// Check if current media is audiobook or podcast
749+
const isAudiobookOrPodcast = computed(() => {
750+
const mediaType = store.curQueueItem?.media_item?.media_type;
751+
return (
752+
mediaType === MediaType.AUDIOBOOK ||
753+
mediaType === MediaType.PODCAST ||
754+
mediaType === MediaType.PODCAST_EPISODE
755+
);
756+
});
757+
758+
// Get configured skip amount from settings
759+
const skipAmount = computed(() => {
760+
return parseInt(
761+
localStorage.getItem("frontend.settings.audiobook_skip_seconds") || "30",
762+
);
763+
});
764+
730765
const titleFontSize = computed(() => {
731766
switch (name.value) {
732767
case "xs":

src/translations/en.json

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -326,7 +326,8 @@
326326
"web_player": "Web Player",
327327
"protocol_settings": "Protocol Settings",
328328
"protocol_output_settings": "Enable {0} support",
329-
"dsp": "Digital Signal Processing (DSP)"
329+
"dsp": "Digital Signal Processing (DSP)",
330+
"audiobooks_podcasts": "Audiobooks & Podcasts"
330331
},
331332
"tts_pre_announce": {
332333
"label": "Pre-announce TTS announcements",
@@ -705,7 +706,11 @@
705706
"onboarding_add_player": "Add Players",
706707
"onboarding_footer": "You can always add more providers later from this settings page.",
707708
"remote_access_qr_code": "QR Code",
708-
"remote_access_qr_code_description": "Scan with your phone camera to connect instantly."
709+
"remote_access_qr_code_description": "Scan with your phone camera to connect instantly.",
710+
"audiobook_skip_seconds": {
711+
"label": "Audiobook\/Podcast skip amount",
712+
"description": "Number of seconds to skip forward or backward when using skip controls during audiobook or podcast playback."
713+
}
709714
},
710715
"show_info": "Show info",
711716
"show_select_boxes": "Show selection boxes",
@@ -835,6 +840,8 @@
835840
"dont_stop_the_music_enable": "Enable 'Don't stop the music!'",
836841
"dont_stop_the_music_disable": "Disable 'Don't stop the music!'",
837842
"open_dsp_settings": "Open DSP settings",
843+
"skip_forward_seconds": "Skip forward {0} seconds",
844+
"skip_backward_seconds": "Skip backward {0} seconds",
838845
"audiobook": "Audiobook",
839846
"audiobooks": "Audiobooks",
840847
"chapter": "Chapter",

src/views/settings/FrontendConfig.vue

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,25 @@ onMounted(() => {
164164
value:
165165
localStorage.getItem("frontend.settings.mobile_sidebar_side") || "left",
166166
},
167+
{
168+
key: "audiobook_skip_seconds",
169+
type: ConfigEntryType.INTEGER,
170+
label: "audiobook_skip_seconds",
171+
default_value: 30,
172+
required: false,
173+
options: [
174+
{ title: "10", value: 10 },
175+
{ title: "15", value: 15 },
176+
{ title: "30", value: 30 },
177+
{ title: "60", value: 60 },
178+
],
179+
multi_value: false,
180+
category: "audiobooks_podcasts",
181+
value: parseInt(
182+
localStorage.getItem("frontend.settings.audiobook_skip_seconds") ||
183+
"30",
184+
),
185+
},
167186
];
168187
169188
// Add web player settings (if not running in companion mode)

0 commit comments

Comments
 (0)