reels-controls/content.js

456 lines
16 KiB
JavaScript

// ============================================================
// Reels Controls — content.js
// Hängt eine eigene Videosteuerung an Instagram-Reels-Videos.
// ============================================================
(() => {
"use strict";
const ATTACHED = "data-rc-attached";
const STORAGE_KEY = "rc_volume";
// Zuletzt genutzte Lautstärke (persistiert über sessionStorage,
// damit nicht jedes neue Reel wieder auf 100% / stumm springt).
let lastVolume = 1;
let lastMuted = false;
try {
const saved = JSON.parse(sessionStorage.getItem(STORAGE_KEY) || "null");
if (saved && typeof saved.volume === "number") {
lastVolume = saved.volume;
lastMuted = !!saved.muted;
}
} catch (_) {}
function persistVolume() {
try {
sessionStorage.setItem(
STORAGE_KEY,
JSON.stringify({ volume: lastVolume, muted: lastMuted })
);
} catch (_) {}
}
// ----------------------------------------------------------
// Erkennen, ob ein <video> ein Reel ist.
// Reels liegen in einem hochformatigen Container; wir nehmen
// jedes Video, das groß genug ist und im Hauptbereich liegt.
// ----------------------------------------------------------
function isReelVideo(video) {
if (!(video instanceof HTMLVideoElement)) return false;
const rect = video.getBoundingClientRect();
// Sehr kleine Videos (Avatare, Vorschauen) ignorieren.
return rect.height > 250 && rect.width > 150;
}
function fmtTime(sec) {
if (!isFinite(sec) || sec < 0) sec = 0;
const m = Math.floor(sec / 60);
const s = Math.floor(sec % 60);
return m + ":" + String(s).padStart(2, "0");
}
// SVG-Icons inline (keine externen Assets nötig).
const ICONS = {
play: '<svg viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>',
pause:
'<svg viewBox="0 0 24 24"><path d="M6 5h4v14H6zM14 5h4v14h-4z"/></svg>',
volHigh:
'<svg viewBox="0 0 24 24"><path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3a4.5 4.5 0 0 0-2.5-4v8a4.5 4.5 0 0 0 2.5-4zM14 3.2v2.1a7 7 0 0 1 0 13.4v2.1a9 9 0 0 0 0-17.6z"/></svg>',
volMute:
'<svg viewBox="0 0 24 24"><path d="M3 9v6h4l5 5V4L7 9H3zm18.5 3-2.3-2.3-1.4 1.4 2.3 2.3-2.3 2.3 1.4 1.4 2.3-2.3 2.3 2.3 1.4-1.4-2.3-2.3 2.3-2.3-1.4-1.4z"/></svg>',
};
// ----------------------------------------------------------
// Steuerleiste für ein Video bauen.
// ----------------------------------------------------------
function buildControls(video) {
const bar = document.createElement("div");
bar.className = "rc-bar";
// Klicks auf der Leiste sollen NICHT bis zu Instagram durchgehen
// (sonst pausiert/scrollt IG selbst).
["click", "mousedown", "pointerdown", "dblclick"].forEach((ev) =>
bar.addEventListener(ev, (e) => e.stopPropagation())
);
// --- Play / Pause -----------------------------------------
const playBtn = document.createElement("button");
playBtn.className = "rc-btn rc-play";
playBtn.title = "Wiedergabe / Pause (Leertaste)";
const togglePlay = () => {
if (video.paused) video.play();
else video.pause();
};
playBtn.addEventListener("click", togglePlay);
const syncPlayIcon = () => {
playBtn.innerHTML = video.paused ? ICONS.play : ICONS.pause;
};
// --- Fortschrittsbalken -----------------------------------
const progWrap = document.createElement("div");
progWrap.className = "rc-progress";
const progFill = document.createElement("div");
progFill.className = "rc-progress-fill";
const progKnob = document.createElement("div");
progKnob.className = "rc-progress-knob";
progWrap.appendChild(progFill);
progWrap.appendChild(progKnob);
let scrubbing = false;
const seekFromEvent = (e) => {
const rect = progWrap.getBoundingClientRect();
const ratio = Math.min(
1,
Math.max(0, (e.clientX - rect.left) / rect.width)
);
if (isFinite(video.duration)) {
video.currentTime = ratio * video.duration;
updateProgress();
}
};
progWrap.addEventListener("pointerdown", (e) => {
scrubbing = true;
progWrap.setPointerCapture(e.pointerId);
seekFromEvent(e);
});
progWrap.addEventListener("pointermove", (e) => {
if (scrubbing) seekFromEvent(e);
});
progWrap.addEventListener("pointerup", (e) => {
scrubbing = false;
try {
progWrap.releasePointerCapture(e.pointerId);
} catch (_) {}
});
// --- Zeitanzeige ------------------------------------------
const timeLabel = document.createElement("span");
timeLabel.className = "rc-time";
timeLabel.textContent = "0:00 / 0:00";
// --- Lautstärke -------------------------------------------
const volBtn = document.createElement("button");
volBtn.className = "rc-btn rc-vol-btn";
volBtn.title = "Stummschalten (M)";
const volSlider = document.createElement("input");
volSlider.type = "range";
volSlider.min = "0";
volSlider.max = "1";
volSlider.step = "0.01";
volSlider.className = "rc-vol-slider";
const applyVolumeUI = () => {
volBtn.innerHTML = video.muted || video.volume === 0
? ICONS.volMute
: ICONS.volHigh;
volSlider.value = video.muted ? 0 : video.volume;
volSlider.style.setProperty(
"--rc-fill",
(video.muted ? 0 : video.volume) * 100 + "%"
);
};
volSlider.addEventListener("input", () => {
const v = parseFloat(volSlider.value);
// Wunsch-Lautstärke: bei 0 den letzten hörbaren Wert behalten,
// damit das Entstummen wieder dorthin zurückkehrt.
if (v > 0) video._rcDesiredVolume = v;
video._rcUserMuted = v === 0;
rcGuarding = true;
video.volume = v;
video.muted = v === 0;
rcGuarding = false;
lastVolume = video._rcDesiredVolume;
lastMuted = video._rcUserMuted;
persistVolume();
applyVolumeUI();
});
volBtn.addEventListener("click", () => {
video._rcUserMuted = !video._rcUserMuted;
rcGuarding = true;
video.muted = video._rcUserMuted;
// Beim Entstummen die gewünschte Lautstärke wiederherstellen.
if (!video._rcUserMuted) {
if (!video._rcDesiredVolume || video._rcDesiredVolume === 0) {
video._rcDesiredVolume = 0.5;
}
video.volume = video._rcDesiredVolume;
}
rcGuarding = false;
lastVolume = video._rcDesiredVolume;
lastMuted = video._rcUserMuted;
persistVolume();
applyVolumeUI();
});
// --- Zusammenbauen ----------------------------------------
bar.appendChild(playBtn);
bar.appendChild(progWrap);
bar.appendChild(timeLabel);
bar.appendChild(volBtn);
bar.appendChild(volSlider);
// ----------------------------------------------------------
// Live-Synchronisation
// ----------------------------------------------------------
function updateProgress() {
const dur = isFinite(video.duration) ? video.duration : 0;
const ratio = dur ? video.currentTime / dur : 0;
progFill.style.width = ratio * 100 + "%";
progKnob.style.left = ratio * 100 + "%";
timeLabel.textContent =
fmtTime(video.currentTime) + " / " + fmtTime(dur);
}
video.addEventListener("timeupdate", updateProgress);
video.addEventListener("durationchange", updateProgress);
video.addEventListener("play", syncPlayIcon);
video.addEventListener("pause", syncPlayIcon);
// ----------------------------------------------------------
// Mute-Guard: Instagram setzt Videos beim Start/Weiter-
// schalten eigenständig auf muted ODER zieht die Lautstärke
// hoch/runter. Wir fangen jede solche Änderung ab und
// erzwingen den vom Nutzer gewünschten Zustand.
//
// `_rcUserMuted` = will der Nutzer stumm? (true/false)
// `_rcDesiredVolume` = welche Lautstärke will der Nutzer?
// (0..1, der echte Wunschwert)
//
// Solange der Nutzer nichts selbst ändert, ist JEDE
// Abweichung von diesen Werten ein Eingriff von Instagram
// und wird sofort zurückgesetzt.
// ----------------------------------------------------------
let rcGuarding = false; // verhindert Endlosschleife im Handler
function enforceVolume() {
if (rcGuarding) return;
rcGuarding = true;
// Lautstärke immer auf den Wunschwert zwingen ...
if (Math.abs(video.volume - video._rcDesiredVolume) > 0.001) {
video.volume = video._rcDesiredVolume;
}
// ... und Mute-Zustand auf die Nutzer-Absicht.
if (video.muted !== video._rcUserMuted) {
video.muted = video._rcUserMuted;
}
rcGuarding = false;
applyVolumeUI();
}
video.addEventListener("volumechange", enforceVolume);
// Anfangszustand: gespeicherte Werte übernehmen.
video._rcUserMuted = lastMuted;
video._rcDesiredVolume = lastVolume;
rcGuarding = true;
video.volume = lastVolume;
video.muted = lastMuted;
rcGuarding = false;
// Sicherheitsnetz: kurz nach play() greift IG manchmal noch
// einmal nach. Wir prüfen die ersten Sekunden mehrfach nach.
video.addEventListener("play", () => {
enforceVolume();
[60, 200, 500, 1000].forEach((ms) => setTimeout(enforceVolume, ms));
});
video.addEventListener("loadeddata", enforceVolume);
syncPlayIcon();
applyVolumeUI();
updateProgress();
return bar;
}
// ----------------------------------------------------------
// Globale Positionierung
//
// Wichtig (Opera!): Wir hängen die Steuerleiste NICHT in den
// Video-Container, sondern als oberste Ebene an den <body> und
// legen sie per fixed-Positionierung über das jeweilige Video.
//
// Grund: Opera ("Video Popout") legt über JEDES <video> eine
// eigene unsichtbare Overlay-Ebene, um Hover zu erkennen und
// den Popout-Button einzublenden. Diese Ebene schluckt alle
// Maus-Events. Eine Leiste, die im Video-Container liegt, sitzt
// darunter -> weder Hover noch Klicks kommen an. Als body-Kind
// mit maximalem z-index sitzt unsere Leiste darüber.
// ----------------------------------------------------------
const placements = []; // { video, wrapper }
function placeAll() {
const vh = window.innerHeight || document.documentElement.clientHeight;
for (let i = placements.length - 1; i >= 0; i--) {
const { video, wrapper } = placements[i];
if (!document.contains(video)) {
wrapper.remove();
placements.splice(i, 1);
continue;
}
const r = video.getBoundingClientRect();
// Nur über sichtbare, ausreichend große Reels einblenden.
const offscreen =
r.width < 150 || r.height < 200 || r.bottom <= 0 || r.top >= vh;
if (offscreen) {
if (wrapper.style.display !== "none") wrapper.style.display = "none";
continue;
}
wrapper.style.display = "flex";
wrapper.style.left = r.left + "px";
wrapper.style.top = r.top + "px";
wrapper.style.width = r.width + "px";
wrapper.style.height = r.height + "px";
}
}
let rafStarted = false;
function ensureLoop() {
if (rafStarted) return;
rafStarted = true;
const tick = () => {
placeAll();
requestAnimationFrame(tick);
};
requestAnimationFrame(tick);
}
// ----------------------------------------------------------
// Steuerung an ein Video anheften.
// ----------------------------------------------------------
function attach(video) {
if (video.getAttribute(ATTACHED) === "1") return;
if (!isReelVideo(video)) return;
video.setAttribute(ATTACHED, "1");
const wrapper = document.createElement("div");
wrapper.className = "rc-wrapper";
// Oberste Ebene am <body>, per fixed über das Video gelegt.
wrapper.style.position = "fixed";
wrapper.style.left = "0";
wrapper.style.top = "0";
wrapper.style.right = "auto";
wrapper.style.bottom = "auto";
wrapper.style.alignItems = "flex-end"; // Leiste am unteren Rand
wrapper.style.zIndex = "2147483647";
wrapper.style.display = "none";
wrapper.appendChild(buildControls(video));
document.body.appendChild(wrapper);
placements.push({ video, wrapper });
ensureLoop();
// Falls Instagram das Video entfernt, räumen wir mit auf.
const cleanup = new MutationObserver(() => {
if (!document.contains(video)) {
wrapper.remove();
const idx = placements.findIndex((p) => p.video === video);
if (idx >= 0) placements.splice(idx, 1);
cleanup.disconnect();
}
});
cleanup.observe(document.body, { childList: true, subtree: true });
}
function scan() {
document.querySelectorAll("video").forEach(attach);
}
// ----------------------------------------------------------
// Beobachter: IG lädt Reels nach beim Scrollen.
// ----------------------------------------------------------
const observer = new MutationObserver(() => {
// Leichtgewichtig drosseln.
if (observer._raf) return;
observer._raf = requestAnimationFrame(() => {
observer._raf = null;
scan();
});
});
observer.observe(document.documentElement, {
childList: true,
subtree: true,
});
// Erstes Scannen + periodischer Fallback (IG-SPA-Navigation).
scan();
setInterval(scan, 1500);
// ----------------------------------------------------------
// Globale Tastenkürzel — gelten für das aktuell sichtbare Reel.
// ----------------------------------------------------------
function visibleReel() {
let best = null;
let bestArea = 0;
document.querySelectorAll("video[" + ATTACHED + '="1"]').forEach((v) => {
const r = v.getBoundingClientRect();
const vh = window.innerHeight;
const visible =
Math.min(r.bottom, vh) - Math.max(r.top, 0);
if (visible > bestArea) {
bestArea = visible;
best = v;
}
});
return best;
}
document.addEventListener(
"keydown",
(e) => {
// Nicht stören, wenn der Nutzer in ein Eingabefeld tippt.
const tag = (e.target.tagName || "").toLowerCase();
if (tag === "input" || tag === "textarea" || e.target.isContentEditable)
return;
const v = visibleReel();
if (!v) return;
if (e.code === "Space") {
e.preventDefault();
e.stopPropagation();
if (v.paused) v.play();
else v.pause();
} else if (e.key.toLowerCase() === "m") {
v._rcUserMuted = !v._rcUserMuted; // Nutzer-Absicht festhalten
v.muted = v._rcUserMuted;
if (!v._rcUserMuted) {
if (!v._rcDesiredVolume) v._rcDesiredVolume = 0.5;
v.volume = v._rcDesiredVolume;
}
lastVolume = v._rcDesiredVolume;
lastMuted = v._rcUserMuted;
persistVolume();
} else if (e.key === "ArrowLeft") {
v.currentTime = Math.max(0, v.currentTime - 5);
} else if (e.key === "ArrowRight") {
v.currentTime = Math.min(v.duration || 0, v.currentTime + 5);
} else if (e.key === "ArrowUp") {
e.preventDefault();
v._rcUserMuted = false; // Nutzer will Ton
v._rcDesiredVolume = Math.min(1, (v._rcDesiredVolume || 0) + 0.1);
v.muted = false;
v.volume = v._rcDesiredVolume;
lastVolume = v._rcDesiredVolume;
lastMuted = false;
persistVolume();
} else if (e.key === "ArrowDown") {
e.preventDefault();
v._rcDesiredVolume = Math.max(0, (v._rcDesiredVolume || 0) - 0.1);
v.volume = v._rcDesiredVolume;
if (v._rcDesiredVolume === 0) {
v._rcUserMuted = true; // bewusst auf 0 = Nutzer will stumm
v.muted = true;
}
lastVolume = v._rcDesiredVolume || lastVolume;
lastMuted = v._rcUserMuted;
persistVolume();
}
},
true // capture: vor Instagram abgreifen
);
})();