mirror of
https://github.com/Shik3i/KoalaSync.git
synced 2026-07-26 12:08:15 +00:00
Save baseline state with Disney+ seek adjustments and build identifier
This commit is contained in:
+323
-48
@@ -58,6 +58,262 @@
|
||||
|
||||
// --- SHARED_EVENTS_INJECT_END ---
|
||||
|
||||
|
||||
|
||||
// Suppresses native event reporting after a programmatic action.
|
||||
|
||||
// Each entry is a per-type timer (key = 'playing'|'paused'|'seek').
|
||||
|
||||
// While a timer exists, matching native events are consumed and not relayed.
|
||||
|
||||
// Timers self-clean after 300ms if the native event never fires.
|
||||
|
||||
let _suppressTimers = {};
|
||||
|
||||
|
||||
|
||||
function _setSuppress(state) {
|
||||
|
||||
if (_suppressTimers[state]) clearTimeout(_suppressTimers[state]);
|
||||
|
||||
_suppressTimers[state] = setTimeout(() => {
|
||||
|
||||
delete _suppressTimers[state];
|
||||
|
||||
}, 300);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
function _clearSuppress(state) {
|
||||
|
||||
if (_suppressTimers[state]) {
|
||||
|
||||
clearTimeout(_suppressTimers[state]);
|
||||
|
||||
delete _suppressTimers[state];
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
// --- Seek Relay Filtering ---
|
||||
|
||||
// Minimum seek delta (seconds) to report. Prevents HLS/DASH buffering micro-seeks
|
||||
|
||||
// from being relayed to peers as user-initiated seeks.
|
||||
|
||||
const MIN_SEEK_DELTA = 2.0;
|
||||
|
||||
let lastReportedSeekTime = null; // last currentTime we relayed as a SEEK
|
||||
|
||||
let seekDebounceTimer = null; // debounce timer for rapid seek events
|
||||
|
||||
let expectedSeekTime = null; // strictly track programmatic seeks
|
||||
|
||||
|
||||
|
||||
const PAGE_API_SEEK_BRIDGE = 1;
|
||||
let lastDisneyPlusTimelineCandidates = [];
|
||||
|
||||
function hostMatchesUrl(host, url) {
|
||||
const normalized = String(url || '')
|
||||
.replace(/^https?:\/\//i, '')
|
||||
.split('/')[0]
|
||||
.toLowerCase();
|
||||
return normalized && (host === normalized || host.endsWith(`.${normalized}`));
|
||||
}
|
||||
|
||||
function matchesPlayerUrls(urls) {
|
||||
const host = window.location.hostname.toLowerCase();
|
||||
return Array.isArray(urls) && urls.some(url => hostMatchesUrl(host, url));
|
||||
}
|
||||
|
||||
function isDisneyPlusHost() {
|
||||
return matchesPlayerUrls(['disneyplus.com']);
|
||||
}
|
||||
|
||||
function getSeekableRange(video) {
|
||||
try {
|
||||
const ranges = video.seekable;
|
||||
if (!ranges || ranges.length === 0) return null;
|
||||
const current = video.currentTime;
|
||||
let index = ranges.length - 1;
|
||||
if (Number.isFinite(current)) {
|
||||
for (let i = 0; i < ranges.length; i++) {
|
||||
if (current >= ranges.start(i) && current <= ranges.end(i)) {
|
||||
index = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
const start = ranges.start(index);
|
||||
const end = ranges.end(index);
|
||||
if (!Number.isFinite(start) || !Number.isFinite(end) || end <= start) return null;
|
||||
return { start, end, duration: end - start };
|
||||
} catch (_e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function parseClockTime(value) {
|
||||
const parts = String(value || '').trim().split(':').map(Number);
|
||||
if (parts.length < 2 || parts.length > 3 || parts.some(n => !Number.isFinite(n))) return null;
|
||||
return parts.length === 2
|
||||
? parts[0] * 60 + parts[1]
|
||||
: parts[0] * 3600 + parts[1] * 60 + parts[2];
|
||||
}
|
||||
|
||||
function parseTimelineText(text) {
|
||||
const matches = String(text || '').match(/\b\d{1,3}:\d{2}(?::\d{2})?\b/g);
|
||||
if (!matches || matches.length < 2) return null;
|
||||
const current = parseClockTime(matches[0]);
|
||||
const duration = parseClockTime(matches[matches.length - 1]);
|
||||
if (!Number.isFinite(current) || !Number.isFinite(duration) || duration < 60 || current > duration + 5) return null;
|
||||
return { current, duration };
|
||||
}
|
||||
|
||||
function getDisneyPlusUiTimeline() {
|
||||
if (typeof document === 'undefined') return null;
|
||||
const selectors = [
|
||||
'[role="slider"]',
|
||||
'[role="progressbar"]',
|
||||
'[aria-valuenow][aria-valuemax]',
|
||||
'[aria-valuetext]',
|
||||
'[aria-label]',
|
||||
'[data-testid]',
|
||||
'[data-test]',
|
||||
'[class*="time" i]',
|
||||
'[class*="progress" i]',
|
||||
'time',
|
||||
'output',
|
||||
'button',
|
||||
'span',
|
||||
'p',
|
||||
'div'
|
||||
].join(',');
|
||||
const nodes = Array.from(document.querySelectorAll(selectors)).slice(0, 2000);
|
||||
const textParts = [];
|
||||
const candidates = [];
|
||||
let best = null;
|
||||
let bestScore = -1;
|
||||
|
||||
function considerTimeline(parsed, source, weight = 0) {
|
||||
if (!parsed) return;
|
||||
const score = weight +
|
||||
(parsed.duration >= 20 * 60 ? 200 : 0) +
|
||||
(parsed.current > 0 ? 50 : 0) -
|
||||
Math.abs((parsed.duration / 2) - parsed.current) / 100;
|
||||
candidates.push({ ...parsed, source, score: Math.round(score) });
|
||||
if (score > bestScore) {
|
||||
bestScore = score;
|
||||
best = parsed;
|
||||
}
|
||||
}
|
||||
|
||||
for (const node of nodes) {
|
||||
const now = Number(node.getAttribute?.('aria-valuenow') ?? node.value);
|
||||
const max = Number(node.getAttribute?.('aria-valuemax') ?? node.max);
|
||||
if (Number.isFinite(now) && Number.isFinite(max) && max >= 60 && now >= 0 && now <= max + 5) {
|
||||
considerTimeline({ current: now, duration: max }, 'aria-value', 300);
|
||||
}
|
||||
|
||||
for (const attr of ['aria-valuetext', 'aria-label', 'title', 'data-testid', 'data-test']) {
|
||||
const parsed = parseTimelineText(node.getAttribute?.(attr));
|
||||
considerTimeline(parsed, attr, 200);
|
||||
}
|
||||
|
||||
const text = String(node.textContent || '').trim();
|
||||
if (text && text.length < 200) {
|
||||
const parsed = parseTimelineText(text);
|
||||
considerTimeline(parsed, 'text', 100);
|
||||
textParts.push(text);
|
||||
}
|
||||
}
|
||||
|
||||
considerTimeline(parseTimelineText(textParts.join(' ')), 'combined-text', 25);
|
||||
lastDisneyPlusTimelineCandidates = candidates
|
||||
.sort((a, b) => b.score - a.score)
|
||||
.slice(0, 8)
|
||||
.map(({ source, current, duration }) => ({ source, current, duration }));
|
||||
return best;
|
||||
}
|
||||
|
||||
function getElementLabel(el) {
|
||||
return [
|
||||
el.getAttribute?.('aria-label'),
|
||||
el.getAttribute?.('title'),
|
||||
el.getAttribute?.('data-testid'),
|
||||
el.getAttribute?.('data-test'),
|
||||
el.textContent
|
||||
].filter(Boolean).join(' ').replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
|
||||
function getDisneyPlusSeekButtonLabels() {
|
||||
if (!isDisneyPlusHost() || typeof document === 'undefined') return [];
|
||||
return Array.from(document.querySelectorAll('button,[role="button"]'))
|
||||
.map(getElementLabel)
|
||||
.filter(Boolean)
|
||||
.slice(0, 20);
|
||||
}
|
||||
|
||||
function clickDisneyPlusRelativeSeek(delta) {
|
||||
if (!isDisneyPlusHost() || !Number.isFinite(delta) || Math.abs(delta) < 1 || typeof document === 'undefined') return false;
|
||||
const backward = delta < 0;
|
||||
const candidates = Array.from(document.querySelectorAll('button,[role="button"]'));
|
||||
const backRe = /rewind|backward|back\b|zurück|zurueck|rück|rueck|retour|recul|retroced|atrás|voltar|indietro/i;
|
||||
const forwardRe = /forward|ahead|skip|vorwärts|vorwaerts|vorsp|weiter|avancer|adelant|avançar|avancar|avanti/i;
|
||||
const timeRe = /\b(5|10|15|30)\b|sec|sek|second/i;
|
||||
|
||||
const button = candidates.find(btn => {
|
||||
const label = getElementLabel(btn);
|
||||
const matchesDirection = backward ? backRe.test(label) : forwardRe.test(label);
|
||||
return matchesDirection && timeRe.test(label);
|
||||
}) || candidates.find(btn => {
|
||||
const label = getElementLabel(btn);
|
||||
return backward ? backRe.test(label) : forwardRe.test(label);
|
||||
});
|
||||
|
||||
if (!button || typeof button.click !== 'function') return false;
|
||||
const clicks = Math.max(1, Math.min(12, Math.round(Math.abs(delta) / 10)));
|
||||
for (let i = 0; i < clicks; i++) {
|
||||
setTimeout(() => button.click(), i * 60);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function getDisneyPlusTimeline(video) {
|
||||
if (!isDisneyPlusHost()) return null;
|
||||
const range = getSeekableRange(video);
|
||||
const current = video.currentTime;
|
||||
const ui = getDisneyPlusUiTimeline();
|
||||
|
||||
if (ui) {
|
||||
let nativeScale = 1;
|
||||
if (range && range.duration > ui.duration * 1.2) {
|
||||
nativeScale = range.duration / ui.duration;
|
||||
} else if (Number.isFinite(current) && ui.current > 1 && current > ui.current * 1.5) {
|
||||
nativeScale = current / ui.current;
|
||||
}
|
||||
const nativeStart = Number.isFinite(current)
|
||||
? current - ui.current * nativeScale
|
||||
: (range ? range.start : 0);
|
||||
return {
|
||||
...(range || {}),
|
||||
current: ui.current,
|
||||
duration: ui.duration,
|
||||
nativeScale,
|
||||
nativeStart
|
||||
};
|
||||
}
|
||||
|
||||
if (!range || range.start < 1 || range.duration < 60) return null;
|
||||
if (!Number.isFinite(current) || current < range.start - 1 || current > range.end + 1) return null;
|
||||
return { ...range, current: Math.max(0, current - range.start), nativeScale: 1, nativeStart: range.start };
|
||||
}
|
||||
|
||||
// Site-specific player exceptions live here. The default HTML5 path stays below.
|
||||
function getSiteQuirkAdapters() {
|
||||
@@ -65,13 +321,19 @@
|
||||
name: 'disneyplus-timeline-and-buttons',
|
||||
key: 'disneyPlus',
|
||||
urls: ['disneyplus.com'],
|
||||
function seekVideo(video, targetTime) {
|
||||
matches() { return matchesPlayerUrls(this.urls); },
|
||||
getTimeline: getDisneyPlusTimeline,
|
||||
clickRelativeSeek: clickDisneyPlusRelativeSeek,
|
||||
getDebug(video) {
|
||||
return {
|
||||
name: this.name,
|
||||
key: 'disneyPlus',
|
||||
urls: this.urls,
|
||||
timeline: getDisneyPlusTimeline(video),
|
||||
timelineCandidates: lastDisneyPlusTimelineCandidates,
|
||||
seekButtons: getDisneyPlusSeekButtonLabels()
|
||||
};
|
||||
video.currentTime = targetTime;
|
||||
}
|
||||
}];
|
||||
}
|
||||
|
||||
@@ -737,7 +999,42 @@
|
||||
// otherwise the desynced user silently never sees the badge (L-4).
|
||||
|
||||
if (!hcmBadgePending) {
|
||||
// --- Helper: YouTube/Twitch specific actions ---
|
||||
|
||||
hcmBadgePending = true;
|
||||
|
||||
const retry = () => {
|
||||
|
||||
hcmBadgePending = false;
|
||||
|
||||
if (hcmDesynced && !hcmBadgeHost) hcmShowBadge();
|
||||
|
||||
};
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
|
||||
document.addEventListener('DOMContentLoaded', retry, { once: true });
|
||||
|
||||
} else {
|
||||
|
||||
setTimeout(retry, 50);
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
return;
|
||||
|
||||
}
|
||||
|
||||
const host = hcmEl('div', 'all:initial');
|
||||
|
||||
const root = host.attachShadow({ mode: 'open' });
|
||||
|
||||
const b = hcmEl('div', 'position:fixed;z-index:2147483646;right:16px;bottom:16px;background:#b45309;color:#fff;font:13px/1.3 system-ui,sans-serif;padding:8px 12px;border-radius:10px;box-shadow:0 6px 20px rgba(0,0,0,.4);cursor:pointer;display:flex;align-items:center;gap:8px');
|
||||
|
||||
b.append(hcmEl('span', null, '● ' + hcmStrings.badge), hcmEl('span', 'text-decoration:underline', hcmStrings.resync));
|
||||
|
||||
b.addEventListener('click', hcmExitDesync);
|
||||
|
||||
root.appendChild(b);
|
||||
|
||||
@@ -752,39 +1049,10 @@
|
||||
function hcmRemoveBadge() {
|
||||
|
||||
if (hcmBadgeHost) { hcmBadgeHost.remove(); hcmBadgeHost = null; }
|
||||
const host = window.location.hostname.toLowerCase();
|
||||
const isYouTube = host === 'youtube.com' || host.endsWith('.youtube.com');
|
||||
const isTwitch = host === 'twitch.tv' || host.endsWith('.twitch.tv');
|
||||
|
||||
if (isYouTube) {
|
||||
const ytButton = document.querySelector('.ytp-play-button');
|
||||
if (ytButton) {
|
||||
const isCurrentlyPlaying = !video.paused;
|
||||
if ((action === EVENTS.PLAY && !isCurrentlyPlaying) || (action === EVENTS.PAUSE && isCurrentlyPlaying)) {
|
||||
_setSuppress(action === EVENTS.PLAY ? 'playing' : 'paused');
|
||||
ytButton.click();
|
||||
}
|
||||
if (action === EVENTS.SEEK) {
|
||||
seekVideo(video, data.targetTime);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
if (isTwitch) {
|
||||
const twitchButton = document.querySelector('[data-a-target="player-play-pause-button"]');
|
||||
if (twitchButton) {
|
||||
const isCurrentlyPlaying = !video.paused;
|
||||
if ((action === EVENTS.PLAY && !isCurrentlyPlaying) || (action === EVENTS.PAUSE && isCurrentlyPlaying)) {
|
||||
_setSuppress(action === EVENTS.PLAY ? 'playing' : 'paused');
|
||||
twitchButton.click();
|
||||
}
|
||||
if (action === EVENTS.SEEK) {
|
||||
seekVideo(video, data.targetTime);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
function hcmReset() {
|
||||
|
||||
@@ -797,7 +1065,7 @@
|
||||
hcmSnapBackCooldownUntil = 0; // don't let a stale cooldown swallow the next snap-back
|
||||
|
||||
hcmBufferingUntil = 0;
|
||||
seekVideo(video, data.targetTime);
|
||||
|
||||
hcmRemoveDialog();
|
||||
|
||||
hcmRemoveBadge();
|
||||
@@ -818,7 +1086,8 @@
|
||||
|
||||
|
||||
|
||||
const timeDiff = Math.abs(video.currentTime - targetTime);
|
||||
function reportLog(message, level = 'info') {
|
||||
|
||||
chrome.runtime.sendMessage({ type: 'LOG', message, level }).catch(() => {});
|
||||
|
||||
}
|
||||
@@ -1094,8 +1363,10 @@
|
||||
node.gain.linearRampToValueAtTime(value, t + 0.04);
|
||||
|
||||
}
|
||||
currentTime: video.currentTime,
|
||||
duration: video.duration || 0,
|
||||
|
||||
|
||||
|
||||
function applyAudioBypass(videoEl) {
|
||||
|
||||
const chain = audioChains.get(videoEl);
|
||||
|
||||
@@ -1124,6 +1395,7 @@
|
||||
|
||||
|
||||
function applyAudioSettings(videoEl, settings) {
|
||||
|
||||
const mergedSettings = mergeAudioSettings(settings);
|
||||
|
||||
if (!mergedSettings.enabled || !mergedSettings.compressor?.enabled) {
|
||||
@@ -1153,8 +1425,8 @@
|
||||
|
||||
|
||||
chain.compressor.threshold.value = params.threshold ?? -24;
|
||||
const current = video.currentTime;
|
||||
if (!Number.isFinite(current)) return;
|
||||
|
||||
chain.compressor.knee.value = params.knee ?? 15;
|
||||
|
||||
chain.compressor.ratio.value = params.ratio ?? 8;
|
||||
|
||||
@@ -1191,8 +1463,10 @@
|
||||
? navigator.mediaSession.metadata.title
|
||||
|
||||
: null;
|
||||
if (v && Number.isFinite(v.currentTime)) {
|
||||
lastReportedSeekTime = v.currentTime;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
// Extract a canonical episode identifier from a title string.
|
||||
|
||||
@@ -1201,8 +1475,8 @@
|
||||
// Returns null if no episode pattern found.
|
||||
|
||||
// --- SHARED_EPISODE_UTILS_INJECT_START ---
|
||||
const current = video.currentTime;
|
||||
if (!Number.isFinite(current)) return;
|
||||
|
||||
// This block is automatically replaced by /scripts/build-extension.js
|
||||
|
||||
function extractEpisodeId(title) {
|
||||
|
||||
@@ -1297,8 +1571,8 @@
|
||||
// Always track the latest known title
|
||||
|
||||
if (currentTitle) lastKnownMediaTitle = currentTitle;
|
||||
const current = video.currentTime;
|
||||
if (!Number.isFinite(current)) return;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1332,7 +1606,8 @@
|
||||
|
||||
// Background checks the setting; if enabled it creates a lobby
|
||||
|
||||
const settled = v.currentTime;
|
||||
// and sends back PAUSE_FOR_LOBBY so we only freeze if the feature is on.
|
||||
|
||||
chrome.runtime.sendMessage({
|
||||
|
||||
type: 'EPISODE_CHANGED',
|
||||
@@ -1437,7 +1712,7 @@
|
||||
|
||||
if (lobbyPollTimer) {
|
||||
|
||||
currentTime: video.currentTime,
|
||||
clearInterval(lobbyPollTimer);
|
||||
|
||||
lobbyPollTimer = null;
|
||||
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
(function(root) {
|
||||
const PAGE_API_SEEK_PROVIDERS = [
|
||||
{ domain: 'netflix.com', provider: 'netflix' } // Avoids M7375 when seeking via video.currentTime.
|
||||
const PAGE_API_SEEK_FIXES = [
|
||||
{
|
||||
name: 'netflix-page-api-seek',
|
||||
urls: ['netflix.com'],
|
||||
provider: 'netflix'
|
||||
}
|
||||
];
|
||||
|
||||
function normalizeHost(input) {
|
||||
@@ -12,12 +16,16 @@
|
||||
}
|
||||
|
||||
function matchesDomain(host, domain) {
|
||||
return host === domain || host.endsWith(`.${domain}`);
|
||||
const normalizedDomain = normalizeHost(domain);
|
||||
return normalizedDomain && (host === normalizedDomain || host.endsWith(`.${normalizedDomain}`));
|
||||
}
|
||||
|
||||
root.KOALA_PAGE_API_SEEK_PROVIDERS = PAGE_API_SEEK_PROVIDERS;
|
||||
root.KOALA_PAGE_API_SEEK_FIXES = PAGE_API_SEEK_FIXES;
|
||||
root.KOALA_PAGE_API_SEEK_PROVIDERS = PAGE_API_SEEK_FIXES;
|
||||
root.koalaFindPageApiSeekProvider = (input) => {
|
||||
const host = normalizeHost(input);
|
||||
return PAGE_API_SEEK_PROVIDERS.find(entry => matchesDomain(host, entry.domain)) || null;
|
||||
return PAGE_API_SEEK_FIXES.find(entry =>
|
||||
Array.isArray(entry.urls) && entry.urls.some(url => matchesDomain(host, url))
|
||||
) || null;
|
||||
};
|
||||
})(globalThis);
|
||||
|
||||
+41
-5
@@ -74,7 +74,8 @@ const elements = {
|
||||
syncTabCopyInvite: document.getElementById('syncTabCopyInvite'),
|
||||
devToolsTabBtn: document.getElementById('devToolsTabBtn'),
|
||||
remoteSeekBack: document.getElementById('remoteSeekBack'),
|
||||
remoteSeekForward: document.getElementById('remoteSeekForward')
|
||||
remoteSeekForward: document.getElementById('remoteSeekForward'),
|
||||
remoteSeekFiveMin: document.getElementById('remoteSeekFiveMin')
|
||||
};
|
||||
|
||||
let localPeerId = null;
|
||||
@@ -1708,8 +1709,8 @@ elements.pauseBtn.addEventListener('click', () => {
|
||||
}, 2500);
|
||||
});
|
||||
|
||||
function simulateRemoteSeek(delta) {
|
||||
chrome.runtime.sendMessage({ type: 'DEV_SIMULATE_REMOTE_SEEK', delta }, (res) => {
|
||||
function simulateRemoteSeek({ delta = null, targetTime = null }) {
|
||||
chrome.runtime.sendMessage({ type: 'DEV_SIMULATE_REMOTE_SEEK', delta, targetTime }, (res) => {
|
||||
if (chrome.runtime.lastError || !res || res.status !== 'ok') {
|
||||
showToast(res?.message || 'Remote seek failed', 'error');
|
||||
return;
|
||||
@@ -1720,10 +1721,13 @@ function simulateRemoteSeek(delta) {
|
||||
}
|
||||
|
||||
if (elements.remoteSeekBack) {
|
||||
elements.remoteSeekBack.addEventListener('click', () => simulateRemoteSeek(-30));
|
||||
elements.remoteSeekBack.addEventListener('click', () => simulateRemoteSeek({ delta: -30 }));
|
||||
}
|
||||
if (elements.remoteSeekForward) {
|
||||
elements.remoteSeekForward.addEventListener('click', () => simulateRemoteSeek(30));
|
||||
elements.remoteSeekForward.addEventListener('click', () => simulateRemoteSeek({ delta: 30 }));
|
||||
}
|
||||
if (elements.remoteSeekFiveMin) {
|
||||
elements.remoteSeekFiveMin.addEventListener('click', () => simulateRemoteSeek({ targetTime: 300 }));
|
||||
}
|
||||
|
||||
elements.clearLogs.addEventListener('click', () => {
|
||||
@@ -2083,6 +2087,26 @@ elements.copyLogs.addEventListener('click', () => {
|
||||
lines.push(`- **ReadyState:** ${readyOk ? '\u2705' : '\u26A0\uFE0F'} ${safe(vs.readyState, '?')} (${readyLabel})`);
|
||||
lines.push(`- **Network:** ${safe(vs.networkState, '?')} (${netLabel})`);
|
||||
lines.push(`- **Buffered:** ${safe(vs.buffered, '?')}`);
|
||||
if (vs.nativeCurrentTime != null || vs.nativeDuration != null) {
|
||||
lines.push(`- **Native Time:** ${safe(vs.nativeCurrentTime, '?')}s / ${safe(vs.nativeDuration, '?')}s`);
|
||||
}
|
||||
if (vs.siteQuirk) {
|
||||
const quirk = vs.siteQuirk;
|
||||
const label = quirk.name || quirk.key || 'site';
|
||||
if (quirk.timeline) {
|
||||
lines.push(`- **${label} Timeline:** ${safe(quirk.timeline.current, '?')}s / ${safe(quirk.timeline.duration, '?')}s`);
|
||||
}
|
||||
const candidates = Array.isArray(quirk.timelineCandidates) ? quirk.timelineCandidates : [];
|
||||
if (candidates.length > 0) {
|
||||
lines.push(`- **${label} Timeline Candidates:**`);
|
||||
candidates.forEach(c => lines.push(` - ${safe(c.source, '?')}: ${safe(c.current, '?')}s / ${safe(c.duration, '?')}s`));
|
||||
}
|
||||
const buttons = Array.isArray(quirk.seekButtons) ? quirk.seekButtons : [];
|
||||
if (buttons.length > 0) {
|
||||
lines.push(`- **${label} Buttons:**`);
|
||||
buttons.forEach(label => lines.push(` - ${label}`));
|
||||
}
|
||||
}
|
||||
lines.push(`- **Dimensions:** ${safe(vs.videoWidth, '?')}x${safe(vs.videoHeight, '?')}${dimOk ? '' : ' \u26A0\uFE0F 0x0'}`);
|
||||
lines.push(`- **Muted:** ${safe(vs.muted, '?')} | **Volume:** ${safe(vs.volume, '?')} | **Speed:** ${safe(vs.playbackRate, '?')}x`);
|
||||
lines.push(`- **Seeking:** ${safe(vs.seeking, '?')} | **Ended:** ${safe(vs.ended, '?')} | **Loop:** ${safe(vs.loop, '?')}`);
|
||||
@@ -2256,6 +2280,18 @@ function refreshDebugInfo() {
|
||||
addField('Network', `${state.networkState} (${state.networkStateLabel || '?'})`,
|
||||
state.networkState === 1 ? '#22c55e' : state.networkState === 3 ? '#ef4444' : 'var(--text-muted)');
|
||||
addField('Buffered', state.buffered || '?');
|
||||
if (state.nativeCurrentTime != null || state.nativeDuration != null) {
|
||||
addField('Native Time', `${state.nativeCurrentTime ?? '?'}s / ${state.nativeDuration ?? '?'}s`);
|
||||
}
|
||||
if (state.siteQuirk) {
|
||||
const quirk = state.siteQuirk;
|
||||
const label = quirk.name || quirk.key || 'Site';
|
||||
if (quirk.timeline) {
|
||||
addField(`${label} Timeline`, `${quirk.timeline.current ?? '?'}s / ${quirk.timeline.duration ?? '?'}s`);
|
||||
}
|
||||
const buttons = Array.isArray(quirk.seekButtons) ? quirk.seekButtons.slice(0, 4).join(' | ') : '';
|
||||
if (buttons) addField(`${label} Buttons`, buttons);
|
||||
}
|
||||
|
||||
addSection('Properties');
|
||||
addField('Seeking', String(state.seeking));
|
||||
|
||||
@@ -19,6 +19,14 @@ function extractFunction(name, text) {
|
||||
throw new Error(`${name} body did not terminate`);
|
||||
}
|
||||
|
||||
function makeSeekable(ranges = []) {
|
||||
return {
|
||||
length: ranges.length,
|
||||
start(i) { return ranges[i][0]; },
|
||||
end(i) { return ranges[i][1]; }
|
||||
};
|
||||
}
|
||||
|
||||
function makeVideo(name, width, height, options = {}) {
|
||||
return {
|
||||
name,
|
||||
@@ -28,7 +36,9 @@ function makeVideo(name, width, height, options = {}) {
|
||||
offsetWidth: width,
|
||||
offsetHeight: height,
|
||||
muted: options.muted ?? true,
|
||||
duration: options.duration ?? 0
|
||||
duration: options.duration ?? 0,
|
||||
currentTime: options.currentTime ?? 0,
|
||||
seekable: options.seekable ?? makeSeekable()
|
||||
};
|
||||
}
|
||||
|
||||
@@ -61,4 +71,100 @@ assert.strictEqual(
|
||||
'findVideo should score Shadow DOM videos together with light DOM videos'
|
||||
);
|
||||
|
||||
function makeDocument(nodes = []) {
|
||||
return {
|
||||
querySelectorAll() { return nodes; }
|
||||
};
|
||||
}
|
||||
|
||||
function makeNode(attrs = {}, textContent = '') {
|
||||
return {
|
||||
textContent,
|
||||
value: attrs.value,
|
||||
max: attrs.max,
|
||||
clicked: 0,
|
||||
click() { this.clicked += 1; },
|
||||
getAttribute(name) { return attrs[name] ?? null; }
|
||||
};
|
||||
}
|
||||
|
||||
function loadTimelineFns(hostname, document = makeDocument()) {
|
||||
return Function('window', 'document', [
|
||||
'let lastDisneyPlusTimelineCandidates = [];',
|
||||
extractFunction('hostMatchesUrl', source),
|
||||
extractFunction('matchesPlayerUrls', source),
|
||||
extractFunction('isDisneyPlusHost', source),
|
||||
extractFunction('getSeekableRange', source),
|
||||
extractFunction('parseClockTime', source),
|
||||
extractFunction('parseTimelineText', source),
|
||||
extractFunction('getDisneyPlusUiTimeline', source),
|
||||
extractFunction('getElementLabel', source),
|
||||
extractFunction('getDisneyPlusSeekButtonLabels', source),
|
||||
extractFunction('clickDisneyPlusRelativeSeek', source),
|
||||
extractFunction('getDisneyPlusTimeline', source),
|
||||
extractFunction('getSiteQuirkAdapters', source),
|
||||
extractFunction('getActiveSiteQuirk', source),
|
||||
extractFunction('getSiteQuirkTimeline', source),
|
||||
extractFunction('getSiteQuirkDebug', source),
|
||||
extractFunction('getSyncCurrentTime', source),
|
||||
extractFunction('getSyncDuration', source),
|
||||
extractFunction('toNativeSeekTime', source),
|
||||
'return { getActiveSiteQuirk, getSyncCurrentTime, getSyncDuration, toNativeSeekTime, clickDisneyPlusRelativeSeek };'
|
||||
].join('\n'))({ location: { hostname } }, document);
|
||||
}
|
||||
|
||||
function loadPlayerFixFns(hostname) {
|
||||
return Function('window', [
|
||||
extractFunction('hostMatchesUrl', source),
|
||||
extractFunction('matchesPlayerUrls', source),
|
||||
extractFunction('getPlayerActionFixes', source),
|
||||
extractFunction('getActivePlayerActionFix', source),
|
||||
'return { getPlayerActionFixes, getActivePlayerActionFix };'
|
||||
].join('\n'))({ location: { hostname } });
|
||||
}
|
||||
|
||||
const disneyUiDocument = makeDocument([
|
||||
makeNode({ 'aria-valuetext': '0:09 / 180:00' })
|
||||
]);
|
||||
const disneyFns = loadTimelineFns('www.disneyplus.com', disneyUiDocument);
|
||||
assert.equal(disneyFns.getActiveSiteQuirk().name, 'disneyplus-timeline-and-buttons');
|
||||
assert.deepEqual(disneyFns.getActiveSiteQuirk().urls, ['disneyplus.com']);
|
||||
const disneyVideo = makeVideo('disney-offset', 1920, 1080, {
|
||||
currentTime: 29,
|
||||
duration: 0,
|
||||
seekable: makeSeekable([[0, 32400]])
|
||||
});
|
||||
assert.equal(disneyFns.getSyncCurrentTime(disneyVideo), 9);
|
||||
assert.equal(disneyFns.getSyncDuration(disneyVideo), 10800);
|
||||
assert.equal(disneyFns.toNativeSeekTime(disneyVideo, 39), 119);
|
||||
|
||||
const disneySeekableFallbackFns = loadTimelineFns('www.disneyplus.com');
|
||||
const disneyOffsetVideo = makeVideo('disney-offset', 1920, 1080, {
|
||||
currentTime: 29,
|
||||
duration: 0,
|
||||
seekable: makeSeekable([[20, 10820]])
|
||||
});
|
||||
assert.equal(disneySeekableFallbackFns.getSyncCurrentTime(disneyOffsetVideo), 9);
|
||||
assert.equal(disneySeekableFallbackFns.getSyncDuration(disneyOffsetVideo), 10800);
|
||||
assert.equal(disneySeekableFallbackFns.toNativeSeekTime(disneyOffsetVideo, 39), 59);
|
||||
|
||||
const genericFns = loadTimelineFns('example.com');
|
||||
assert.equal(genericFns.getActiveSiteQuirk(), null);
|
||||
assert.equal(genericFns.getSyncCurrentTime(disneyVideo), 29);
|
||||
assert.equal(genericFns.getSyncDuration(disneyVideo), 0);
|
||||
assert.equal(genericFns.toNativeSeekTime(disneyVideo, 39), 39);
|
||||
|
||||
const backButton = makeNode({ 'aria-label': '10 Sekunden zurück' });
|
||||
const forwardButton = makeNode({ 'aria-label': '10 Sekunden vorspulen' });
|
||||
const disneyButtonFns = loadTimelineFns('www.disneyplus.com', makeDocument([backButton, forwardButton]));
|
||||
assert.equal(disneyButtonFns.clickDisneyPlusRelativeSeek(-30), true);
|
||||
assert.equal(disneyButtonFns.clickDisneyPlusRelativeSeek(30), true);
|
||||
|
||||
const twitchFixFns = loadPlayerFixFns('player.twitch.tv');
|
||||
assert.equal(twitchFixFns.getActivePlayerActionFix().name, 'twitch-player-buttons');
|
||||
assert.deepEqual(twitchFixFns.getActivePlayerActionFix().urls, ['twitch.tv']);
|
||||
|
||||
const genericFixFns = loadPlayerFixFns('example.com');
|
||||
assert.equal(genericFixFns.getActivePlayerActionFix(), null);
|
||||
|
||||
console.log('content video finder tests passed');
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 1.3 KiB |
Reference in New Issue
Block a user