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.