mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
bitvid attached every hosted source with `videoEl.src = url`, which only plays
HLS on Safari-family browsers. Firefox 146 fails with MEDIA_ERR_SRC_NOT_SUPPORTED
(canPlayType returns ""), so every .m3u8 note -- e.g. anything published by
nostube/slidestr -- was unplayable there and the URL-health badge showed
"offline". Chrome/Brave 149 happen to play HLS natively, which is why this
looked browser-specific.
Vendor hls.js (npm run build:hls) and route HLS sources through it:
- js/services/hlsPlayback.js owns detection, engine choice and the hls.js
lifecycle. Engine choice keys off MediaSource, NOT canPlayType: Chrome
returns "maybe" for every HLS type, so a native-first branch would hang.
Native is used only when MSE is genuinely unavailable.
- The bundle is the FULL build, not hls.js/light -- light drops the alternate
audio stream controller and real ladders demux audio into an EXT-X-MEDIA
group. It is lazy-imported only when a .m3u8 is encountered, so ordinary
MP4/WebTorrent playback never pays the ~500KB.
- modalCoordinator tears the instance down first; clearing `src` alone leaves
the transmuxing worker and segment fetches running after the modal closes.
- urlHealthController probes HLS by fetching the playlist for #EXTM3U. The old
bare-<video> probe could not load it and on Chrome emitted no events at all,
burning the full timeout on every HLS card.
- HLS_PLAYBACK_START_TIMEOUT widens the 3s start window, which fired
mid-startup: the bundle, master playlist, variant playlist, init segment and
first media segment are four-plus serial round trips.
Also fixes two bugs found while verifying this:
- A stale MediaError was reported as the current failure. Teardown clears `src`
and calls load(), leaving MEDIA_ERR_SRC_NOT_SUPPORTED ("Empty src attribute")
on the element; a later watchdog fallback rendered that as "Hosted playback
failed: source not supported or blocked." over a stream that was playing
fine. Only a MediaError produced by the current attempt is surfaced now.
Sources are also bound before the watchdogs are armed, so hls.js's own
attachMedia() src swap cannot read as a playback failure.
- `audio/x-mpegurl` is a legacy alias for an HLS playlist of any kind, not an
audio-only assertion. isAudioOnlyVideoObject misfiled HLS video ladders
published with it as podcasts and dropped them from every video feed.
Verified end-to-end in Chromium, Chrome 149, Brave 149 and Firefox 146 against
a live HLS stream: Firefox goes from "No playable source found." to playing.
Rollback: stop calling attachHlsSource() and the previous `src` assignment
returns; see docs/playback-fallback.md.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
723 lines
21 KiB
JavaScript
723 lines
21 KiB
JavaScript
// js/app/modalCoordinator.js
|
|
|
|
/**
|
|
* Modal open/close lifecycle and controller routing.
|
|
*
|
|
* Stateful collaborators (clients, services, app state) are injected from the
|
|
* Application composition root rather than imported at module scope. Pure,
|
|
* stateless helper modules are imported directly, as in the other coordinators.
|
|
*
|
|
* Methods use `this` which is bound to the Application instance.
|
|
*/
|
|
import { detachHls } from "../services/hlsPlayback.js";
|
|
|
|
/**
|
|
* @param {object} deps - Injected dependencies.
|
|
* @returns {object} Methods to be bound to the Application instance.
|
|
*/
|
|
export function createModalCoordinator(deps) {
|
|
const {
|
|
devLogger,
|
|
nostrClient,
|
|
recordVideoViewApi,
|
|
torrentClient,
|
|
watchHistoryService,
|
|
isWatchHistoryDebugEnabled,
|
|
subscribeToVideoViewCount,
|
|
unsubscribeFromVideoViewCount,
|
|
formatViewCount,
|
|
ingestLocalViewEvent,
|
|
requestVideoZapTotal,
|
|
getVideoZapTotalSnapshot,
|
|
onZapTotalsChanged,
|
|
pointerArrayToKey,
|
|
pointerKey,
|
|
getCanonicalDesignSystemMode,
|
|
BITVID_WEBSITE_URL,
|
|
} = deps;
|
|
|
|
// Zap-total badge helpers are LOCAL closures, NOT coordinator methods:
|
|
// coordinator methods run with `this` bound to the Application, so a
|
|
// cross-method `this.helper()` call only works when app.js also adds an
|
|
// explicit delegate for it — a silent landmine (a missing delegate here
|
|
// threw on EVERY modal open and broke video playback entirely).
|
|
let modalZapTotalUnsub = null;
|
|
|
|
function teardownModalZapTotal() {
|
|
if (typeof modalZapTotalUnsub === "function") {
|
|
try {
|
|
modalZapTotalUnsub();
|
|
} catch (error) {
|
|
devLogger.warn("[zapTotals] Failed to tear down modal listener:", error);
|
|
}
|
|
}
|
|
modalZapTotalUnsub = null;
|
|
const el =
|
|
typeof document !== "undefined"
|
|
? document.getElementById("videoZapTotal")
|
|
: null;
|
|
if (el) {
|
|
el.classList.add("hidden");
|
|
const text = el.querySelector("[data-zap-total-text]");
|
|
if (text) {
|
|
text.textContent = "";
|
|
}
|
|
}
|
|
}
|
|
|
|
// Orange sats badge on the modal's meta row (right side; views stay left).
|
|
// Mirrors the card badge: cached total now (also schedules the batched
|
|
// receipt fetch), re-rendered when a batch lands, hidden while zero.
|
|
function subscribeModalZapTotal(pointer) {
|
|
if (
|
|
typeof requestVideoZapTotal !== "function" ||
|
|
typeof getVideoZapTotalSnapshot !== "function" ||
|
|
typeof onZapTotalsChanged !== "function"
|
|
) {
|
|
return;
|
|
}
|
|
const el =
|
|
typeof document !== "undefined"
|
|
? document.getElementById("videoZapTotal")
|
|
: null;
|
|
if (!el || !pointer) {
|
|
return;
|
|
}
|
|
// Always visible while a video is open (mirrors the view counter); a zero
|
|
// is a real value, not noise to hide.
|
|
const render = () => {
|
|
const sats = getVideoZapTotalSnapshot(pointer);
|
|
const value = Number.isFinite(sats) && sats > 0 ? sats : 0;
|
|
const text = el.querySelector("[data-zap-total-text]");
|
|
if (text) {
|
|
text.textContent = `${formatViewCount(value)} sats`;
|
|
}
|
|
el.classList.remove("hidden");
|
|
};
|
|
requestVideoZapTotal(pointer);
|
|
render();
|
|
modalZapTotalUnsub = onZapTotalsChanged(render);
|
|
}
|
|
|
|
return {
|
|
normalizeModalTrigger(candidate) {
|
|
if (!candidate) {
|
|
return null;
|
|
}
|
|
const doc =
|
|
(this.videoModal && this.videoModal.document) ||
|
|
(typeof document !== "undefined" ? document : null);
|
|
const isElement =
|
|
typeof candidate === "object" &&
|
|
candidate !== null &&
|
|
typeof candidate.nodeType === "number" &&
|
|
candidate.nodeType === 1 &&
|
|
typeof candidate.focus === "function";
|
|
if (!isElement) {
|
|
return null;
|
|
}
|
|
if (doc && typeof doc.contains === "function" && !doc.contains(candidate)) {
|
|
return null;
|
|
}
|
|
return candidate;
|
|
},
|
|
|
|
setLastModalTrigger(candidate) {
|
|
this.lastModalTrigger = this.normalizeModalTrigger(candidate);
|
|
return this.lastModalTrigger;
|
|
},
|
|
|
|
getDesignSystemMode() {
|
|
return getCanonicalDesignSystemMode();
|
|
},
|
|
|
|
isDesignSystemNew() {
|
|
return true;
|
|
},
|
|
|
|
/**
|
|
* Show the modal and set the "Please stand by" poster on the video.
|
|
*/
|
|
async showModalWithPoster(video = this.currentVideo, options = {}) {
|
|
const result = await this.videoModalController.showModalWithPoster(video, options);
|
|
this.cacheTorrentStatusNodes();
|
|
return result;
|
|
},
|
|
|
|
applyModalLoadingPoster() {
|
|
this.videoModalController.applyModalLoadingPoster();
|
|
},
|
|
|
|
forceRemoveModalPoster(reason = "manual-clear") {
|
|
return this.videoModalController.forceRemoveModalPoster(reason);
|
|
},
|
|
|
|
async ensureVideoModalReady({ ensureVideoElement = false } = {}) {
|
|
const result = await this.videoModalController.ensureVideoModalReady({ ensureVideoElement });
|
|
this.modalVideo = result.videoElement;
|
|
return result;
|
|
},
|
|
|
|
formatViewCountLabel(total) {
|
|
const value = Number.isFinite(total) ? Number(total) : 0;
|
|
const label = value === 1 ? "view" : "views";
|
|
return `${formatViewCount(value)} ${label}`;
|
|
},
|
|
|
|
pruneDetachedViewCountElements() {
|
|
if (this.videoListView) {
|
|
this.videoListView.pruneDetachedViewCountElements();
|
|
}
|
|
},
|
|
|
|
teardownAllViewCountSubscriptions() {
|
|
if (this.videoListView) {
|
|
this.videoListView.teardownAllViewCountSubscriptions();
|
|
}
|
|
},
|
|
|
|
teardownModalViewCountSubscription() {
|
|
if (typeof this.modalViewCountUnsub === "function") {
|
|
try {
|
|
this.modalViewCountUnsub();
|
|
} catch (error) {
|
|
devLogger.warn("[viewCount] Failed to tear down modal subscription:", error);
|
|
}
|
|
}
|
|
this.modalViewCountUnsub = null;
|
|
teardownModalZapTotal();
|
|
if (this.videoModal) {
|
|
this.videoModal.updateViewCountLabel("\u2013 views");
|
|
this.videoModal.setViewCountPointer(null);
|
|
}
|
|
},
|
|
|
|
subscribeModalViewCount(pointer, pointerKey) {
|
|
const viewEl = this.videoModal?.getViewCountElement() || null;
|
|
if (!viewEl) {
|
|
return;
|
|
}
|
|
|
|
this.teardownModalViewCountSubscription();
|
|
|
|
if (!pointer || !pointerKey) {
|
|
return;
|
|
}
|
|
|
|
if (this.videoModal) {
|
|
this.videoModal.updateViewCountLabel("Loading views\u2026");
|
|
this.videoModal.setViewCountPointer(pointerKey);
|
|
}
|
|
subscribeModalZapTotal(pointer);
|
|
try {
|
|
const token = subscribeToVideoViewCount(pointer, ({ total, status, partial }) => {
|
|
const latestViewEl = this.videoModal?.getViewCountElement() || null;
|
|
if (!latestViewEl) {
|
|
return;
|
|
}
|
|
|
|
if (Number.isFinite(total)) {
|
|
const numeric = Number(total);
|
|
if (this.videoModal) {
|
|
const label = this.formatViewCountLabel(numeric);
|
|
this.videoModal.updateViewCountLabel(
|
|
partial ? `${label} (partial)` : label
|
|
);
|
|
}
|
|
latestViewEl.dataset.viewCountState = partial ? "partial" : "ready";
|
|
return;
|
|
}
|
|
|
|
if (status === "hydrating") {
|
|
if (this.videoModal) {
|
|
this.videoModal.updateViewCountLabel("Loading views\u2026");
|
|
}
|
|
latestViewEl.dataset.viewCountState = "hydrating";
|
|
} else {
|
|
if (this.videoModal) {
|
|
this.videoModal.updateViewCountLabel("\u2013 views");
|
|
}
|
|
latestViewEl.dataset.viewCountState = status;
|
|
}
|
|
}, { exact: true });
|
|
|
|
this.modalViewCountUnsub = () => {
|
|
try {
|
|
unsubscribeFromVideoViewCount(pointer, token);
|
|
} catch (error) {
|
|
devLogger.warn(
|
|
"[viewCount] Failed to unsubscribe modal view counter:",
|
|
error
|
|
);
|
|
} finally {
|
|
this.modalViewCountUnsub = null;
|
|
}
|
|
};
|
|
} catch (error) {
|
|
devLogger.warn("[viewCount] Failed to subscribe modal view counter:", error);
|
|
if (this.videoModal) {
|
|
this.videoModal.updateViewCountLabel("\u2013 views");
|
|
this.videoModal.setViewCountPointer(null);
|
|
}
|
|
}
|
|
},
|
|
|
|
async hideModal() {
|
|
// 1) Clear timers/listeners immediately so playback stats stop updating
|
|
this.cancelPendingViewLogging();
|
|
this.clearActiveIntervals();
|
|
this.removeTorrentStatusVisibilityHandlers();
|
|
this.teardownModalViewCountSubscription();
|
|
if (this.reactionController) {
|
|
this.reactionController.unsubscribe();
|
|
}
|
|
this.pendingModeratedPlayback = null;
|
|
if (
|
|
this.videoModal &&
|
|
typeof this.videoModal.clearSimilarContent === "function"
|
|
) {
|
|
try {
|
|
this.videoModal.clearSimilarContent();
|
|
} catch (error) {
|
|
devLogger.warn("[hideModal] Failed to clear similar content:", error);
|
|
}
|
|
}
|
|
|
|
// 2) Close the modal UI right away so the user gets instant feedback
|
|
const modalVideoElement =
|
|
(this.videoModal &&
|
|
typeof this.videoModal.getVideoElement === "function" &&
|
|
this.videoModal.getVideoElement()) ||
|
|
this.modalVideo ||
|
|
null;
|
|
if (modalVideoElement) {
|
|
try {
|
|
modalVideoElement.pause();
|
|
modalVideoElement.removeAttribute("src");
|
|
modalVideoElement.load();
|
|
} catch (error) {
|
|
devLogger.warn("[hideModal] Failed to reset modal video element:", error);
|
|
}
|
|
}
|
|
|
|
if (this.videoModal) {
|
|
try {
|
|
this.videoModal.close();
|
|
} catch (error) {
|
|
devLogger.warn("[hideModal] Failed to close video modal immediately:", error);
|
|
}
|
|
}
|
|
|
|
this.lastModalTrigger = null;
|
|
|
|
this.currentMagnetUri = null;
|
|
this.clearTorrentStatusNodes();
|
|
|
|
// 3) Kick off heavy cleanup work asynchronously. We still await it so
|
|
// callers that depend on teardown finishing behave the same, but the
|
|
// user-visible UI is already closed.
|
|
const performCleanup = async () => {
|
|
try {
|
|
if (navigator.serviceWorker && navigator.serviceWorker.controller) {
|
|
await fetch("/webtorrent/cancel/", { mode: "no-cors" });
|
|
}
|
|
} catch (err) {
|
|
devLogger.warn("[hideModal] webtorrent cancel fetch failed:", err);
|
|
}
|
|
|
|
await this.cleanup({
|
|
preserveSubscriptions: true,
|
|
preserveObservers: true,
|
|
preserveModals: true,
|
|
});
|
|
};
|
|
|
|
// 4) Remove only `?v=` but **keep** the hash
|
|
const url = new URL(window.location.href);
|
|
url.searchParams.delete("v"); // remove ?v= param
|
|
const newUrl = url.pathname + url.search + url.hash;
|
|
window.history.replaceState({}, "", newUrl);
|
|
|
|
try {
|
|
await performCleanup();
|
|
} catch (error) {
|
|
devLogger.error("[hideModal] Cleanup failed:", error);
|
|
}
|
|
},
|
|
|
|
cancelPendingViewLogging() {
|
|
this.watchHistoryTelemetry?.cancelPlaybackLogging?.();
|
|
},
|
|
|
|
resetViewLoggingState() {
|
|
this.watchHistoryTelemetry?.resetPlaybackLoggingState?.();
|
|
},
|
|
|
|
persistWatchHistoryMetadataForVideo(video, pointerInfo) {
|
|
if (this.watchHistoryTelemetry) {
|
|
this.watchHistoryTelemetry.persistMetadataForVideo(video, pointerInfo);
|
|
return;
|
|
}
|
|
|
|
if (
|
|
!pointerInfo ||
|
|
!pointerInfo.key ||
|
|
typeof watchHistoryService?.setLocalMetadata !== "function"
|
|
) {
|
|
return;
|
|
}
|
|
|
|
if (!video || typeof video !== "object") {
|
|
return;
|
|
}
|
|
|
|
const metadata = {
|
|
video: {
|
|
id: typeof video.id === "string" ? video.id : "",
|
|
title: typeof video.title === "string" ? video.title : "",
|
|
thumbnail: typeof video.thumbnail === "string" ? video.thumbnail : "",
|
|
pubkey: typeof video.pubkey === "string" ? video.pubkey : "",
|
|
},
|
|
};
|
|
|
|
try {
|
|
watchHistoryService.setLocalMetadata(pointerInfo.key, metadata);
|
|
} catch (error) {
|
|
devLogger.warn(
|
|
"[watchHistory] Failed to persist local metadata for pointer:",
|
|
pointerInfo.key,
|
|
error,
|
|
);
|
|
}
|
|
},
|
|
|
|
dropWatchHistoryMetadata(pointerKey) {
|
|
if (this.watchHistoryTelemetry) {
|
|
this.watchHistoryTelemetry.dropMetadata(pointerKey);
|
|
return;
|
|
}
|
|
|
|
if (!pointerKey || typeof pointerKey !== "string") {
|
|
return;
|
|
}
|
|
if (typeof watchHistoryService?.removeLocalMetadata !== "function") {
|
|
return;
|
|
}
|
|
try {
|
|
watchHistoryService.removeLocalMetadata(pointerKey);
|
|
} catch (error) {
|
|
devLogger.warn(
|
|
"[watchHistory] Failed to remove cached metadata for pointer:",
|
|
pointerKey,
|
|
error,
|
|
);
|
|
}
|
|
},
|
|
|
|
async handleRemoveHistoryAction(dataset = {}, { trigger } = {}) {
|
|
if (!this.watchHistoryController) {
|
|
this.showError("Watch history sync is not available right now.");
|
|
return;
|
|
}
|
|
|
|
try {
|
|
await this.watchHistoryController.removeEntry({
|
|
dataset,
|
|
trigger,
|
|
removeCard: dataset.removeCard === "true",
|
|
reason: dataset.reason || "remove-item",
|
|
});
|
|
} catch (error) {
|
|
if (!error?.handled) {
|
|
this.showError("Failed to remove from history. Please try again.");
|
|
}
|
|
}
|
|
},
|
|
|
|
async handleWatchHistoryRemoval(payload = {}) {
|
|
if (!this.watchHistoryTelemetry) {
|
|
this.showError("Watch history sync is not available right now.");
|
|
const error = new Error("watch-history-disabled");
|
|
error.handled = true;
|
|
throw error;
|
|
}
|
|
return this.watchHistoryTelemetry.handleRemoval(payload);
|
|
},
|
|
|
|
flushWatchHistory(reason = "session-end", context = "watch-history") {
|
|
if (!this.watchHistoryTelemetry) {
|
|
return Promise.resolve();
|
|
}
|
|
return this.watchHistoryTelemetry.flush(reason, context);
|
|
},
|
|
|
|
getActiveViewIdentityKey() {
|
|
if (!this.watchHistoryTelemetry) {
|
|
const normalizedUser = this.normalizeHexPubkey(this.pubkey);
|
|
if (normalizedUser) {
|
|
return `actor:${normalizedUser}`;
|
|
}
|
|
|
|
const sessionActorPubkey = this.normalizeHexPubkey(
|
|
nostrClient?.sessionActor?.pubkey
|
|
);
|
|
if (sessionActorPubkey) {
|
|
return `actor:${sessionActorPubkey}`;
|
|
}
|
|
|
|
return "actor:anonymous";
|
|
}
|
|
|
|
return this.watchHistoryTelemetry.getActiveViewIdentityKey();
|
|
},
|
|
|
|
deriveViewIdentityKeyFromEvent(event) {
|
|
if (!this.watchHistoryTelemetry) {
|
|
if (!event || typeof event !== "object") {
|
|
return "";
|
|
}
|
|
|
|
const normalizedPubkey = this.normalizeHexPubkey(event.pubkey);
|
|
if (!normalizedPubkey) {
|
|
return "";
|
|
}
|
|
|
|
return `actor:${normalizedPubkey}`;
|
|
}
|
|
|
|
return this.watchHistoryTelemetry.deriveViewIdentityKeyFromEvent(event);
|
|
},
|
|
|
|
buildViewCooldownKey(pointerKey, identityKey) {
|
|
if (!this.watchHistoryTelemetry) {
|
|
const normalizedPointerKey =
|
|
typeof pointerKey === "string" && pointerKey.trim()
|
|
? pointerKey.trim()
|
|
: "";
|
|
if (!normalizedPointerKey) {
|
|
return "";
|
|
}
|
|
|
|
const normalizedIdentity =
|
|
typeof identityKey === "string" && identityKey.trim()
|
|
? identityKey.trim().toLowerCase()
|
|
: "";
|
|
|
|
return normalizedIdentity
|
|
? `${normalizedPointerKey}::${normalizedIdentity}`
|
|
: normalizedPointerKey;
|
|
}
|
|
|
|
return this.watchHistoryTelemetry.buildViewCooldownKey(
|
|
pointerKey,
|
|
identityKey
|
|
);
|
|
},
|
|
|
|
preparePlaybackLogging(videoEl) {
|
|
if (!this.watchHistoryTelemetry) {
|
|
this.cancelPendingViewLogging();
|
|
return;
|
|
}
|
|
|
|
const pointer = this.currentVideoPointer;
|
|
const pointerKey = this.currentVideoPointerKey || pointerArrayToKey(pointer);
|
|
|
|
if (!pointer || !pointerKey) {
|
|
this.watchHistoryTelemetry.cancelPlaybackLogging();
|
|
return;
|
|
}
|
|
|
|
this.watchHistoryTelemetry.preparePlaybackLogging({
|
|
videoElement: videoEl,
|
|
pointer,
|
|
pointerKey,
|
|
video: this.currentVideo,
|
|
});
|
|
},
|
|
|
|
teardownVideoElement(videoElement, { replaceNode = false } = {}) {
|
|
if (!videoElement) {
|
|
this.log(
|
|
`[teardownVideoElement] No video provided (replaceNode=${replaceNode}); skipping.`
|
|
);
|
|
return videoElement;
|
|
}
|
|
|
|
const safe = (fn) => {
|
|
try {
|
|
fn();
|
|
} catch (err) {
|
|
devLogger.warn("[teardownVideoElement]", err);
|
|
}
|
|
};
|
|
|
|
const describeSource = () => {
|
|
try {
|
|
return videoElement.currentSrc || videoElement.src || "<unset>";
|
|
} catch (err) {
|
|
return "<unavailable>";
|
|
}
|
|
};
|
|
|
|
this.log(
|
|
`[teardownVideoElement] Resetting video (replaceNode=${replaceNode}) readyState=${videoElement.readyState} networkState=${videoElement.networkState} src=${describeSource()}`
|
|
);
|
|
|
|
// Destroy any hls.js instance FIRST. It owns a MediaSource, a transmuxing
|
|
// worker and in-flight segment fetches; clearing `src` without destroying
|
|
// it leaves those running (and downloading) after the modal is closed.
|
|
safe(() => detachHls(videoElement));
|
|
|
|
safe(() => videoElement.pause());
|
|
|
|
safe(() => {
|
|
videoElement.removeAttribute("src");
|
|
videoElement.src = "";
|
|
});
|
|
|
|
safe(() => {
|
|
videoElement.srcObject = null;
|
|
});
|
|
|
|
safe(() => {
|
|
if ("crossOrigin" in videoElement) {
|
|
videoElement.crossOrigin = null;
|
|
}
|
|
if (videoElement.hasAttribute("crossorigin")) {
|
|
videoElement.removeAttribute("crossorigin");
|
|
}
|
|
});
|
|
|
|
safe(() => {
|
|
if (typeof videoElement.load === "function") {
|
|
videoElement.load();
|
|
}
|
|
});
|
|
|
|
if (!replaceNode || !videoElement.parentNode) {
|
|
this.log(
|
|
`[teardownVideoElement] Completed without node replacement (readyState=${videoElement.readyState}).`
|
|
);
|
|
return videoElement;
|
|
}
|
|
|
|
const parent = videoElement.parentNode;
|
|
const clone = videoElement.cloneNode(false);
|
|
|
|
if (clone.dataset && "autoplayBound" in clone.dataset) {
|
|
delete clone.dataset.autoplayBound;
|
|
}
|
|
if (clone.hasAttribute("data-autoplay-bound")) {
|
|
clone.removeAttribute("data-autoplay-bound");
|
|
}
|
|
|
|
safe(() => {
|
|
clone.removeAttribute("src");
|
|
clone.src = "";
|
|
});
|
|
|
|
safe(() => {
|
|
clone.srcObject = null;
|
|
});
|
|
|
|
safe(() => {
|
|
if ("crossOrigin" in clone) {
|
|
clone.crossOrigin = null;
|
|
}
|
|
if (clone.hasAttribute("crossorigin")) {
|
|
clone.removeAttribute("crossorigin");
|
|
}
|
|
});
|
|
|
|
clone.autoplay = videoElement.autoplay;
|
|
clone.controls = videoElement.controls;
|
|
clone.loop = videoElement.loop;
|
|
clone.muted = videoElement.muted;
|
|
clone.defaultMuted = videoElement.defaultMuted;
|
|
clone.preload = videoElement.preload;
|
|
clone.playsInline = videoElement.playsInline;
|
|
|
|
clone.poster = "";
|
|
if (clone.hasAttribute("poster")) {
|
|
clone.removeAttribute("poster");
|
|
}
|
|
|
|
let replaced = false;
|
|
safe(() => {
|
|
parent.replaceChild(clone, videoElement);
|
|
replaced = true;
|
|
});
|
|
|
|
if (!replaced) {
|
|
return videoElement;
|
|
}
|
|
|
|
safe(() => {
|
|
if (typeof clone.load === "function") {
|
|
clone.load();
|
|
}
|
|
});
|
|
|
|
this.log(
|
|
`[teardownVideoElement] Replaced modal video node (readyState=${clone.readyState} networkState=${clone.networkState}).`
|
|
);
|
|
|
|
return clone;
|
|
},
|
|
|
|
resetTorrentStats() {
|
|
try {
|
|
if (this.videoModal && typeof this.videoModal.resetStats === "function") {
|
|
this.videoModal.resetStats();
|
|
} else {
|
|
devLogger.info(
|
|
"[Application] resetTorrentStats: videoModal.resetStats not available \u2014 skipping."
|
|
);
|
|
}
|
|
} catch (err) {
|
|
devLogger.warn("[Application] resetTorrentStats failed", err);
|
|
}
|
|
},
|
|
|
|
setShareButtonState(enabled) {
|
|
if (this.videoModal) {
|
|
this.videoModal.setShareEnabled(enabled);
|
|
}
|
|
},
|
|
|
|
getShareUrlBase() {
|
|
if (typeof BITVID_WEBSITE_URL === "string" && BITVID_WEBSITE_URL) {
|
|
// Ensure no trailing slash for consistency if desired, though buildShareUrlFromNevent
|
|
// appends ?v=... so trailing slash is fine if URL ctor handles it.
|
|
// BITVID_WEBSITE_URL usually has a trailing slash in config, but let's be safe.
|
|
return BITVID_WEBSITE_URL.replace(/\/$/, "");
|
|
}
|
|
|
|
try {
|
|
const current = new URL(window.location.href);
|
|
// If we are in the embed, we want to strip that filename.
|
|
if (current.pathname.endsWith("/embed.html")) {
|
|
return `${current.origin}${current.pathname.replace(/\/embed\.html$/, "")}`;
|
|
}
|
|
return `${current.origin}${current.pathname}`;
|
|
} catch (err) {
|
|
const origin = window.location?.origin || "";
|
|
const pathname = window.location?.pathname || "";
|
|
if (origin || pathname) {
|
|
return `${origin}${pathname}`;
|
|
}
|
|
const href = window.location?.href || "";
|
|
if (href) {
|
|
const base = href.split(/[?#]/)[0];
|
|
if (base) {
|
|
return base;
|
|
}
|
|
}
|
|
devLogger.warn("Unable to determine share URL base:", err);
|
|
return "";
|
|
}
|
|
},
|
|
};
|
|
}
|