Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions src/helpers/skipControls.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import api from "@/plugins/api";
import { store } from "@/plugins/store";

// Shared skip control logic with throttling for smooth multiple clicks
class SkipControlManager {
private lastSeekPos: number | undefined = undefined;
private lastSeekPosTimeoutHandle: any = undefined;
private readonly TIMEOUT_MS = 2000;

private lastSeekPosTimeout() {
clearTimeout(this.lastSeekPosTimeoutHandle);
this.lastSeekPosTimeoutHandle = setTimeout(() => {
this.lastSeekPos = undefined;
this.lastSeekPosTimeoutHandle = undefined;
}, this.TIMEOUT_MS);
}

public skip(queueId: string, skipSeconds: number) {
const currentTime =
this.lastSeekPos || store.activePlayerQueue?.elapsed_time || 0;
const newTime = Math.max(0, currentTime + skipSeconds);

this.lastSeekPos = newTime;
this.lastSeekPosTimeout();

// Send the seek command immediately for the accumulated position
api.playerCommandSeek(
store.activePlayer?.player_id || "",
Math.round(newTime),
);
}
}

export const skipControlManager = new SkipControlManager();
17 changes: 15 additions & 2 deletions src/layouts/default/PlayerOSD/PlayerBrowserMediaControls.vue
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
import audio from "@/assets/almost_silent.mp3";
import { useMediaBrowserMetaData } from "@/helpers/useMediaBrowserMetaData";
import api from "@/plugins/api";
import { PlaybackState } from "@/plugins/api/interfaces";
import { MediaType, PlayerState } from "@/plugins/api/interfaces";
import { store } from "@/plugins/store";
import { onMounted, ref, watch } from "vue";

