456 lines
16 KiB
JavaScript
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
|
|
);
|
|
})();
|