Skip to content

Commit 3fd00c6

Browse files
authored
Implement control of other sources playing on a player (#1195)
1 parent 6c33d82 commit 3fd00c6

File tree

9 files changed

+354
-25
lines changed

9 files changed

+354
-25
lines changed

src/helpers/elapsed.test.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2+
import computeElapsedTime from "./elapsed";
3+
import { PlaybackState } from "../plugins/api/interfaces";
4+
5+
describe("computeElapsedTime", () => {
6+
const REAL_DATE_NOW = Date.now;
7+
8+
beforeEach(() => {
9+
vi.useFakeTimers();
10+
});
11+
12+
afterEach(() => {
13+
vi.useRealTimers();
14+
// @ts-ignore
15+
Date.now = REAL_DATE_NOW;
16+
});
17+
18+
it("returns undefined when elapsed_time undefined", () => {
19+
expect(computeElapsedTime(undefined, undefined)).toBeUndefined();
20+
});
21+
22+
it("returns same value when playback is paused", () => {
23+
const t0 = 1000; // seconds
24+
const ts = 1600000000; // seconds
25+
expect(computeElapsedTime(t0, ts, PlaybackState.PAUSED)).toBe(t0);
26+
});
27+
28+
it("advances elapsed when playing using timestamp difference", () => {
29+
const t0 = 10; // seconds
30+
const ts = 1_600_000_000; // seconds
31+
// move time forward by 5.5 seconds
32+
vi.setSystemTime(ts * 1000 + 5500);
33+
34+
const result = computeElapsedTime(t0, ts, PlaybackState.PLAYING);
35+
// allow small float delta
36+
expect(result).toBeGreaterThanOrEqual(15.499);
37+
expect(result).toBeLessThanOrEqual(15.501);
38+
});
39+
40+
it("handles elapsed_time_last_updated provided in seconds (not ms)", () => {
41+
const t0 = 30; // seconds
42+
const ts_seconds = 1_600_000_000; // seconds
43+
// set now to ts_seconds + 2.5 seconds
44+
vi.setSystemTime(ts_seconds * 1000 + 2500);
45+
46+
const result = computeElapsedTime(t0, ts_seconds, PlaybackState.PLAYING);
47+
expect(result).toBeGreaterThanOrEqual(32.499);
48+
expect(result).toBeLessThanOrEqual(32.501);
49+
});
50+
51+
it("handles elapsed_time provided in milliseconds (not seconds)", () => {
52+
// server sends seconds; elapsed_time in seconds
53+
const t0 = 30; // seconds
54+
const ts = 1_600_000_000; // seconds
55+
vi.setSystemTime(ts * 1000 + 1000);
56+
57+
const result = computeElapsedTime(t0, ts, PlaybackState.PLAYING);
58+
expect(result).toBeGreaterThanOrEqual(31.0 - 0.001);
59+
expect(result).toBeLessThanOrEqual(31.0 + 0.001);
60+
});
61+
});

src/helpers/elapsed.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
/**
2+
* computeElapsedTime
3+
*
4+
* Calculate the current elapsed playback time in seconds from a stored
5+
* `elapsed_time` value and the `elapsed_time_last_updated` UTC timestamp
6+
* (milliseconds since epoch). The function assumes:
7+
* - elapsed_time: seconds (may be fractional)
8+
* - elapsed_time_last_updated: ms since epoch (UTC)
9+
*
10+
* If `playbackState` is not PLAYING, the function returns the provided
11+
* `elapsed_time` without applying the time-delta. This avoids advancing the
12+
* position while paused/stopped.
13+
*/
14+
import { PlaybackState } from "../plugins/api/interfaces";
15+
16+
export function computeElapsedTime(
17+
elapsed_time: number | undefined,
18+
elapsed_time_last_updated: number | undefined,
19+
playbackState?: PlaybackState,
20+
): number | undefined {
21+
if (elapsed_time === undefined || elapsed_time_last_updated === undefined) {
22+
return elapsed_time;
23+
}
24+
25+
// Only advance the elapsed time when the player is actually playing.
26+
if (playbackState !== undefined && playbackState !== PlaybackState.PLAYING) {
27+
return elapsed_time;
28+
}
29+
// The server sends `elapsed_time_last_updated` in seconds since epoch.
30+
// Convert to milliseconds for Date.now() math.
31+
const lastUpdatedMs = elapsed_time_last_updated * 1000;
32+
33+
const nowMs = Date.now();
34+
const deltaMs = Math.max(0, nowMs - lastUpdatedMs);
35+
const deltaSeconds = deltaMs / 1000;
36+
37+
// elapsed_time is expected to be in seconds.
38+
return elapsed_time + deltaSeconds;
39+
}
40+
41+
export default computeElapsedTime;

src/helpers/useMediaBrowserMetaData.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,12 @@ export function useMediaBrowserMetaData(player_id?: string) {
113113
return;
114114
}
115115
const duration = playerQueue.value?.current_item?.duration || 1;
116-
const position = Math.min(duration, playerQueue.value?.elapsed_time || 0);
116+
const position = Math.min(
117+
duration,
118+
playerQueue.value?.elapsed_time != null
119+
? playerQueue.value?.elapsed_time
120+
: 0,
121+
);
117122
navigator.mediaSession.setPositionState({
118123
duration: duration,
119124
playbackRate: 1.0,

src/layouts/default/PlayerOSD/PlayerBrowserMediaControls.vue

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -107,8 +107,9 @@ const seekHandler = function (
107107
to = evt.seekTime;
108108
} else if (evt.action === "seekforward" || evt.action === "seekbackward") {
109109
const offset = evt.seekOffset || 10;
110-
const elapsed_time = lastSeekPos || store.activePlayerQueue?.elapsed_time;
111-
if (!elapsed_time) return;
110+
const elapsed_time =
111+
lastSeekPos != null ? lastSeekPos : store.activePlayerQueue?.elapsed_time;
112+
if (elapsed_time == null) return;
112113
if (evt.action === "seekbackward") {
113114
to = elapsed_time - offset;
114115
} else {

src/layouts/default/PlayerOSD/PlayerFullscreen.vue

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -638,6 +638,7 @@ import {
638638
computed,
639639
onBeforeUnmount,
640640
onMounted,
641+
onUnmounted,
641642
ref,
642643
watch,
643644
watchEffect,

src/layouts/default/PlayerOSD/PlayerTimeline.vue

Lines changed: 143 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,10 @@
66
:disabled="!canSeek"
77
style="width: 100%"
88
:min="0"
9-
:max="store.curQueueItem?.duration"
9+
:max="
10+
store.curQueueItem?.duration ||
11+
store.activePlayer?.current_media?.duration
12+
"
1013
hide-details
1114
:track-size="4"
1215
:thumb-size="isThumbHidden ? 0 : 10"
@@ -63,7 +66,8 @@ import { MediaType } from "@/plugins/api/interfaces";
6366
import { store } from "@/plugins/store";
6467
import { useActiveSource } from "@/composables/activeSource";
6568
import { formatDuration } from "@/helpers/utils";
66-
import { ref, computed, watch, toRef } from "vue";
69+
import { ref, computed, watch, toRef, onMounted, onUnmounted } from "vue";
70+
import computeElapsedTime from "@/helpers/elapsed";
6771
6872
// properties
6973
export interface Props {
@@ -84,52 +88,173 @@ const isThumbHidden = ref(true);
8488
const isDragging = ref(false);
8589
const curTimeValue = ref(0);
8690
const tempTime = ref(0);
91+
// ticking ref to force recompute of elapsed time (Date.now() is non-reactive)
92+
const nowTick = ref(0);
93+
let tickTimer: ReturnType<typeof setInterval> | null = null;
94+
95+
const startTick = (interval = 500) => {
96+
if (!tickTimer)
97+
tickTimer = setInterval(() => (nowTick.value = Date.now()), interval);
98+
};
99+
100+
const stopTick = () => {
101+
if (tickTimer) {
102+
clearInterval(tickTimer);
103+
tickTimer = null;
104+
}
105+
};
106+
107+
onUnmounted(() => {
108+
stopTick();
109+
});
87110
88111
// computed properties
89112
const canSeek = computed(() => {
90113
// Check if active source allows seeking
91-
// commented out to fix issue first with actually retrieving the elapsed time of the source
92-
// if (activeSource.value) {
93-
// return activeSource.value.can_seek;
94-
// }
114+
if (activeSource.value) {
115+
// When an active external source is present, only allow seeking if the
116+
// source reports that it supports seeking (can_seek) AND the current
117+
// media (or queue item) has a known duration. We also keep checks for
118+
// powered state and radio streams.
119+
if (store.activePlayer?.powered === false) return false;
120+
121+
// If queue is active prefer queue duration
122+
const queueHasDuration = !!store.curQueueItem?.duration;
123+
const currentMediaDuration = store.activePlayer?.current_media?.duration;
124+
const currentMediaHasDuration = !!currentMediaDuration;
125+
126+
// Disallow seeking for radio streams
127+
const isRadio =
128+
store.curQueueItem?.media_item?.media_type == MediaType.RADIO ||
129+
store.activePlayer?.current_media?.media_type == MediaType.RADIO;
130+
if (isRadio) return false;
131+
132+
// If the active source reports can_seek, allow seeking when there is a
133+
// duration available (either queue item or current_media).
134+
if (
135+
activeSource.value.can_seek &&
136+
(queueHasDuration || currentMediaHasDuration)
137+
)
138+
return true;
139+
140+
return false;
141+
}
95142
96143
if (store.curQueueItem?.media_item?.media_type == MediaType.RADIO)
97144
return false;
98145
if (store.activePlayer?.powered == false) return false;
99146
if (!store.curQueueItem) return false;
100147
if (!store.curQueueItem.media_item) return false;
148+
// Duration must be present and truthy (non-zero) for seeking when using the
149+
// local queue. Elapsed_time may be 0, but duration of 0 isn't seekable.
101150
if (!store.curQueueItem.duration) return false;
102151
103152
// Default to true if no active source (queue control)
104153
return true;
105154
});
106155
107156
const playerCurTimeStr = computed(() => {
108-
if (!store.curQueueItem) return "0:00";
109-
if (showRemainingTime.value) {
110-
return `-${formatDuration(
111-
store.curQueueItem.duration - curQueueItemTime.value,
112-
)}`;
113-
} else {
157+
// If there's an active queue item use its duration arithmetic
158+
if (store.curQueueItem) {
159+
if (showRemainingTime.value) {
160+
return `-${formatDuration(
161+
store.curQueueItem.duration - curQueueItemTime.value,
162+
)}`;
163+
}
114164
return `${formatDuration(curQueueItemTime.value)}`;
115165
}
166+
167+
// No queue item: prefer current_media elapsed when available
168+
if (
169+
store.activePlayer?.current_media?.elapsed_time != null ||
170+
store.activePlayer?.elapsed_time != null
171+
) {
172+
const val = curQueueItemTime.value || 0;
173+
if (showRemainingTime.value && store.activePlayer?.current_media?.duration)
174+
return `-${formatDuration(
175+
store.activePlayer.current_media.duration - val,
176+
)}`;
177+
return `${formatDuration(val)}`;
178+
}
179+
180+
return "0:00";
116181
});
117182
118183
const playerTotalTimeStr = computed(() => {
119-
if (!store.curQueueItem) return "";
120-
if (!store.curQueueItem.duration) return "";
121-
if (store.curQueueItem.media_item?.media_type == MediaType.RADIO) return "";
122-
const totalSecs = store.curQueueItem.duration;
123-
return formatDuration(totalSecs);
184+
// Prefer queue item duration, fall back to current_media duration for external sources
185+
const duration =
186+
store.curQueueItem?.duration || store.activePlayer?.current_media?.duration;
187+
if (!duration) return "";
188+
// If radio/streaming with unknown duration, don't show
189+
const isRadio =
190+
store.curQueueItem?.media_item?.media_type == MediaType.RADIO ||
191+
store.activePlayer?.current_media?.media_type == MediaType.RADIO;
192+
if (isRadio) return "";
193+
return formatDuration(duration);
124194
});
125195
126196
const curQueueItemTime = computed(() => {
197+
// include nowTick.value so this computed re-evaluates periodically while mounted
198+
// and updates UI for fallback player-level current_media that relies on Date.now()
199+
void nowTick.value;
200+
201+
// Adaptive tick: only run the timer when we have a playing source that relies on time progression
202+
const isPlaying = store.activePlayer?.playback_state === "playing";
203+
const usingQueue = !!(
204+
store.activePlayerQueue && store.activePlayerQueue.active
205+
);
206+
const hasCurrentMedia =
207+
store.activePlayer?.current_media?.elapsed_time != null;
208+
209+
// Start ticking when playing and either using queue or external current_media
210+
if (isPlaying && (usingQueue || hasCurrentMedia)) startTick();
211+
else stopTick();
127212
if (isDragging.value) {
128213
// eslint-disable-next-line vue/no-side-effects-in-computed-properties
129214
tempTime.value = curTimeValue.value;
130215
return curTimeValue.value;
131216
}
132-
if (store.activePlayerQueue) return store.activePlayerQueue.elapsed_time;
217+
218+
// Prefer queue-level elapsed_time if available
219+
const queue = store.activePlayerQueue;
220+
if (queue?.elapsed_time != null && queue?.elapsed_time_last_updated != null) {
221+
const computed = computeElapsedTime(
222+
queue.elapsed_time,
223+
queue.elapsed_time_last_updated,
224+
store.activePlayer?.playback_state,
225+
);
226+
return computed ?? 0;
227+
}
228+
229+
// Fallback to player-level elapsed_time. This is used for external/3rd-party
230+
// sources currently playing on the player (not for Music Assistant queue
231+
// playback). Use the player-level fields when no activePlayerQueue is set.
232+
// Prefer current_media timing when available (external source playing on the player)
233+
if (
234+
store.activePlayer?.current_media?.elapsed_time != null &&
235+
store.activePlayer?.current_media?.elapsed_time_last_updated != null
236+
) {
237+
const computed = computeElapsedTime(
238+
store.activePlayer.current_media.elapsed_time,
239+
store.activePlayer.current_media.elapsed_time_last_updated,
240+
store.activePlayer?.playback_state,
241+
);
242+
return computed ?? 0;
243+
}
244+
245+
// Fall back to player-level elapsed_time (legacy / provider-level value)
246+
if (
247+
store.activePlayer?.elapsed_time != null &&
248+
store.activePlayer?.elapsed_time_last_updated != null
249+
) {
250+
const computed = computeElapsedTime(
251+
store.activePlayer.elapsed_time,
252+
store.activePlayer.elapsed_time_last_updated,
253+
store.activePlayer?.playback_state,
254+
);
255+
return computed ?? 0;
256+
}
257+
133258
return 0;
134259
});
135260

0 commit comments

Comments
 (0)