Expand Down Expand Up @@ -106,7 +106,20 @@ const seekHandler = function (
if (evt.action === "seekto" && evt.seekTime) {
to = evt.seekTime;
} else if (evt.action === "seekforward" || evt.action === "seekbackward") {
const offset = evt.seekOffset || 10;
// Use configurable skip amount for audiobooks/podcasts, fallback to 10 seconds for music
const mediaType = store.curQueueItem?.media_item?.media_type;
const isAudiobookOrPodcast =
mediaType === MediaType.AUDIOBOOK ||
mediaType === MediaType.PODCAST ||
mediaType === MediaType.PODCAST_EPISODE;
const defaultSkipAmount = isAudiobookOrPodcast
? parseInt(
localStorage.getItem("frontend.settings.audiobook_skip_seconds") ||
"30",
)
: 10;

const offset = evt.seekOffset || defaultSkipAmount;
const elapsed_time = lastSeekPos || store.activePlayerQueue?.elapsed_time;
if (!elapsed_time) return;
if (evt.action === "seekbackward") {
Expand Down
64 changes: 64 additions & 0 deletions src/layouts/default/PlayerOSD/PlayerControlBtn/SkipBackBtn.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
<template>
<!-- skip back button -->
<ResponsiveIcon
v-if="isVisible && skipAmount > 0"
v-bind="icon"
:disabled="!playerQueue?.active || !curQueueItem"
icon="mdi-rewind"
:type="'btn'"
:title="$t('skip_backward_seconds', [skipAmount])"
@click="
playerQueue && skipControlManager.skip(playerQueue.queue_id, -skipAmount)
"
>
<template #default>
<div class="skip-button-content">
<v-icon>mdi-rewind</v-icon>
<span class="skip-amount">{{ skipAmount }}</span>
</div>
</template>
</ResponsiveIcon>
</template>

<script setup lang="ts">
import { PlayerQueue, QueueItem } from "@/plugins/api/interfaces";
import ResponsiveIcon, {
ResponsiveIconProps,
} from "@/components/mods/ResponsiveIcon.vue";
import { skipControlManager } from "@/helpers/skipControls";

// properties
export interface Props {
playerQueue: PlayerQueue | undefined;
curQueueItem: QueueItem | undefined;
skipAmount: number;
isVisible?: boolean;
icon?: ResponsiveIconProps;
}
withDefaults(defineProps<Props>(), {
isVisible: true,
icon: undefined,
});
</script>

<style scoped>
.skip-button-content {
position: relative;
display: flex;
align-items: center;
justify-content: center;
}

.skip-amount {
position: absolute;
font-size: 10px;
font-weight: bold;
bottom: -2px;
right: -2px;
background: rgba(0, 0, 0, 0.7);
border-radius: 8px;
padding: 1px 3px;
min-width: 16px;
text-align: center;
}
</style>
64 changes: 64 additions & 0 deletions src/layouts/default/PlayerOSD/PlayerControlBtn/SkipForwardBtn.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
<template>
<!-- skip forward button -->
<ResponsiveIcon
v-if="isVisible && skipAmount > 0"
v-bind="icon"
:disabled="!playerQueue?.active || !curQueueItem"
icon="mdi-fast-forward"
:type="'btn'"
:title="$t('skip_forward_seconds', [skipAmount])"
@click="
playerQueue && skipControlManager.skip(playerQueue.queue_id, skipAmount)
"
>
<template #default>
<div class="skip-button-content">
<v-icon>mdi-fast-forward</v-icon>
<span class="skip-amount">{{ skipAmount }}</span>
</div>
</template>
</ResponsiveIcon>
</template>

<script setup lang="ts">
import { PlayerQueue, QueueItem } from "@/plugins/api/interfaces";
import ResponsiveIcon, {
ResponsiveIconProps,
} from "@/components/mods/ResponsiveIcon.vue";
import { skipControlManager } from "@/helpers/skipControls";

// properties
export interface Props {
playerQueue: PlayerQueue | undefined;
curQueueItem: QueueItem | undefined;
skipAmount: number;
isVisible?: boolean;
icon?: ResponsiveIconProps;
}
withDefaults(defineProps<Props>(), {
isVisible: true,
icon: undefined,
});
</script>

<style scoped>
.skip-button-content {
position: relative;
display: flex;
align-items: center;
justify-content: center;
}

.skip-amount {
position: absolute;
font-size: 10px;
font-weight: bold;
bottom: -2px;
right: -2px;
background: rgba(0, 0, 0, 0.7);
border-radius: 8px;
padding: 1px 3px;
min-width: 16px;
text-align: center;
}
</style>
39 changes: 39 additions & 0 deletions src/layouts/default/PlayerOSD/PlayerControls.vue
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,15 @@
:icon="visibleComponents.previous.icon"
/>
</div>
<!-- skip back button for audiobooks/podcasts -->
<div v-if="isAudiobookOrPodcast" class="player-controls-elements">
<SkipBackBtn
:player-queue="store.activePlayerQueue"
:cur-queue-item="store.curQueueItem"
:skip-amount="skipAmount"
class="media-controls-item"
/>
</div>
<!-- play/pause button -->
<div v-if="visibleComponents && visibleComponents.play?.isVisible">
<PlayBtn
Expand All @@ -33,6 +42,15 @@
:icon="visibleComponents.play.icon"
/>
</div>
<!-- skip forward button for audiobooks/podcasts -->
<div v-if="isAudiobookOrPodcast" class="player-controls-elements">
<SkipForwardBtn
:player-queue="store.activePlayerQueue"
:cur-queue-item="store.curQueueItem"
:skip-amount="skipAmount"
class="media-controls-item"
/>
</div>
<!-- next button -->
<div
v-if="visibleComponents && visibleComponents.next?.isVisible"
Expand Down Expand Up @@ -68,7 +86,11 @@ import ShuffleBtn from "./PlayerControlBtn/ShuffleBtn.vue";
import PlayBtn from "./PlayerControlBtn/PlayBtn.vue";
import PreviousBtn from "./PlayerControlBtn/PreviousBtn.vue";
import NextBtn from "./PlayerControlBtn/NextBtn.vue";
import SkipForwardBtn from "./PlayerControlBtn/SkipForwardBtn.vue";
import SkipBackBtn from "./PlayerControlBtn/SkipBackBtn.vue";
import { store } from "@/plugins/store";
import { MediaType } from "@/plugins/api/interfaces";
import { computed } from "vue";

// properties
export interface Props {
Expand Down Expand Up @@ -106,6 +128,23 @@ withDefaults(defineProps<Props>(), {
next: { isVisible: true },
}),
});

// Check if current media is audiobook or podcast
const isAudiobookOrPodcast = computed(() => {
const mediaType = store.curQueueItem?.media_item?.media_type;
return (
mediaType === MediaType.AUDIOBOOK ||
mediaType === MediaType.PODCAST ||
mediaType === MediaType.PODCAST_EPISODE
);
});

// Get configured skip amount from settings
const skipAmount = computed(() => {
return parseInt(
localStorage.getItem("frontend.settings.audiobook_skip_seconds") || "30",
);
});
</script>

<style>
Expand Down
55 changes: 55 additions & 0 deletions src/layouts/default/PlayerOSD/PlayerFullscreen.vue
Original file line number Diff line number Diff line change
Expand Up @@ -493,12 +493,28 @@
class="media-controls-item"
max-height="45px"
/>
<SkipBackBtn
v-if="isAudiobookOrPodcast"
:player-queue="store.activePlayerQueue"
:cur-queue-item="store.curQueueItem"
:skip-amount="skipAmount"
class="media-controls-item"
max-height="45px"
/>
<PlayBtn
:player="store.activePlayer"
:player-queue="store.activePlayerQueue"
class="media-controls-item"
max-height="100px"
/>
<SkipForwardBtn
v-if="isAudiobookOrPodcast"
:player-queue="store.activePlayerQueue"
:cur-queue-item="store.curQueueItem"
:skip-amount="skipAmount"
class="media-controls-item"
max-height="45px"
/>
<NextBtn
:player="store.activePlayer"
:player-queue="store.activePlayerQueue"
Expand Down Expand Up @@ -620,6 +636,28 @@
Track,
} from "@/plugins/api/interfaces";
import { getBreakpointValue } from "@/plugins/breakpoint";
import Button from "@/components/mods/Button.vue";

Check failure on line 639 in src/layouts/default/PlayerOSD/PlayerFullscreen.vue

View workflow job for this annotation

GitHub Actions / Lint

'Button' is already defined
import ResponsiveIcon from "@/components/mods/ResponsiveIcon.vue";

Check failure on line 640 in src/layouts/default/PlayerOSD/PlayerFullscreen.vue

View workflow job for this annotation

GitHub Actions / Lint

'ResponsiveIcon' is already defined
import ListItem from "@/components/mods/ListItem.vue";

Check failure on line 641 in src/layouts/default/PlayerOSD/PlayerFullscreen.vue

View workflow job for this annotation

GitHub Actions / Lint

'ListItem' is already defined
import LyricsViewer from "@/components/LyricsViewer.vue";

Check failure on line 642 in src/layouts/default/PlayerOSD/PlayerFullscreen.vue

View workflow job for this annotation

GitHub Actions / Lint

'LyricsViewer' is already defined
import vuetify from "@/plugins/vuetify";
import PlayBtn from "@/layouts/default/PlayerOSD/PlayerControlBtn/PlayBtn.vue";

Check failure on line 644 in src/layouts/default/PlayerOSD/PlayerFullscreen.vue

View workflow job for this annotation

GitHub Actions / Lint

'PlayBtn' is already defined
import NextBtn from "@/layouts/default/PlayerOSD/PlayerControlBtn/NextBtn.vue";

Check failure on line 645 in src/layouts/default/PlayerOSD/PlayerFullscreen.vue

View workflow job for this annotation

GitHub Actions / Lint

'NextBtn' is already defined
import PreviousBtn from "@/layouts/default/PlayerOSD/PlayerControlBtn/PreviousBtn.vue";

Check failure on line 646 in src/layouts/default/PlayerOSD/PlayerFullscreen.vue

View workflow job for this annotation

GitHub Actions / Lint

'PreviousBtn' is already defined
import SkipForwardBtn from "@/layouts/default/PlayerOSD/PlayerControlBtn/SkipForwardBtn.vue";
import SkipBackBtn from "@/layouts/default/PlayerOSD/PlayerControlBtn/SkipBackBtn.vue";
import ShuffleBtn from "@/layouts/default/PlayerOSD/PlayerControlBtn/ShuffleBtn.vue";

Check failure on line 649 in src/layouts/default/PlayerOSD/PlayerFullscreen.vue

View workflow job for this annotation

GitHub Actions / Lint

'ShuffleBtn' is already defined
import RepeatBtn from "@/layouts/default/PlayerOSD/PlayerControlBtn/RepeatBtn.vue";

Check failure on line 650 in src/layouts/default/PlayerOSD/PlayerFullscreen.vue

View workflow job for this annotation

GitHub Actions / Lint

'RepeatBtn' is already defined
import PlayerVolume from "@/layouts/default/PlayerOSD/PlayerVolume.vue";

Check failure on line 651 in src/layouts/default/PlayerOSD/PlayerFullscreen.vue

View workflow job for this annotation

GitHub Actions / Lint

'PlayerVolume' is already defined
import QueueBtn from "./PlayerControlBtn/QueueBtn.vue";
import QualityDetailsBtn from "@/components/QualityDetailsBtn.vue";
import MarqueeText from "@/components/MarqueeText.vue";
import SpeakerBtn from "./PlayerControlBtn/SpeakerBtn.vue";
import { MarqueeTextSync } from "@/helpers/marquee_text_sync";
import {
imgCoverLight,
imgCoverDark,
} from "@/components/QualityDetailsBtn.vue";
import { eventbus } from "@/plugins/eventbus";
import { $t } from "@/plugins/i18n";
import router from "@/plugins/router";
Expand Down Expand Up @@ -679,6 +717,23 @@
);
});

