Playback fix
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 4m28s
Traceability Validation / Check Requirement Traces (push) Successful in 22s
🏗️ Build and Test JellyTau / Build Android APK (push) Successful in 18m37s
Build & Release / Run Tests (push) Successful in 4m12s
Build & Release / Build Linux (push) Successful in 16m20s
Build & Release / Build Android (push) Successful in 18m57s
Build & Release / Create Release (push) Successful in 13s
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 4m28s
Traceability Validation / Check Requirement Traces (push) Successful in 22s
🏗️ Build and Test JellyTau / Build Android APK (push) Successful in 18m37s
Build & Release / Run Tests (push) Successful in 4m12s
Build & Release / Build Linux (push) Successful in 16m20s
Build & Release / Build Android (push) Successful in 18m57s
Build & Release / Create Release (push) Successful in 13s
This commit is contained in:
@@ -1,10 +1,9 @@
|
||||
<!-- TRACES: UR-004, UR-005, UR-028 | DR-009 -->
|
||||
<script lang="ts">
|
||||
import { commands } from "$lib/api/bindings";
|
||||
import { playerController } from "$lib/player";
|
||||
import { truncateMiddle } from "$lib/utils/truncateMiddle";
|
||||
import { goto } from "$app/navigation";
|
||||
import type { MediaItem } from "$lib/api/types";
|
||||
import { auth } from "$lib/stores/auth";
|
||||
import { sleepTimerActive } from "$lib/stores/sleepTimer";
|
||||
import { queue, queueItems, currentQueueIndex } from "$lib/stores/queue";
|
||||
import {
|
||||
@@ -75,28 +74,28 @@
|
||||
async function handleSeekEnd() {
|
||||
seeking = false;
|
||||
seekPending = true; // Keep showing target position until backend catches up
|
||||
await commands.playerSeek(seekValue);
|
||||
await playerController.seek(seekValue);
|
||||
}
|
||||
|
||||
// Control handlers for Controls component
|
||||
async function handlePlayPause() {
|
||||
await commands.playerToggle();
|
||||
await playerController.toggle();
|
||||
}
|
||||
|
||||
async function handlePrevious() {
|
||||
await commands.playerPrevious();
|
||||
await playerController.previous();
|
||||
}
|
||||
|
||||
async function handleNext() {
|
||||
await commands.playerNext();
|
||||
await playerController.next();
|
||||
}
|
||||
|
||||
async function handleToggleShuffle() {
|
||||
await commands.playerToggleShuffle();
|
||||
await playerController.toggleShuffle();
|
||||
}
|
||||
|
||||
async function handleCycleRepeat() {
|
||||
await commands.playerCycleRepeat();
|
||||
await playerController.cycleRepeat();
|
||||
}
|
||||
|
||||
// Prefer album ID for artwork (all tracks in an album share the same cover)
|
||||
@@ -129,7 +128,7 @@
|
||||
async function handleQueueItemClick(index: number) {
|
||||
try {
|
||||
queue.skipTo(index);
|
||||
await commands.playerSkipTo(index);
|
||||
await playerController.skipTo(index);
|
||||
} catch (e) {
|
||||
console.error("Failed to skip to queue item:", e);
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
* @req: UR-010 - Control playback of Jellyfin remote sessions
|
||||
*/
|
||||
|
||||
import { commands } from "$lib/api/bindings";
|
||||
import { playerController } from "$lib/player";
|
||||
import { truncateMiddle } from "$lib/utils/truncateMiddle";
|
||||
import { goto } from "$app/navigation";
|
||||
import type { MediaItem } from "$lib/api/types";
|
||||
@@ -106,23 +106,23 @@
|
||||
|
||||
// Control handlers for Controls component
|
||||
async function handlePlayPause() {
|
||||
await commands.playerToggle();
|
||||
await playerController.toggle();
|
||||
}
|
||||
|
||||
async function handlePrevious() {
|
||||
await commands.playerPrevious();
|
||||
await playerController.previous();
|
||||
}
|
||||
|
||||
async function handleNext() {
|
||||
await commands.playerNext();
|
||||
await playerController.next();
|
||||
}
|
||||
|
||||
async function handleToggleShuffle() {
|
||||
await commands.playerToggleShuffle();
|
||||
await playerController.toggleShuffle();
|
||||
}
|
||||
|
||||
async function handleCycleRepeat() {
|
||||
await commands.playerCycleRepeat();
|
||||
await playerController.cycleRepeat();
|
||||
}
|
||||
|
||||
// Scrubbing (seek) handler
|
||||
@@ -134,7 +134,7 @@
|
||||
const newPosition = percent * displayDuration;
|
||||
|
||||
try {
|
||||
await commands.playerSeek(newPosition);
|
||||
await playerController.seek(newPosition);
|
||||
haptics.tap();
|
||||
} catch (err) {
|
||||
console.error("Failed to seek:", err);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { commands } from "$lib/api/bindings";
|
||||
import { playerController } from "$lib/player";
|
||||
import { truncateMiddle } from "$lib/utils/truncateMiddle";
|
||||
import { dndzone, SOURCES, TRIGGERS } from "svelte-dnd-action";
|
||||
import type { MediaItem } from "$lib/api/types";
|
||||
@@ -80,7 +80,7 @@
|
||||
queue.moveInQueue(fromIndex, toIndex);
|
||||
|
||||
// Sync with backend
|
||||
await commands.playerMoveInQueue(fromIndex, toIndex);
|
||||
await playerController.moveInQueue(fromIndex, toIndex);
|
||||
} catch (e) {
|
||||
console.error("Failed to move queue item:", e);
|
||||
// The store already updated optimistically, refresh if needed
|
||||
@@ -107,7 +107,7 @@
|
||||
e.stopPropagation();
|
||||
try {
|
||||
queue.removeFromQueue(index);
|
||||
await commands.playerRemoveFromQueue(index);
|
||||
await playerController.removeFromQueue(index);
|
||||
} catch (err) {
|
||||
console.error("Failed to remove from queue:", err);
|
||||
}
|
||||
|
||||
@@ -1,22 +1,27 @@
|
||||
/**
|
||||
* VideoPlayer scrub regression tests (Android native backend path)
|
||||
* VideoPlayer scrub regression tests (Android backend path)
|
||||
*
|
||||
* Reproduces the reported bug: with a sleep timer active, scrubbing the
|
||||
* video seek bar "seeks, then jumps back to the old position".
|
||||
*
|
||||
* These tests mount the REAL VideoPlayer in native-backend mode (what
|
||||
* Android uses: playerPlayItem responds useHtml5Element=false), scrub the
|
||||
* seek bar, then drive the same backend signals the app receives at
|
||||
* runtime (position updates, sleep-timer ticks) and assert the seek bar
|
||||
* does not snap back.
|
||||
* Root cause history:
|
||||
* - Native init called onDestroy() after an await -> lifecycle_outside_component
|
||||
* -> the catch treated init as failed and silently flipped useHtml5Element to
|
||||
* true, so seeks went down the HTML5 path while ExoPlayer kept playing.
|
||||
* - The native SurfaceView has never been visible through the webview, so the
|
||||
* INTERIM behavior (until the video-player API refactor) is: when the backend
|
||||
* reports native mode, VideoPlayer deliberately overrides to HTML5 rendering
|
||||
* and stops the native backend (single audio source, webview owns playback).
|
||||
*
|
||||
* These tests pin the interim behavior: Android's native response is
|
||||
* overridden, the backend is stopped exactly once, and scrubbing keeps
|
||||
* working (and holds its position) with a sleep timer active.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
// ---- Mocks (must precede component import) --------------------------------
|
||||
|
||||
// Capture raw-channel listeners VideoPlayer registers in native mode
|
||||
// ("player://position-update", "player://state-changed").
|
||||
const channelHandlers: Record<string, (event: any) => void> = {};
|
||||
vi.mock("@tauri-apps/api/event", () => ({
|
||||
listen: vi.fn(async (channel: string, handler: any) => {
|
||||
@@ -32,6 +37,7 @@ vi.mock("@tauri-apps/api/core", () => ({
|
||||
}));
|
||||
|
||||
const playerPlayItem = vi.fn(async () => ({
|
||||
// What Android reports: native ExoPlayer backend
|
||||
useHtml5Element: false,
|
||||
backend: "exoplayer",
|
||||
state: { kind: "playing" },
|
||||
@@ -104,7 +110,7 @@ function sleepTimerTick(remaining = 2) {
|
||||
});
|
||||
}
|
||||
|
||||
async function mountNativePlayer() {
|
||||
async function mountAndroidPlayer() {
|
||||
const utils = render(VideoPlayer, {
|
||||
props: {
|
||||
media: makeEpisode(),
|
||||
@@ -115,38 +121,36 @@ async function mountNativePlayer() {
|
||||
},
|
||||
});
|
||||
|
||||
// Wait for onMount init: backend chosen (native), raw listeners registered.
|
||||
// Init: backend reports native, component overrides to HTML5 and stops it.
|
||||
await waitFor(() => expect(playerPlayItem).toHaveBeenCalled());
|
||||
await waitFor(() =>
|
||||
expect(channelHandlers["player://position-update"]).toBeDefined()
|
||||
);
|
||||
|
||||
// Backend reports playing at 300s.
|
||||
channelHandlers["player://state-changed"]({ payload: { state: "playing" } });
|
||||
channelHandlers["player://position-update"]({
|
||||
payload: { position: 300, duration: 1440 },
|
||||
});
|
||||
await tick();
|
||||
await waitFor(() => expect(playerStop).toHaveBeenCalled());
|
||||
|
||||
const slider = utils.container.querySelector(
|
||||
'input[type="range"]'
|
||||
) as HTMLInputElement;
|
||||
const video = utils.container.querySelector("video") as HTMLVideoElement;
|
||||
expect(slider).not.toBeNull();
|
||||
expect(parseFloat(slider.value)).toBeCloseTo(300);
|
||||
return { ...utils, slider };
|
||||
expect(video).not.toBeNull();
|
||||
return { ...utils, slider, video };
|
||||
}
|
||||
|
||||
/** Scrub the seek bar to `target` seconds like a user drag. */
|
||||
async function scrubTo(slider: HTMLInputElement, target: number) {
|
||||
async function scrubTo(
|
||||
slider: HTMLInputElement,
|
||||
video: HTMLVideoElement,
|
||||
target: number
|
||||
) {
|
||||
await fireEvent.mouseDown(slider);
|
||||
slider.value = String(target);
|
||||
await fireEvent.input(slider);
|
||||
await fireEvent.change(slider);
|
||||
await fireEvent.mouseUp(slider);
|
||||
// Resolve the "wait for seeked" step of the HTML5 native-seek path.
|
||||
await fireEvent(video, new Event("seeked"));
|
||||
await tick();
|
||||
}
|
||||
|
||||
describe("VideoPlayer scrubbing with active sleep timer (native backend)", () => {
|
||||
describe("VideoPlayer scrubbing with active sleep timer (Android)", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
for (const key of Object.keys(channelHandlers)) delete channelHandlers[key];
|
||||
@@ -154,10 +158,17 @@ describe("VideoPlayer scrubbing with active sleep timer (native backend)", () =>
|
||||
sleepTimerExpiredSignal.set(0);
|
||||
});
|
||||
|
||||
it("scrubbing without a timer issues a native seek and keeps the new position", async () => {
|
||||
const { slider } = await mountNativePlayer();
|
||||
it("overrides the native backend response to HTML5 rendering and stops the backend once", async () => {
|
||||
await mountAndroidPlayer();
|
||||
// The native backend must be stopped so it doesn't play audio behind the
|
||||
// webview (frozen picture + double audio source).
|
||||
expect(playerStop).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
await scrubTo(slider, 600);
|
||||
it("scrubbing without a timer seeks via the HTML5 path and keeps the new position", async () => {
|
||||
const { slider, video } = await mountAndroidPlayer();
|
||||
|
||||
await scrubTo(slider, video, 600);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(playerSeekVideo).toHaveBeenCalledWith(
|
||||
@@ -165,14 +176,14 @@ describe("VideoPlayer scrubbing with active sleep timer (native backend)", () =>
|
||||
600,
|
||||
"src-1",
|
||||
null,
|
||||
false
|
||||
true // HTML5 path: the webview owns playback after the override
|
||||
)
|
||||
);
|
||||
expect(parseFloat(slider.value)).toBeCloseTo(600);
|
||||
});
|
||||
|
||||
it("scrubbing still works after enabling an episodes sleep timer", async () => {
|
||||
const { slider } = await mountNativePlayer();
|
||||
it("scrubbing still works (and holds position) after enabling an episodes sleep timer", async () => {
|
||||
const { slider, video } = await mountAndroidPlayer();
|
||||
|
||||
// Enable "2 more episodes" timer; backend then ticks every second.
|
||||
sleepTimerTick(2);
|
||||
@@ -180,50 +191,31 @@ describe("VideoPlayer scrubbing with active sleep timer (native backend)", () =>
|
||||
sleepTimerTick(2);
|
||||
await tick();
|
||||
|
||||
await scrubTo(slider, 600);
|
||||
await scrubTo(slider, video, 600);
|
||||
await waitFor(() => expect(playerSeekVideo).toHaveBeenCalledTimes(1));
|
||||
expect(parseFloat(slider.value)).toBeCloseTo(600);
|
||||
|
||||
await waitFor(() => expect(playerSeekVideo).toHaveBeenCalled());
|
||||
expect(playerSeekVideo).toHaveBeenCalledWith("repo-1", 600, "src-1", null, false);
|
||||
// Timer ticks after the seek must not snap the bar back.
|
||||
sleepTimerTick(2);
|
||||
await tick();
|
||||
expect(parseFloat(slider.value)).toBeCloseTo(600);
|
||||
|
||||
// A second scrub must also work.
|
||||
await scrubTo(slider, 900);
|
||||
await scrubTo(slider, video, 900);
|
||||
await waitFor(() => expect(playerSeekVideo).toHaveBeenCalledTimes(2));
|
||||
expect(parseFloat(slider.value)).toBeCloseTo(900);
|
||||
});
|
||||
|
||||
it("REGRESSION: a stale backend position tick right after scrubbing must not snap the bar back", async () => {
|
||||
const { slider } = await mountNativePlayer();
|
||||
|
||||
sleepTimerTick(2);
|
||||
await tick();
|
||||
|
||||
await scrubTo(slider, 600);
|
||||
await waitFor(() => expect(playerSeekVideo).toHaveBeenCalled());
|
||||
expect(parseFloat(slider.value)).toBeCloseTo(600);
|
||||
|
||||
// ExoPlayer's position poller runs on its own cadence: a tick captured
|
||||
// just before the seek landed arrives now, carrying the OLD position.
|
||||
channelHandlers["player://position-update"]({
|
||||
payload: { position: 301, duration: 1440 },
|
||||
});
|
||||
// Plus the per-second sleep-timer tick.
|
||||
sleepTimerTick(2);
|
||||
await tick();
|
||||
|
||||
// The bar must hold the seek target, not snap back to the stale position.
|
||||
expect(parseFloat(slider.value)).toBeCloseTo(600);
|
||||
});
|
||||
|
||||
it("sleep-timer ticks alone never move the seek bar", async () => {
|
||||
const { slider } = await mountNativePlayer();
|
||||
const { slider } = await mountAndroidPlayer();
|
||||
|
||||
const before = slider.value;
|
||||
for (let i = 0; i < 5; i++) {
|
||||
sleepTimerTick(2);
|
||||
await tick();
|
||||
}
|
||||
|
||||
expect(parseFloat(slider.value)).toBeCloseTo(300);
|
||||
expect(slider.value).toBe(before);
|
||||
expect(playerSeekVideo).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
import CachedImage from "../common/CachedImage.svelte";
|
||||
import { sleepTimerActive, sleepTimerExpiredSignal } from "$lib/stores/sleepTimer";
|
||||
import { playbackPosition } from "$lib/stores/player";
|
||||
import * as html5Adapter from "$lib/player/html5Adapter";
|
||||
|
||||
interface Props {
|
||||
media: MediaItem | null;
|
||||
@@ -216,6 +217,7 @@
|
||||
hasPerformedInitialSeek = false; // Reset so new video can seek to initial position
|
||||
lastAppliedInitialPosition = undefined; // New stream - forget the previously-applied resume point
|
||||
endedFired = false; // New stream loaded - allow onEnded to fire again
|
||||
html5Adapter.resetReporting(); // New stream - clear position-report throttle
|
||||
}
|
||||
});
|
||||
|
||||
@@ -319,6 +321,25 @@
|
||||
console.log('[VideoPlayer] HLS manifest parsed, ready to play');
|
||||
});
|
||||
|
||||
// On the Android WebView the element's own `canplay` may not fire for
|
||||
// MSE-fed HLS, so treat the first buffered fragment as "ready" too.
|
||||
// This reveals the <video> element (otherwise it stays invisible behind
|
||||
// the black poster card while audio plays).
|
||||
hls.on(Hls.Events.FRAG_BUFFERED, () => {
|
||||
markMediaReady();
|
||||
});
|
||||
|
||||
// The canplay-fallback timeout is normally armed from the element's
|
||||
// `loadstart` event, but with hls.js the element's `src` is "" and
|
||||
// `loadstart` may not fire, so arm a backstop here directly.
|
||||
if (canplayFallbackTimeout) clearTimeout(canplayFallbackTimeout);
|
||||
canplayFallbackTimeout = setTimeout(() => {
|
||||
if (!isMediaReady && videoElement && videoElement.readyState >= 2) {
|
||||
console.warn('[VideoPlayer] HLS canplay fallback - revealing video (readyState:', videoElement.readyState, ')');
|
||||
markMediaReady();
|
||||
}
|
||||
}, 5000);
|
||||
|
||||
// Reset recovery attempts for new HLS instance
|
||||
hlsFatalRecoveryAttempts = 0;
|
||||
|
||||
@@ -490,9 +511,27 @@
|
||||
backendChosen = true;
|
||||
console.log(`[VideoPlayer] Backend: ${response.backend}, useHtml5Element: ${useHtml5Element}`);
|
||||
|
||||
// INTERIM (until the video-player API refactor lands): always render
|
||||
// through the webview HTML5 element, including Android. The native
|
||||
// ExoPlayer SurfaceView sits behind an opaque webview and has never
|
||||
// actually been visible (an init bug kept the app on the HTML5 path
|
||||
// since the POC), so true native mode plays audio behind a frozen
|
||||
// picture. Stop the native backend and let the webview own playback,
|
||||
// matching Linux behavior and avoiding dual audio.
|
||||
if (!useHtml5Element) {
|
||||
console.warn("[VideoPlayer] Native video backend reported - overriding to HTML5 rendering (native surface not visible through webview)");
|
||||
useHtml5Element = true;
|
||||
try {
|
||||
await commands.playerStop();
|
||||
didStopBackendEarly = true;
|
||||
} catch (err) {
|
||||
console.warn("[VideoPlayer] Failed to stop native backend:", err);
|
||||
}
|
||||
}
|
||||
|
||||
// If using HTML5 element for non-transcoded content, stop the backend player
|
||||
// For transcoded content, we need to keep the backend running to handle seeking/audio track switching
|
||||
if (useHtml5Element && !needsTranscoding) {
|
||||
if (useHtml5Element && !needsTranscoding && !didStopBackendEarly) {
|
||||
try {
|
||||
console.log("[VideoPlayer] Using HTML5 for direct stream - stopping backend player to prevent dual audio");
|
||||
await commands.playerStop();
|
||||
@@ -646,6 +685,9 @@
|
||||
const newCurrentTime = seekOffset + videoElement.currentTime;
|
||||
if (videoElement.readyState >= 2) {
|
||||
currentTime = newCurrentTime;
|
||||
// Feed the Rust controller a throttled position tick (~250ms) so it
|
||||
// stays the source of truth for HTML5 video without flooding IPC.
|
||||
html5Adapter.reportPosition(currentTime, duration);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -695,6 +737,10 @@
|
||||
console.log("[VideoPlayer] videoDuration state is now:", videoDuration);
|
||||
}
|
||||
|
||||
// Tell the Rust controller the media is loaded and its duration (mirrors the
|
||||
// native MediaLoaded event so the backend has a duration for HTML5 video).
|
||||
html5Adapter.reportMediaLoaded(duration);
|
||||
|
||||
// Use setTimeout to log the derived value after reactive updates
|
||||
setTimeout(() => {
|
||||
console.log("[VideoPlayer] Derived duration value:", duration);
|
||||
@@ -702,6 +748,20 @@
|
||||
}, 0);
|
||||
}
|
||||
|
||||
// Flip out of the Loading state and reveal the <video> element (which is
|
||||
// `invisible` and covered by the black poster card until then). Multiple
|
||||
// signals can legitimately mean "ready": the native `canplay` event, hls.js
|
||||
// buffering its first fragment, or the element actually reaching `playing`.
|
||||
// On the Android system WebView the HLS path feeds the element through MSE
|
||||
// with `src=""`, so `loadstart`/`canplay` don't fire reliably and the
|
||||
// canplay-fallback timeout was never armed — audio played while the video
|
||||
// stayed invisible. Any of these callers now reveals it.
|
||||
function markMediaReady() {
|
||||
if (isMediaReady) return;
|
||||
console.log("[VideoPlayer] Marking media ready");
|
||||
isMediaReady = true;
|
||||
}
|
||||
|
||||
async function handleCanPlay() {
|
||||
// Media is ready to play - transition from Loading to Playing state (DR-001)
|
||||
console.log("[VideoPlayer] canplay event fired - media is ready");
|
||||
@@ -799,6 +859,9 @@
|
||||
function handlePlaying() {
|
||||
console.log("[VideoPlayer] playing event - playback resumed");
|
||||
isBuffering = false;
|
||||
// Safety net: if we reached `playing` we are definitely renderable, even if
|
||||
// `canplay`/hls FRAG_BUFFERED were missed on this WebView.
|
||||
markMediaReady();
|
||||
}
|
||||
|
||||
function handleLoadStart() {
|
||||
@@ -881,6 +944,10 @@
|
||||
function handlePlay() {
|
||||
isPlaying = true;
|
||||
startTimeUpdates(); // Start RAF loop for smooth time updates
|
||||
// Mirror the DOM state into the Rust PlayerController so it is the single
|
||||
// source of truth for HTML5 video (the <video> lives in the webview, which
|
||||
// Rust cannot observe directly). See html5Adapter.ts.
|
||||
html5Adapter.reportState("playing", reportMediaId ?? null);
|
||||
// Report playback start on first play (skip for live - no resume tracking)
|
||||
if (!isLive && !hasReportedStart && onReportStart) {
|
||||
onReportStart(currentTime, reportMediaId);
|
||||
@@ -891,6 +958,8 @@
|
||||
function handlePause() {
|
||||
isPlaying = false;
|
||||
stopTimeUpdates(); // Stop RAF loop when paused
|
||||
html5Adapter.reportState("paused", reportMediaId ?? null);
|
||||
html5Adapter.reportPosition(currentTime, duration, { force: true });
|
||||
// Report progress when paused
|
||||
if (onReportProgress) {
|
||||
onReportProgress(currentTime, true, reportMediaId);
|
||||
@@ -900,6 +969,7 @@
|
||||
function handleEnded() {
|
||||
isPlaying = false;
|
||||
stopTimeUpdates(); // Stop RAF loop when ended
|
||||
html5Adapter.reportState("stopped", reportMediaId ?? null);
|
||||
// Report stop when video ends (skip for live - no resume tracking)
|
||||
if (!isLive && onReportStop) {
|
||||
onReportStop(currentTime, reportMediaId);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { commands } from "$lib/api/bindings";
|
||||
import { playerController } from "$lib/player";
|
||||
import { volume, isMuted, mergedVolume } from "$lib/stores/player";
|
||||
import { isRemoteMode } from "$lib/stores/playbackMode";
|
||||
import { selectedSession, sessions } from "$lib/stores/sessions";
|
||||
@@ -31,7 +31,7 @@
|
||||
// Remote mode: send volume as 0-100 integer to remote session
|
||||
await sessions.sendVolume($selectedSession.id, Math.round(newVolume * 100));
|
||||
} else {
|
||||
await commands.playerSetVolume(newVolume);
|
||||
await playerController.setVolume(newVolume);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@
|
||||
if ($isRemoteMode && $selectedSession) {
|
||||
await sessions.sendToggleMute($selectedSession.id);
|
||||
} else {
|
||||
await commands.playerToggleMute();
|
||||
await playerController.toggleMute();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user