diff --git a/app/src/content_scripts/webApps/youtube/global.css b/app/src/content_scripts/webApps/youtube/global.css
index 65dd5f6..62cba13 100644
--- a/app/src/content_scripts/webApps/youtube/global.css
+++ b/app/src/content_scripts/webApps/youtube/global.css
@@ -1 +1,15 @@
@tailwind utilities;
+
+:root {
+ --tw-translate-x: 0;
+ --tw-translate-y: 0;
+ --tw-rotate: 0;
+ --tw-skew-x: 0;
+ --tw-skew-y: 0;
+ --tw-scale-x: 1;
+ --tw-scale-y: 1;
+}
+
+#contentContainer.tp-yt-app-drawer[swipe-open].tp-yt-app-drawer::after {
+ width: 10px !important;
+}
diff --git a/app/src/content_scripts/webApps/youtube/routes/watch/components/LoopSegment.tsx b/app/src/content_scripts/webApps/youtube/routes/watch/components/LoopSegment.tsx
new file mode 100644
index 0000000..e804daa
--- /dev/null
+++ b/app/src/content_scripts/webApps/youtube/routes/watch/components/LoopSegment.tsx
@@ -0,0 +1,164 @@
+import React from "dom-chef";
+import elementReady from "element-ready";
+
+let isMarkerDragging: "start" | "end" | null = null;
+let video: HTMLVideoElement | null = null;
+let timeUpdateListener: ((event: Event) => void) | null = null;
+
+export const LoopSegmentButton = (
+
+);
+
+export const LoopSegmentStartMarker = (
+
handleMarkerDrag(e, "start")}
+ />
+);
+
+export const LoopSegmentEndMarker = (
+
handleMarkerDrag(e, "end")}
+ />
+);
+
+export async function initLoopSegment() {
+ video = (await elementReady("video")) as HTMLVideoElement | null;
+ disableLoopSegment();
+ LoopSegmentButton.setAttribute("aria-pressed", "false");
+ LoopSegmentStartMarker.classList.add("hidden");
+ LoopSegmentEndMarker.classList.add("hidden");
+}
+
+export function toggleLoopSegment(_event?: React.MouseEvent) {
+ const isPressed = LoopSegmentButton.getAttribute("aria-pressed") ?? "false";
+ if (isPressed === "true") {
+ disableLoopSegment();
+ LoopSegmentButton.setAttribute("aria-pressed", "false");
+ } else {
+ enableLoopSegment();
+ LoopSegmentButton.setAttribute("aria-pressed", "true");
+ }
+ LoopSegmentStartMarker.classList.toggle("hidden");
+ LoopSegmentEndMarker.classList.toggle("hidden");
+}
+
+async function enableLoopSegment() {
+ if (!video) return;
+
+ timeUpdateListener = (_event: Event) => {
+ if (!video) return;
+ const currentTime = video.currentTime;
+ const loopStartTime = getTimeFromMarker("#start-marker") || 0;
+ let loopEndTime = getTimeFromMarker("#end-marker") || video.duration;
+
+ if (video.duration - loopEndTime <= 0.3) {
+ loopEndTime = video.duration - 0.3;
+ }
+
+ const isLoopSegmentEnabled =
+ LoopSegmentButton.getAttribute("aria-pressed") ?? "false";
+ if (currentTime > loopEndTime && isLoopSegmentEnabled === "true") {
+ video.currentTime = loopStartTime;
+ }
+ };
+
+ video.addEventListener("timeupdate", timeUpdateListener);
+ LoopSegmentStartMarker.style.left = "0%";
+ LoopSegmentEndMarker.style.left = "100%";
+}
+
+function disableLoopSegment() {
+ if (video && timeUpdateListener) {
+ video.removeEventListener("timeupdate", timeUpdateListener);
+ timeUpdateListener = null;
+ }
+}
+
+function getTimeFromMarker(markerSelector: "#start-marker" | "#end-marker") {
+ if (!video) return;
+
+ const duration = video.duration;
+ const marker = document.querySelector(markerSelector);
+ if (!marker) return;
+ const markerPositionPercentage = parseFloat(
+ (marker as HTMLDivElement).style.left,
+ );
+
+ return (markerPositionPercentage / 100) * duration;
+}
+
+function handleMarkerDrag(
+ e: React.MouseEvent,
+ markerType: "start" | "end" | null,
+) {
+ e.preventDefault();
+ isMarkerDragging = markerType;
+
+ document.addEventListener("mousemove", handleMouseMove);
+ document.addEventListener("mouseup", handleMouseUp);
+}
+
+function handleMouseMove(e: MouseEvent) {
+ if (!isMarkerDragging) return;
+
+ const progressBarContainer = document.querySelector(
+ ".ytp-progress-bar-container",
+ );
+ if (!progressBarContainer) return;
+ const progressBarRect = progressBarContainer.getBoundingClientRect();
+ const progressBarWidth = progressBarRect.width;
+
+ const xPosition = e.clientX - progressBarRect.left;
+ const newPosition = Math.max(0, Math.min(xPosition, progressBarWidth));
+ let newPercentage = (newPosition / progressBarWidth) * 100;
+
+ const startPercentage = parseFloat(LoopSegmentStartMarker.style.left);
+ const endPercentage = parseFloat(LoopSegmentEndMarker.style.left);
+
+ // Prevent markers from crossing each other
+ if (isMarkerDragging === "start" && newPercentage >= endPercentage) {
+ newPercentage = endPercentage - 0.1;
+ } else if (isMarkerDragging === "end" && newPercentage <= startPercentage) {
+ newPercentage = startPercentage + 0.1;
+ }
+
+ const marker = document.getElementById(`${isMarkerDragging}-marker`);
+ if (marker) {
+ marker.style.left = `${newPercentage}%`;
+ }
+ if (video && isMarkerDragging === "start") {
+ const loopStartTime = (newPercentage / 100) * video.duration;
+ video.currentTime = loopStartTime;
+ }
+}
+
+function handleMouseUp() {
+ if (isMarkerDragging) {
+ isMarkerDragging = null;
+ document.removeEventListener("mousemove", handleMouseMove);
+ document.removeEventListener("mouseup", handleMouseUp);
+ }
+}
diff --git a/app/src/content_scripts/webApps/youtube/routes/watch/index.ts b/app/src/content_scripts/webApps/youtube/routes/watch/index.ts
index 116a559..0811738 100644
--- a/app/src/content_scripts/webApps/youtube/routes/watch/index.ts
+++ b/app/src/content_scripts/webApps/youtube/routes/watch/index.ts
@@ -3,12 +3,21 @@ import YTPlayer from "../../common/VideoPlayer";
import { type YouTubeWatchContext } from "../../../../../background/parsers//parseYouTubeContext";
import { YouTubeConfig } from "../../webApp.config";
import loadElement from "../../../../../lib/loadElement";
+import elementReady from "element-ready";
+import {
+ LoopSegmentButton,
+ LoopSegmentStartMarker,
+ LoopSegmentEndMarker,
+ toggleLoopSegment,
+ initLoopSegment,
+} from "./components/LoopSegment";
let toggleSpeedShortcut: string;
let seekBackwardShortcut: string;
let seekForwardShortcut: string;
let increaseSpeedShortcut: string;
let decreaseSpeedShortcut: string;
+let toggleLoopSegmentShortcut: string;
let customPrecisionSpeedList: string[];
let toggleSpeed: string;
let defaultSpeed: string;
@@ -36,6 +45,7 @@ const runWatch = async (context: YouTubeWatchContext): Promise => {
seekForwardShortcut = keybindings.seekForwardShortcut;
increaseSpeedShortcut = keybindings.increaseSpeedShortcut;
decreaseSpeedShortcut = keybindings.decreaseSpeedShortcut;
+ toggleLoopSegmentShortcut = keybindings.toggleLoopSegmentShortcut;
customPrecisionSpeedList = preferences.customSpeedList;
toggleSpeed = preferences.toggleSpeed;
defaultSpeed = preferences.defaultSpeed;
@@ -52,6 +62,15 @@ const runWatch = async (context: YouTubeWatchContext): Promise => {
playerSettingsButton = await loadSettingsBtn();
document.addEventListener("keydown", useShortcuts);
if (playerSettingsButton !== null) {
+ const rightControls = await elementReady("div.ytp-right-controls");
+ if (rightControls) {
+ rightControls.prepend(LoopSegmentButton);
+ }
+ const progressBar = await elementReady("div.ytp-progress-bar-container");
+ if (progressBar) {
+ await initLoopSegment();
+ progressBar.append(LoopSegmentStartMarker, LoopSegmentEndMarker);
+ }
playerSettingsButton.removeEventListener("click", onSettingsMenu);
playerSettingsButton.addEventListener("click", onSettingsMenu, {
once: true,
@@ -352,6 +371,8 @@ const useShortcuts = (event: KeyboardEvent): void => {
return;
}
changePlaybackSpeed(decreasedSpeed);
+ } else if (event.key === `${toggleLoopSegmentShortcut.toLowerCase()}`) {
+ toggleLoopSegment();
}
}
};
diff --git a/app/src/content_scripts/webApps/youtube/webApp.config.ts b/app/src/content_scripts/webApps/youtube/webApp.config.ts
index 3ffb08b..cb9dcae 100644
--- a/app/src/content_scripts/webApps/youtube/webApp.config.ts
+++ b/app/src/content_scripts/webApps/youtube/webApp.config.ts
@@ -13,6 +13,7 @@ const youtubeConfig = {
seekForwardShortcut: "D" as string,
increaseSpeedShortcut: "W" as string,
decreaseSpeedShortcut: "S" as string,
+ toggleLoopSegmentShortcut: "Z" as string,
},
// TODO: The Options page should save the values with toFixed(2) as part of saving
// validation so that content script doesn't have to bother about it.