// Check if current media is audiobook or podcast
const isAudiobookOrPodcast = computed(() => {
const mediaType = store.curQueueItem?.media_item?.media_type;
return (
mediaType === MediaType.AUDIOBOOK ||
mediaType === MediaType.PODCAST ||
mediaType === MediaType.PODCAST_EPISODE
);
});

// Get configured skip amount from settings
const skipAmount = computed(() => {
return parseInt(
localStorage.getItem("frontend.settings.audiobook_skip_seconds") || "30",
);
});

const titleFontSize = computed(() => {
switch (name.value) {
case "xs":
Expand Down
9 changes: 8 additions & 1 deletion src/translations/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -290,7 +290,8 @@
"announcements": "Announcements configuration",
"airplay": "AirPlay specific settings",
"player_controls": "Player controls",
"presets": "Presets"
"presets": "Presets",
"audiobooks_podcasts": "Audiobooks & Podcasts"
},
"tts_pre_announce": {
"label": "Pre-announce TTS announcements",
Expand Down Expand Up @@ -516,6 +517,10 @@
"enable_builtin_player": {
"label": "Allow playback to this device\/browser",
"description": "Enable the (experimental) built-in player for this device\/browser, which allows you to stream all Music Assistant content directly to this device."
},
"audiobook_skip_seconds": {
"label": "Audiobook\/Podcast skip amount",
"description": "Number of seconds to skip forward or backward when using skip controls during audiobook or podcast playback."
}
},
"show_info": "Show info",
Expand Down Expand Up @@ -644,6 +649,8 @@
"dont_stop_the_music_enable": "Enable 'Don't stop the music!'",
"dont_stop_the_music_disable": "Disable 'Don't stop the music!'",
"open_dsp_settings": "Open DSP settings",
"skip_forward_seconds": "Skip forward {0} seconds",
"skip_backward_seconds": "Skip backward {0} seconds",
"audiobook": "Audiobook",
"audiobooks": "Audiobooks",
"chapter": "Chapter",
Expand Down
Loading
Loading