mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
### Motivation - Prevent a browser-policy muted-autoplay retry from overwriting a viewer's explicit mute/unmute preference and ensure fallback mutes are transient. - Ensure that when a user logs in while a video comment modal is open, the open thread is reloaded (not just refreshed) so comments continue to display correctly after relay-backed services are reconfigured. ### Description - Add `js/utils/autoplayPreference.js` with `muteForAutoplayFallback` and `consumeAutoplayFallbackMute` helpers to mark and consume transient fallback mutes. - Update `js/services/playbackService.js` to treat sound as default unless the viewer explicitly chose mute, persist explicit choices via `localStorage`, ignore fallback-generated volume changes using `consumeAutoplayFallbackMute`, and expose `muteForAutoplayFallback` delegating to the new util. - Replace direct `videoElement.muted = true` calls with `playbackService.muteForAutoplayFallback` in `js/app/playbackCoordinator.js` and `js/webtorrent.js` so fallback mutes are marked as transient. - Change `js/app/authSessionCoordinator.js` to reload the currently open video comment thread with `commentController.load(activeModalVideo)` after login if a modal video is active, instead of only calling `refreshAuthState`. - Update tests in `tests/services/playbackService.test.mjs` and add a test in `tests/app/login-defers-dms.test.mjs` to cover the login behavior that reloads an open comment thread. ### Testing - Ran the `PlaybackService` unit tests (`tests/services/playbackService.test.mjs`) which assert `prepareVideoElement` behavior, explicit mute persistence, and the transient nature of fallback mute, and they passed. - Ran the auth login unit tests including `tests/app/login-defers-dms.test.mjs` which verifies DM deferral and reload of an open comment thread, and they passed.
281 lines
10 KiB
JavaScript
281 lines
10 KiB
JavaScript
import { describe, test, mock, before, after, beforeEach, afterEach } from "node:test";
|
|
import assert from "node:assert/strict";
|
|
import { createUiDom } from "../ui/helpers/jsdom-test-helpers.mjs";
|
|
import { PlaybackService } from "../../js/services/playbackService.js";
|
|
|
|
describe("PlaybackService", () => {
|
|
let dom;
|
|
let document;
|
|
let window;
|
|
|
|
before(() => {
|
|
dom = createUiDom();
|
|
window = dom.window;
|
|
document = dom.document;
|
|
|
|
// Mock HTMLMediaElement constants if missing in JSDOM
|
|
if (!window.HTMLMediaElement) {
|
|
window.HTMLMediaElement = {
|
|
HAVE_NOTHING: 0,
|
|
HAVE_METADATA: 1,
|
|
HAVE_CURRENT_DATA: 2,
|
|
HAVE_FUTURE_DATA: 3,
|
|
HAVE_ENOUGH_DATA: 4,
|
|
};
|
|
}
|
|
});
|
|
|
|
after(() => {
|
|
dom.cleanup();
|
|
});
|
|
|
|
test("Initialization sets defaults and dependencies", () => {
|
|
const logger = mock.fn();
|
|
const service = new PlaybackService({ logger });
|
|
|
|
assert.ok(service);
|
|
assert.equal(typeof service.log, "function");
|
|
service.log("test");
|
|
assert.equal(logger.mock.callCount(), 1);
|
|
assert.equal(service.urlFirstEnabled, true);
|
|
});
|
|
|
|
test("prepareVideoElement respects localStorage and binds listener", () => {
|
|
const service = new PlaybackService();
|
|
const video = document.createElement("video");
|
|
|
|
// Default: sound on unless the viewer explicitly chose mute.
|
|
localStorage.removeItem("unmutedAutoplay");
|
|
service.prepareVideoElement(video);
|
|
assert.equal(video.muted, false);
|
|
assert.equal(video.dataset.autoplayBound, "true");
|
|
|
|
// Simulate an explicit mute choice.
|
|
video.muted = true;
|
|
video.dispatchEvent(new window.Event("volumechange"));
|
|
assert.equal(localStorage.getItem("unmutedAutoplay"), "false");
|
|
|
|
// Test respecting stored value
|
|
const video2 = document.createElement("video");
|
|
service.prepareVideoElement(video2);
|
|
assert.equal(video2.muted, true);
|
|
|
|
// A browser-policy fallback is temporary and must not become a saved mute.
|
|
localStorage.setItem("unmutedAutoplay", "true");
|
|
video2.muted = false;
|
|
service.muteForAutoplayFallback(video2);
|
|
assert.equal(localStorage.getItem("unmutedAutoplay"), "true");
|
|
});
|
|
|
|
test("registerUrlPlaybackWatchdogs triggers onFallback on error", () => {
|
|
const service = new PlaybackService();
|
|
const video = document.createElement("video");
|
|
const onFallback = mock.fn();
|
|
const onSuccess = mock.fn();
|
|
|
|
service.registerUrlPlaybackWatchdogs(video, { onFallback, onSuccess });
|
|
|
|
video.dispatchEvent(new window.Event("error"));
|
|
assert.equal(onFallback.mock.callCount(), 1);
|
|
assert.equal(onFallback.mock.calls[0].arguments[0], "error");
|
|
});
|
|
|
|
test("registerUrlPlaybackWatchdogs triggers onSuccess on playing", () => {
|
|
const service = new PlaybackService();
|
|
const video = document.createElement("video");
|
|
const onFallback = mock.fn();
|
|
const onSuccess = mock.fn();
|
|
|
|
service.registerUrlPlaybackWatchdogs(video, { onFallback, onSuccess });
|
|
|
|
video.dispatchEvent(new window.Event("playing"));
|
|
assert.equal(onSuccess.mock.callCount(), 1);
|
|
});
|
|
|
|
test("createSession returns a PlaybackSession", () => {
|
|
const service = new PlaybackService();
|
|
const session = service.createSession({ url: "https://example.com/video.mp4" });
|
|
|
|
assert.ok(session);
|
|
assert.equal(service.currentSession, session);
|
|
assert.equal(session.sanitizedUrl, "https://example.com/video.mp4");
|
|
});
|
|
|
|
describe("PlaybackSession Flow", () => {
|
|
let service;
|
|
let video;
|
|
let playMock;
|
|
let loadMock;
|
|
let pauseMock;
|
|
|
|
beforeEach(() => {
|
|
mock.timers.enable({ apis: ['setTimeout'] });
|
|
service = new PlaybackService({
|
|
logger: () => {},
|
|
isValidMagnetUri: () => true, // Allow all magnets
|
|
});
|
|
video = document.createElement("video");
|
|
|
|
// Mock methods not implemented in JSDOM
|
|
playMock = mock.fn(async () => {});
|
|
loadMock = mock.fn();
|
|
pauseMock = mock.fn();
|
|
|
|
video.play = playMock;
|
|
video.load = loadMock;
|
|
video.pause = pauseMock;
|
|
|
|
Object.defineProperty(video, 'readyState', {
|
|
writable: true,
|
|
value: window.HTMLMediaElement.HAVE_NOTHING
|
|
});
|
|
Object.defineProperty(video, 'networkState', {
|
|
writable: true,
|
|
value: 0
|
|
});
|
|
});
|
|
|
|
// spec_correction: the URL probe only exists to decide whether to fail over
|
|
// to P2P quickly. It used to run for EVERY hosted attempt, but its timeout
|
|
// (4s) exceeds the playback-start window (3s), so a slow no-cors HEAD (e.g.
|
|
// archive.org) consumed the whole budget and the URL was never even loaded —
|
|
// a false "No playable source found." for perfectly playable URL-only
|
|
// videos. The probe now runs ONLY when there's a magnet to fall over to.
|
|
// This test therefore uses a DUAL-source video so the probe path is still
|
|
// exercised; the URL-only skip is covered by the next test.
|
|
//
|
|
// test_integrity_note:
|
|
// change_type: ["spec_correction"]
|
|
// scenarios:
|
|
// - id: SCN-probe-success-dual-source
|
|
// given: "a dual-source (url+magnet) video whose URL probe succeeds"
|
|
// when: "playback starts"
|
|
// then: "the probe runs, the URL plays, and torrent is not used"
|
|
// observable_outcomes:
|
|
// - "probe called once; URL playback started; playViaWebTorrent not called"
|
|
// determinism_controls: ["jsdom video element; mocked probe/torrent"]
|
|
// anti_cheat_rationale:
|
|
// prevents: ["over-mocking internal logic"]
|
|
// relaxation:
|
|
// did_relax_any_assertion: false
|
|
// if_true_explain_spec_basis: "moved the probe assertion to the dual-source case it now applies to; url-only skip is separately asserted"
|
|
test("URL Probe Success starts playback (dual-source)", async () => {
|
|
const probeUrl = mock.fn(async () => ({ outcome: "good", status: 200 }));
|
|
const playViaWebTorrent = mock.fn();
|
|
|
|
const testSession = service.createSession({
|
|
url: "https://example.com/video.mp4",
|
|
magnet: "magnet:?xt=urn:btih:1111111111111111111111111111111111111111",
|
|
videoElement: video,
|
|
probeUrl,
|
|
playViaWebTorrent,
|
|
});
|
|
|
|
const startPromise = testSession.start();
|
|
|
|
// Allow execute() to proceed past await probeUrl() and install watchdogs
|
|
await new Promise(resolve => process.nextTick(resolve));
|
|
await new Promise(resolve => process.nextTick(resolve));
|
|
|
|
// Trigger success event
|
|
video.dispatchEvent(new window.Event("playing"));
|
|
|
|
const result = await startPromise;
|
|
|
|
assert.equal(probeUrl.mock.callCount(), 1);
|
|
assert.equal(playMock.mock.callCount(), 1);
|
|
assert.equal(result.source, "url");
|
|
assert.equal(playViaWebTorrent.mock.callCount(), 0);
|
|
});
|
|
|
|
// The reported bug: a URL-only video (no magnet) must NOT be gated by the
|
|
// probe — the video element is the arbiter. It plays directly, probe skipped.
|
|
//
|
|
// test_integrity_note:
|
|
// change_type: ["new_tests"]
|
|
// scenarios:
|
|
// - id: SCN-url-only-skips-probe
|
|
// given: "a URL-only video (no magnet) with a playable URL"
|
|
// when: "playback starts"
|
|
// then: "the probe is NOT called and the URL plays directly"
|
|
// observable_outcomes: ["probe callCount 0; URL playback started"]
|
|
// determinism_controls: ["jsdom video element; mocked probe"]
|
|
// anti_cheat_rationale: { prevents: ["hard-coded return value"] }
|
|
// relaxation: { did_relax_any_assertion: false }
|
|
test("URL-only video skips the probe and plays directly", async () => {
|
|
const probeUrl = mock.fn(async () => ({ outcome: "good", status: 200 }));
|
|
const playViaWebTorrent = mock.fn();
|
|
|
|
const testSession = service.createSession({
|
|
url: "https://example.com/video.mp4",
|
|
videoElement: video,
|
|
probeUrl,
|
|
playViaWebTorrent,
|
|
});
|
|
|
|
const startPromise = testSession.start();
|
|
await new Promise(resolve => process.nextTick(resolve));
|
|
await new Promise(resolve => process.nextTick(resolve));
|
|
video.dispatchEvent(new window.Event("playing"));
|
|
const result = await startPromise;
|
|
|
|
assert.equal(probeUrl.mock.callCount(), 0, "URL-only: probe not on the critical path");
|
|
assert.equal(playMock.mock.callCount(), 1);
|
|
assert.equal(result.source, "url");
|
|
});
|
|
|
|
test("URL Probe Failure triggers fallback", async () => {
|
|
const probeUrl = mock.fn(async () => ({ outcome: "bad", status: 404 }));
|
|
const playViaWebTorrent = mock.fn(async () => ({ infoHash: "123" }));
|
|
|
|
const session = service.createSession({
|
|
url: "https://example.com/video.mp4",
|
|
magnet: "magnet:?xt=urn:btih:123",
|
|
videoElement: video,
|
|
probeUrl,
|
|
playViaWebTorrent,
|
|
});
|
|
|
|
const result = await session.start();
|
|
|
|
assert.equal(probeUrl.mock.callCount(), 1);
|
|
assert.equal(playViaWebTorrent.mock.callCount(), 1);
|
|
assert.equal(result.source, "torrent");
|
|
});
|
|
|
|
test("Watchdog Stall triggers fallback", async () => {
|
|
const probeUrl = mock.fn(async () => ({ outcome: "good", status: 200 }));
|
|
const playViaWebTorrent = mock.fn(async () => ({ infoHash: "123" }));
|
|
|
|
const session = service.createSession({
|
|
url: "https://example.com/video.mp4",
|
|
magnet: "magnet:?xt=urn:btih:123",
|
|
videoElement: video,
|
|
probeUrl,
|
|
playViaWebTorrent,
|
|
});
|
|
|
|
const startPromise = session.start();
|
|
|
|
// Allow execute() to proceed past await probeUrl() and install watchdogs
|
|
await new Promise(resolve => process.nextTick(resolve));
|
|
await new Promise(resolve => process.nextTick(resolve));
|
|
await new Promise(resolve => process.nextTick(resolve));
|
|
|
|
// Advance time to trigger stall (default 8000ms)
|
|
mock.timers.tick(10000);
|
|
|
|
const result = await startPromise;
|
|
|
|
assert.equal(probeUrl.mock.callCount(), 1);
|
|
assert.equal(playMock.mock.callCount(), 1); // It tried to play
|
|
assert.equal(playViaWebTorrent.mock.callCount(), 1); // Fallback happened
|
|
assert.equal(result.source, "torrent");
|
|
});
|
|
|
|
afterEach(() => {
|
|
mock.timers.reset();
|
|
});
|
|
});
|
|
});
|