First Commit, Code by Claude 4.7 Opus
This commit is contained in:
commit
4f1e373c4f
7 changed files with 650 additions and 0 deletions
398
content.js
Normal file
398
content.js
Normal file
|
|
@ -0,0 +1,398 @@
|
|||
// ============================================================
|
||||
// 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;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// Steuerung an ein Video anheften.
|
||||
// ----------------------------------------------------------
|
||||
function attach(video) {
|
||||
if (video.getAttribute(ATTACHED) === "1") return;
|
||||
if (!isReelVideo(video)) return;
|
||||
|
||||
video.setAttribute(ATTACHED, "1");
|
||||
|
||||
// Wir hängen die Leiste an den nächstgelegenen positionierten
|
||||
// Vorfahren, damit sie über dem Video sitzt.
|
||||
let host = video.parentElement;
|
||||
if (!host) return;
|
||||
// Sicherstellen, dass der Host positioniert ist.
|
||||
const pos = getComputedStyle(host).position;
|
||||
if (pos === "static") host.style.position = "relative";
|
||||
|
||||
const wrapper = document.createElement("div");
|
||||
wrapper.className = "rc-wrapper";
|
||||
wrapper.appendChild(buildControls(video));
|
||||
host.appendChild(wrapper);
|
||||
|
||||
// Falls Instagram das Video entfernt, räumen wir mit auf.
|
||||
const cleanup = new MutationObserver(() => {
|
||||
if (!document.contains(video)) {
|
||||
wrapper.remove();
|
||||
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
|
||||
);
|
||||
})();
|
||||
Loading…
Add table
Add a link
Reference in a new issue