/* Parts of this are inspired from code from VideoSegments, but rewritten and under the LGPLv3 license https://github.com/videosegments/videosegments/commits/f1e111bdfe231947800c6efdd51f62a4e7fef4d4/segmentsbar/segmentsbar.js */ 'use strict'; import Config from "../config"; import { ChapterVote } from "../render/ChapterVote"; import { ActionType, Category, SegmentContainer, SponsorHideType, SponsorSourceType, SponsorTime } from "../types"; import { partition } from "../utils/arrayUtils"; import { DEFAULT_CATEGORY, shortCategoryName } from "../utils/categoryUtils"; import { normalizeChapterName } from "../utils/exporter"; import { getFormattedTimeToSeconds } from "@ajayyy/maze-utils/lib/formating"; import { findValidElement } from "@ajayyy/maze-utils/lib/dom"; const TOOLTIP_VISIBLE_CLASS = 'sponsorCategoryTooltipVisible'; const MIN_CHAPTER_SIZE = 0.003; export interface PreviewBarSegment { segment: [number, number]; category: Category; actionType: ActionType; unsubmitted: boolean; showLarger: boolean; description: string; source: SponsorSourceType; requiredSegment?: boolean; } interface ChapterGroup extends SegmentContainer { originalDuration: number; actionType: ActionType; } class PreviewBar { container: HTMLUListElement; categoryTooltip?: HTMLDivElement; categoryTooltipContainer?: HTMLElement; chapterTooltip?: HTMLDivElement; lastSmallestSegment: Record = {}; parent: HTMLElement; onMobileYouTube: boolean; onInvidious: boolean; progressBar: HTMLElement; segments: PreviewBarSegment[] = []; existingChapters: PreviewBarSegment[] = []; videoDuration = 0; updateExistingChapters: () => void; lastChapterUpdate = 0; // For chapter bar hoveredSection: HTMLElement; customChaptersBar: HTMLElement; chaptersBarSegments: PreviewBarSegment[]; chapterVote: ChapterVote; originalChapterBar: HTMLElement; originalChapterBarBlocks: NodeListOf; chapterMargin: number; lastRenderedSegments: PreviewBarSegment[]; unfilteredChapterGroups: ChapterGroup[]; chapterGroups: ChapterGroup[]; constructor(parent: HTMLElement, onMobileYouTube: boolean, onInvidious: boolean, chapterVote: ChapterVote, updateExistingChapters: () => void, test=false) { if (test) return; this.container = document.createElement('ul'); this.container.id = 'previewbar'; this.parent = parent; this.onMobileYouTube = onMobileYouTube; this.onInvidious = onInvidious; this.chapterVote = chapterVote; this.updateExistingChapters = updateExistingChapters; this.updatePageElements(); this.createElement(parent); this.createChapterMutationObservers(); this.setupHoverText(); } setupHoverText(): void { if (this.onMobileYouTube || this.onInvidious) return; // delete old ones document.querySelectorAll(`.sponsorCategoryTooltip`) .forEach((e) => e.remove()); // Create label placeholder this.categoryTooltip = document.createElement("div"); this.categoryTooltip.className = "ytp-tooltip-title sponsorCategoryTooltip"; this.chapterTooltip = document.createElement("div"); this.chapterTooltip.className = "ytp-tooltip-title sponsorCategoryTooltip"; const tooltipTextWrapper = document.querySelector(".ytp-tooltip-text-wrapper"); const originalTooltip = tooltipTextWrapper.querySelector(".ytp-tooltip-title:not(.sponsorCategoryTooltip)") as HTMLElement; if (!tooltipTextWrapper || !tooltipTextWrapper.parentElement) return; // Grab the tooltip from the text wrapper as the tooltip doesn't have its classes on init this.categoryTooltipContainer = tooltipTextWrapper.parentElement; const titleTooltip = tooltipTextWrapper.querySelector(".ytp-tooltip-title") as HTMLElement; if (!this.categoryTooltipContainer || !titleTooltip) return; tooltipTextWrapper.insertBefore(this.categoryTooltip, titleTooltip.nextSibling); tooltipTextWrapper.insertBefore(this.chapterTooltip, titleTooltip.nextSibling); const seekBar = document.querySelector(".ytp-progress-bar-container"); if (!seekBar) return; let mouseOnSeekBar = false; seekBar.addEventListener("mouseenter", () => { mouseOnSeekBar = true; }); seekBar.addEventListener("mouseleave", () => { mouseOnSeekBar = false; }); const observer = new MutationObserver((mutations) => { if (!mouseOnSeekBar || !this.categoryTooltip || !this.categoryTooltipContainer) return; // Only care about mutations to time tooltip if (!mutations.some((mutation) => (mutation.target as HTMLElement).classList.contains("ytp-tooltip-text"))) { return; } const tooltipTextElements = tooltipTextWrapper.querySelectorAll(".ytp-tooltip-text"); let timeInSeconds: number | null = null; let noYoutubeChapters = false; for (const tooltipTextElement of tooltipTextElements) { if (tooltipTextElement.classList.contains('ytp-tooltip-text-no-title')) noYoutubeChapters = true; const tooltipText = tooltipTextElement.textContent; if (tooltipText === null || tooltipText.length === 0) continue; timeInSeconds = getFormattedTimeToSeconds(tooltipText); if (timeInSeconds !== null) break; } if (timeInSeconds === null) { originalTooltip.style.removeProperty("display"); return; } // Find the segment at that location, using the shortest if multiple found const [normalSegments, chapterSegments] = partition(this.segments.filter((s) => s.source !== SponsorSourceType.YouTube), (segment) => segment.actionType !== ActionType.Chapter); let mainSegment = this.getSmallestSegment(timeInSeconds, normalSegments, "normal"); let secondarySegment = this.getSmallestSegment(timeInSeconds, chapterSegments, "chapter"); if (mainSegment === null && secondarySegment !== null) { mainSegment = secondarySegment; secondarySegment = this.getSmallestSegment(timeInSeconds, chapterSegments.filter((s) => s !== secondarySegment)); } if (mainSegment === null && secondarySegment === null) { this.categoryTooltipContainer.classList.remove(TOOLTIP_VISIBLE_CLASS); originalTooltip.style.removeProperty("display"); } else { this.categoryTooltipContainer.classList.add(TOOLTIP_VISIBLE_CLASS); if (mainSegment !== null && secondarySegment !== null) { this.categoryTooltipContainer.classList.add("sponsorTwoTooltips"); } else { this.categoryTooltipContainer.classList.remove("sponsorTwoTooltips"); } this.setTooltipTitle(mainSegment, this.categoryTooltip); this.setTooltipTitle(secondarySegment, this.chapterTooltip); if (normalizeChapterName(originalTooltip.textContent) === normalizeChapterName(this.categoryTooltip.textContent) || normalizeChapterName(originalTooltip.textContent) === normalizeChapterName(this.chapterTooltip.textContent)) { if (originalTooltip.style.display !== "none") originalTooltip.style.display = "none"; noYoutubeChapters = true; } else if (originalTooltip.style.display === "none") { originalTooltip.style.removeProperty("display"); } // Used to prevent overlapping this.categoryTooltip.classList.toggle("ytp-tooltip-text-no-title", noYoutubeChapters); this.chapterTooltip.classList.toggle("ytp-tooltip-text-no-title", noYoutubeChapters); // To prevent offset issue this.categoryTooltip.style.right = titleTooltip.style.right; this.chapterTooltip.style.right = titleTooltip.style.right; this.categoryTooltip.style.textAlign = titleTooltip.style.textAlign; this.chapterTooltip.style.textAlign = titleTooltip.style.textAlign; } }); observer.observe(tooltipTextWrapper, { childList: true, subtree: true, }); } private setTooltipTitle(segment: PreviewBarSegment, tooltip: HTMLElement): void { if (segment) { const name = segment.description || shortCategoryName(segment.category); if (segment.unsubmitted) { tooltip.textContent = chrome.i18n.getMessage("unsubmitted") + " " + name; } else { tooltip.textContent = name; } tooltip.style.removeProperty("display"); } else { tooltip.style.display = "none"; } } createElement(parent: HTMLElement): void { this.parent = parent; if (this.onMobileYouTube) { if (parent.classList.contains("progress-bar-background")) { parent.style.backgroundColor = "rgba(255, 255, 255, 0.3)"; parent.style.opacity = "1"; } this.container.style.transform = "none"; } else if (!this.onInvidious) { this.container.classList.add("sbNotInvidious"); } // On the seek bar this.parent.prepend(this.container); } clear(): void { while (this.container.firstChild) { this.container.removeChild(this.container.firstChild); } if (this.customChaptersBar) this.customChaptersBar.style.display = "none"; this.originalChapterBar?.style?.removeProperty("display"); this.chapterVote?.setVisibility(false); document.querySelectorAll(`.sponsorBlockChapterBar`).forEach((e) => { if (e !== this.customChaptersBar) { e.remove(); } }); } set(segments: PreviewBarSegment[], videoDuration: number): void { this.segments = segments ?? []; this.videoDuration = videoDuration ?? 0; this.updatePageElements(); // Sometimes video duration is inaccurate, pull from accessibility info const ariaDuration = parseInt(this.progressBar?.getAttribute('aria-valuemax')) ?? 0; if (ariaDuration && Math.abs(ariaDuration - this.videoDuration) > 3) { this.videoDuration = ariaDuration; } this.update(); } private updatePageElements(): void { const allProgressBars = document.querySelectorAll('.ytp-progress-bar') as NodeListOf; this.progressBar = findValidElement(allProgressBars) ?? allProgressBars?.[0]; if (this.progressBar) { const newChapterBar = this.progressBar.querySelector(".ytp-chapters-container:not(.sponsorBlockChapterBar)") as HTMLElement; if (this.originalChapterBar !== newChapterBar) { // Make sure changes are undone on old bar this.originalChapterBar?.style?.removeProperty("display"); this.originalChapterBar = newChapterBar; } } } private update(): void { this.clear(); const chapterChevron = this.getChapterChevron(); if (!this.segments) { chapterChevron?.style?.removeProperty("display"); } this.chapterMargin = 2; if (this.originalChapterBar) { this.originalChapterBarBlocks = this.originalChapterBar.querySelectorAll(":scope > div") as NodeListOf this.existingChapters = this.segments.filter((s) => s.source === SponsorSourceType.YouTube).sort((a, b) => a.segment[0] - b.segment[0]); if (this.existingChapters?.length > 0) { const margin = parseFloat(this.originalChapterBarBlocks?.[0]?.style?.marginRight?.replace("px", "")); if (margin) this.chapterMargin = margin; } } const sortedSegments = this.segments.sort(({ segment: a }, { segment: b }) => { // Sort longer segments before short segments to make shorter segments render later return (b[1] - b[0]) - (a[1] - a[0]); }); for (const segment of sortedSegments) { const bar = this.createBar(segment); this.container.appendChild(bar); } this.createChaptersBar(this.segments.sort((a, b) => a.segment[0] - b.segment[0])); if (chapterChevron) { if (this.segments.some((segment) => segment.source === SponsorSourceType.YouTube)) { chapterChevron.style.removeProperty("display"); } else if (this.segments) { chapterChevron.style.display = "none"; } } } createBar(barSegment: PreviewBarSegment): HTMLLIElement { const { category, unsubmitted, segment, showLarger } = barSegment; const bar = document.createElement('li'); bar.classList.add('previewbar'); if (barSegment.requiredSegment) bar.classList.add("requiredSegment"); bar.innerHTML = showLarger ? '  ' : ' '; const fullCategoryName = (unsubmitted ? 'preview-' : '') + category; bar.setAttribute('sponsorblock-category', fullCategoryName); // Handled by setCategoryColorCSSVariables() of content.ts bar.style.backgroundColor = `var(--sb-category-${fullCategoryName})`; if (!this.onMobileYouTube) bar.style.opacity = Config.config.barTypes[fullCategoryName]?.opacity; bar.style.position = "absolute"; const duration = Math.min(segment[1], this.videoDuration) - segment[0]; if (duration > 0) { bar.style.width = `calc(${this.intervalToPercentage(segment[0], segment[1])}${ this.chapterFilter(barSegment) && segment[1] < this.videoDuration ? ` - ${this.chapterMargin}px` : ''})`; } const time = segment[1] ? Math.min(this.videoDuration, segment[0]) : segment[0]; bar.style.left = this.timeToPercentage(time); return bar; } createChaptersBar(segments: PreviewBarSegment[]): void { if (!this.progressBar || !this.originalChapterBar || this.originalChapterBar.childElementCount <= 0) { if (this.originalChapterBar) this.originalChapterBar.style.removeProperty("display"); // Make sure other video types lose their chapter bar document.querySelectorAll(".sponsorBlockChapterBar").forEach((element) => element.remove()); this.customChaptersBar = null; return; } const remakingBar = segments !== this.lastRenderedSegments; if (remakingBar) { this.lastRenderedSegments = segments; // Merge overlapping chapters this.unfilteredChapterGroups = this.createChapterRenderGroups(segments); } if (segments.every((segments) => segments.source === SponsorSourceType.YouTube) || (!Config.config.renderSegmentsAsChapters && segments.every((segment) => segment.actionType !== ActionType.Chapter || segment.source === SponsorSourceType.YouTube))) { if (this.customChaptersBar) this.customChaptersBar.style.display = "none"; this.originalChapterBar.style.removeProperty("display"); return; } const filteredSegments = segments?.filter((segment) => this.chapterFilter(segment)); if (filteredSegments) { let groups = this.unfilteredChapterGroups; if (filteredSegments.length !== segments.length) { groups = this.createChapterRenderGroups(filteredSegments); } this.chapterGroups = groups.filter((segment) => this.chapterGroupFilter(segment)); if (groups.length !== this.chapterGroups.length) { // Fix missing sections due to filtered segments for (let i = 1; i < this.chapterGroups.length; i++) { if (this.chapterGroups[i].segment[0] !== this.chapterGroups[i - 1].segment[1]) { this.chapterGroups[i - 1].segment[1] = this.chapterGroups[i].segment[0] } } } } else { this.chapterGroups = this.unfilteredChapterGroups; } if (!this.chapterGroups || this.chapterGroups.length <= 0) { if (this.customChaptersBar) this.customChaptersBar.style.display = "none"; this.originalChapterBar.style.removeProperty("display"); return; } // Create it from cloning let createFromScratch = false; if (!this.customChaptersBar || !this.progressBar.contains(this.customChaptersBar)) { // Clear anything remaining document.querySelectorAll(".sponsorBlockChapterBar").forEach((element) => element.remove()); createFromScratch = true; this.customChaptersBar = this.originalChapterBar.cloneNode(true) as HTMLElement; this.customChaptersBar.classList.add("sponsorBlockChapterBar"); } this.customChaptersBar.style.display = "none"; const originalSections = this.customChaptersBar.querySelectorAll(".ytp-chapter-hover-container"); const originalSection = originalSections[0]; // For switching to a video with less chapters if (originalSections.length > this.chapterGroups.length) { for (let i = originalSections.length - 1; i >= this.chapterGroups.length; i--) { this.customChaptersBar.removeChild(originalSections[i]); } } // Modify it to have sections for each segment for (let i = 0; i < this.chapterGroups.length; i++) { const chapter = this.chapterGroups[i].segment; let newSection = originalSections[i] as HTMLElement; if (!newSection) { newSection = originalSection.cloneNode(true) as HTMLElement; this.firstTimeSetupChapterSection(newSection); this.customChaptersBar.appendChild(newSection); } else if (createFromScratch) { this.firstTimeSetupChapterSection(newSection); } this.setupChapterSection(newSection, chapter[0], chapter[1], i !== this.chapterGroups.length - 1); } // Hide old bar this.originalChapterBar.style.display = "none"; this.customChaptersBar.style.removeProperty("display"); if (createFromScratch) { if (this.container?.parentElement === this.progressBar) { this.progressBar.insertBefore(this.customChaptersBar, this.container.nextSibling); } else { this.progressBar.prepend(this.customChaptersBar); } } if (remakingBar) { this.updateChapterAllMutation(this.originalChapterBar, this.progressBar, true); } } createChapterRenderGroups(segments: PreviewBarSegment[]): ChapterGroup[] { const result: ChapterGroup[] = []; segments?.forEach((segment, index) => { const latestChapter = result[result.length - 1]; if (latestChapter && latestChapter.segment[1] > segment.segment[0]) { const segmentDuration = segment.segment[1] - segment.segment[0]; if (segment.segment[0] < latestChapter.segment[0] || segmentDuration < latestChapter.originalDuration) { // Remove latest if it starts too late let latestValidChapter = latestChapter; const chaptersToAddBack: ChapterGroup[] = [] while (latestValidChapter?.segment[0] >= segment.segment[0]) { const invalidChapter = result.pop(); if (invalidChapter.segment[1] > segment.segment[1]) { if (invalidChapter.segment[0] === segment.segment[0]) { invalidChapter.segment[0] = segment.segment[1]; } chaptersToAddBack.push(invalidChapter); } latestValidChapter = result[result.length - 1]; } const priorityActionType = this.getActionTypePrioritized([segment.actionType, latestChapter?.actionType]); // Split the latest chapter if smaller result.push({ segment: [segment.segment[0], segment.segment[1]], originalDuration: segmentDuration, actionType: priorityActionType }); if (latestValidChapter?.segment[1] > segment.segment[1]) { result.push({ segment: [segment.segment[1], latestValidChapter.segment[1]], originalDuration: latestValidChapter.originalDuration, actionType: latestValidChapter.actionType }); } chaptersToAddBack.reverse(); let lastChapterChecked: number[] = segment.segment; for (const chapter of chaptersToAddBack) { if (chapter.segment[0] < lastChapterChecked[1]) { chapter.segment[0] = lastChapterChecked[1]; } lastChapterChecked = chapter.segment; } result.push(...chaptersToAddBack); if (latestValidChapter) latestValidChapter.segment[1] = segment.segment[0]; } else { // Start at end of old one otherwise result.push({ segment: [latestChapter.segment[1], segment.segment[1]], originalDuration: segmentDuration, actionType: segment.actionType }); } } else { // Add empty buffer before segment if needed const lastTime = latestChapter?.segment[1] || 0; if (segment.segment[0] > lastTime) { result.push({ segment: [lastTime, segment.segment[0]], originalDuration: 0, actionType: null }); } // Normal case const endTime = Math.min(segment.segment[1], this.videoDuration); result.push({ segment: [segment.segment[0], endTime], originalDuration: endTime - segment.segment[0], actionType: segment.actionType }); } // Add empty buffer after segment if needed if (index === segments.length - 1) { const nextSegment = segments[index + 1]; const nextTime = nextSegment ? nextSegment.segment[0] : this.videoDuration; const lastTime = result[result.length - 1]?.segment[1] || segment.segment[1]; if (this.intervalToDecimal(lastTime, nextTime) > MIN_CHAPTER_SIZE) { result.push({ segment: [lastTime, nextTime], originalDuration: 0, actionType: null }); } } }); return result; } private getActionTypePrioritized(actionTypes: ActionType[]): ActionType { if (actionTypes.includes(ActionType.Skip)) { return ActionType.Skip; } else if (actionTypes.includes(ActionType.Mute)) { return ActionType.Mute; } else { return actionTypes.find(a => a) ?? actionTypes[0]; } } private setupChapterSection(section: HTMLElement, startTime: number, endTime: number, addMargin: boolean): void { const sizePercent = this.intervalToPercentage(startTime, endTime); if (addMargin) { section.style.marginRight = `${this.chapterMargin}px`; section.style.width = `calc(${sizePercent} - ${this.chapterMargin}px)`; } else { section.style.marginRight = "0"; section.style.width = sizePercent; } section.setAttribute("decimal-width", String(this.intervalToDecimal(startTime, endTime))); } private firstTimeSetupChapterSection(section: HTMLElement): void { section.addEventListener("mouseenter", () => { this.hoveredSection?.classList.remove("ytp-exp-chapter-hover-effect"); section.classList.add("ytp-exp-chapter-hover-effect"); this.hoveredSection = section; }); } private createChapterMutationObservers(): void { if (!this.progressBar || !this.originalChapterBar) return; const attributeObserver = new MutationObserver((mutations) => { const changes: Record = {}; for (const mutation of mutations) { const currentElement = mutation.target as HTMLElement; if (mutation.type === "attributes" && currentElement.parentElement?.classList.contains("ytp-progress-list")) { changes[currentElement.classList[0]] = mutation.target as HTMLElement; } } this.updateChapterMutation(changes, this.progressBar); }); attributeObserver.observe(this.originalChapterBar, { subtree: true, attributes: true, attributeFilter: ["style", "class"] }); const childListObserver = new MutationObserver((mutations) => { const changes: Record = {}; for (const mutation of mutations) { if (mutation.type === "childList") { this.update(); break; } } this.updateChapterMutation(changes, this.progressBar); }); // Only direct children, no subtree childListObserver.observe(this.originalChapterBar, { childList: true }); } private updateChapterAllMutation(originalChapterBar: HTMLElement, progressBar: HTMLElement, firstUpdate = false): void { const elements = originalChapterBar.querySelectorAll(".ytp-progress-list > *"); const changes: Record = {}; for (const element of elements) { changes[element.classList[0]] = element as HTMLElement; } this.updateChapterMutation(changes, progressBar, firstUpdate); } private updateChapterMutation(changes: Record, progressBar: HTMLElement, firstUpdate = false): void { // Go through each newly generated chapter bar and update the width based on changes array if (this.customChaptersBar) { // Width reached so far in decimal percent let cursor = 0; const sections = this.customChaptersBar.querySelectorAll(".ytp-chapter-hover-container") as NodeListOf; for (let i = 0; i < sections.length; i++) { const section = sections[i]; const sectionWidthDecimal = parseFloat(section.getAttribute("decimal-width")); const sectionWidthDecimalNoMargin = sectionWidthDecimal - this.chapterMargin / progressBar.clientWidth; for (const className in changes) { const selector = `.${className}` const customChangedElement = section.querySelector(selector) as HTMLElement; if (customChangedElement) { const fullSectionWidth = i === sections.length - 1 ? sectionWidthDecimal : sectionWidthDecimalNoMargin; const changedElement = changes[className]; const changedData = this.findLeftAndScale(selector, changedElement, progressBar); const left = (changedData.left) / progressBar.clientWidth; const calculatedLeft = Math.max(0, Math.min(1, (left - cursor) / fullSectionWidth)); if (!isNaN(left) && !isNaN(calculatedLeft)) { customChangedElement.style.left = `${calculatedLeft * 100}%`; customChangedElement.style.removeProperty("display"); } if (changedData.scale !== null) { const transformScale = (changedData.scale) / progressBar.clientWidth; customChangedElement.style.transform = `scaleX(${Math.max(0, Math.min(1 - calculatedLeft, (transformScale - cursor) / fullSectionWidth - calculatedLeft))}`; if (firstUpdate) { customChangedElement.style.transition = "none"; setTimeout(() => customChangedElement.style.removeProperty("transition"), 50); } } if (customChangedElement.className !== changedElement.className) { customChangedElement.className = changedElement.className; } } } cursor += sectionWidthDecimal; } if (sections.length !== 0 && sections.length !== this.existingChapters?.length && Date.now() - this.lastChapterUpdate > 3000) { this.lastChapterUpdate = Date.now(); this.updateExistingChapters(); } } } private findLeftAndScale(selector: string, currentElement: HTMLElement, progressBar: HTMLElement): { left: number; scale: number } { const sections = currentElement.parentElement.parentElement.parentElement.children; let currentWidth = 0; let lastWidth = 0; let left = 0; let leftPosition = 0; let scale = null; let scalePosition = 0; let scaleWidth = 0; let lastScalePosition = 0; for (let i = 0; i < sections.length; i++) { const section = sections[i] as HTMLElement; const checkElement = section.querySelector(selector) as HTMLElement; const currentSectionWidthNoMargin = this.getPartialChapterSectionStyle(section, "width") ?? progressBar.clientWidth; const currentSectionWidth = currentSectionWidthNoMargin + this.getPartialChapterSectionStyle(section, "marginRight"); // First check for left const checkLeft = parseFloat(checkElement.style.left.replace("px", "")); if (checkLeft !== 0) { left = checkLeft; leftPosition = currentWidth; } // Then check for scale const transformMatch = checkElement.style.transform.match(/scaleX\(([0-9.]+?)\)/); if (transformMatch) { const transformScale = parseFloat(transformMatch[1]); const endPosition = transformScale + checkLeft / currentSectionWidthNoMargin; if (lastScalePosition > 0.99999 && endPosition === 0) { // Last one was an end section that was fully filled scalePosition = currentWidth - lastWidth; break; } lastScalePosition = endPosition; scale = transformScale; scaleWidth = currentSectionWidthNoMargin; if ((i === sections.length - 1 || endPosition < 0.99999) && endPosition > 0) { // reached the end of this section for sure // if the scale is always zero, then it will go through all sections but still return 0 scalePosition = currentWidth; if (checkLeft !== 0) { scalePosition += left; } break; } } lastWidth = currentSectionWidth; currentWidth += lastWidth; } return { left: left + leftPosition, scale: scale !== null ? scale * scaleWidth + scalePosition : null }; } private getPartialChapterSectionStyle(element: HTMLElement, param: string): number { const data = element.style[param]; if (data?.includes("%")) { return this.customChaptersBar.clientWidth * (parseFloat(data.replace("%", "")) / 100); } else { return parseInt(element.style[param].match(/\d+/g)?.[0]) || 0; } } updateChapterText(segments: SponsorTime[], submittingSegments: SponsorTime[], currentTime: number): SponsorTime[] { if (!Config.config.showSegmentNameInChapterBar || ((!segments || segments.length <= 0) && submittingSegments?.length <= 0)) { const chaptersContainer = this.getChaptersContainer(); const chapterButton = this.getChapterButton(chaptersContainer); if (chapterButton && chapterButton.classList.contains("ytp-chapter-container-disabled")) { chaptersContainer.style.display = "none"; } return []; } segments ??= []; if (submittingSegments?.length > 0) segments = segments.concat(submittingSegments); const activeSegments = segments.filter((segment) => { return segment.hidden === SponsorHideType.Visible && segment.segment[0] <= currentTime && segment.segment[1] > currentTime && segment.category !== DEFAULT_CATEGORY; }); this.setActiveSegments(activeSegments); return activeSegments; } /** * Adds the text to the chapters slot if not filled by default */ private setActiveSegments(segments: SponsorTime[]): void { const chaptersContainer = this.getChaptersContainer(); if (chaptersContainer) { if (segments.length > 0) { chaptersContainer.style.removeProperty("display"); const chosenSegment = segments.sort((a, b) => { if (a.actionType === ActionType.Chapter && b.actionType !== ActionType.Chapter) { return -1; } else if (a.actionType !== ActionType.Chapter && b.actionType === ActionType.Chapter) { return 1; } else { return (b.segment[0] - a.segment[0]); } })[0]; const chapterButton = this.getChapterButton(chaptersContainer); chapterButton.classList.remove("ytp-chapter-container-disabled"); chapterButton.disabled = false; const chapterTitle = chaptersContainer.querySelector(".ytp-chapter-title-content") as HTMLDivElement; chapterTitle.innerText = ""; const chapterCustomText = (chapterTitle.querySelector(".sponsorChapterText") || (() => { const elem = document.createElement("div"); chapterTitle.appendChild(elem); elem.classList.add("sponsorChapterText"); return elem; })()) as HTMLDivElement; chapterCustomText.innerText = chosenSegment.description || shortCategoryName(chosenSegment.category); if (chosenSegment.actionType !== ActionType.Chapter) { chapterTitle.classList.add("sponsorBlock-segment-title"); } else { chapterTitle.classList.remove("sponsorBlock-segment-title"); } if (chosenSegment.source === SponsorSourceType.Server) { const chapterVoteContainer = this.chapterVote.getContainer(); if (!chapterButton.contains(chapterVoteContainer)) { const oldVoteContainers = document.querySelectorAll("#chapterVote"); if (oldVoteContainers.length > 0) { oldVoteContainers.forEach((oldVoteContainer) => oldVoteContainer.remove()); } chapterButton.insertBefore(chapterVoteContainer, this.getChapterChevron()); } this.chapterVote.setVisibility(true); this.chapterVote.setSegment(chosenSegment); } else { this.chapterVote.setVisibility(false); } } else { chaptersContainer.querySelector(".sponsorChapterText")?.remove(); const chapterTitle = chaptersContainer.querySelector(".ytp-chapter-title-content") as HTMLDivElement; if (chapterTitle.innerText === "") { chaptersContainer.style.display = "none"; } else { chaptersContainer.style.removeProperty("display"); } this.chapterVote.setVisibility(false); } } } private getChaptersContainer(): HTMLElement { return document.querySelector(".ytp-chapter-container") as HTMLElement; } private getChapterButton(chaptersContainer: HTMLElement): HTMLButtonElement { return (chaptersContainer ?? this.getChaptersContainer()) ?.querySelector("button.ytp-chapter-title") as HTMLButtonElement; } remove(): void { this.container.remove(); if (this.categoryTooltip) { this.categoryTooltip.remove(); this.categoryTooltip = undefined; } if (this.categoryTooltipContainer) { this.categoryTooltipContainer.classList.remove(TOOLTIP_VISIBLE_CLASS); this.categoryTooltipContainer = undefined; } } private chapterFilter(segment: PreviewBarSegment): boolean { return (Config.config.renderSegmentsAsChapters || segment.actionType === ActionType.Chapter) && segment.actionType !== ActionType.Poi && this.chapterGroupFilter(segment); } private chapterGroupFilter(segment: SegmentContainer): boolean { return segment.segment.length === 2 && this.intervalToDecimal(segment.segment[0], segment.segment[1]) > MIN_CHAPTER_SIZE; } intervalToPercentage(startTime: number, endTime: number) { return `${this.intervalToDecimal(startTime, endTime) * 100}%`; } intervalToDecimal(startTime: number, endTime: number) { return (this.timeToDecimal(endTime) - this.timeToDecimal(startTime)); } timeToPercentage(time: number): string { return `${this.timeToDecimal(time) * 100}%` } timeToDecimal(time: number): number { if (this.originalChapterBarBlocks?.length > 1 && this.existingChapters.length === this.originalChapterBarBlocks?.length) { // Parent element to still work when display: none const totalPixels = this.originalChapterBar.parentElement.clientWidth; let pixelOffset = 0; let lastCheckedChapter = -1; for (let i = 0; i < this.originalChapterBarBlocks.length; i++) { const chapterElement = this.originalChapterBarBlocks[i]; const widthPixels = parseFloat(chapterElement.style.width.replace("px", "")); if (time >= this.existingChapters[i].segment[1]) { const marginPixels = chapterElement.style.marginRight ? parseFloat(chapterElement.style.marginRight.replace("px", "")) : 0; pixelOffset += widthPixels + marginPixels; lastCheckedChapter = i; } else { break; } } // The next chapter is the one we are currently inside of const latestChapter = this.existingChapters[lastCheckedChapter + 1]; if (latestChapter) { const latestWidth = parseFloat(this.originalChapterBarBlocks[lastCheckedChapter + 1].style.width.replace("px", "")); const latestChapterDuration = latestChapter.segment[1] - latestChapter.segment[0]; const percentageInCurrentChapter = (time - latestChapter.segment[0]) / latestChapterDuration; const sizeOfCurrentChapter = latestWidth / totalPixels; return Math.min(1, ((pixelOffset / totalPixels) + (percentageInCurrentChapter * sizeOfCurrentChapter))); } } return Math.min(1, time / this.videoDuration); } /* * Approximate size on preview bar for smallest element (due to  ) */ getMinimumSize(showLarger = false): number { return this.videoDuration * (showLarger ? 0.006 : 0.003); } // Name parameter used for cache private getSmallestSegment(timeInSeconds: number, segments: PreviewBarSegment[], name?: string): PreviewBarSegment | null { const proposedIndex = name ? this.lastSmallestSegment[name]?.index : null; const startSearchIndex = proposedIndex && segments[proposedIndex] === this.lastSmallestSegment[name].segment ? proposedIndex : 0; const direction = startSearchIndex > 0 && timeInSeconds < this.lastSmallestSegment[name].segment.segment[0] ? -1 : 1; let segment: PreviewBarSegment | null = null; let index = -1; let currentSegmentLength = Infinity; for (let i = startSearchIndex; i < segments.length && i >= 0; i += direction) { const seg = segments[i]; const segmentLength = seg.segment[1] - seg.segment[0]; const minSize = this.getMinimumSize(seg.showLarger); const startTime = segmentLength !== 0 ? seg.segment[0] : Math.floor(seg.segment[0]); const endTime = segmentLength > minSize ? seg.segment[1] : Math.ceil(seg.segment[0] + minSize); if (startTime <= timeInSeconds && endTime >= timeInSeconds) { if (segmentLength < currentSegmentLength) { currentSegmentLength = segmentLength; segment = seg; index = i; } } if (direction === 1 && seg.segment[0] > timeInSeconds) { break; } } if (segment) { this.lastSmallestSegment[name] = { index: index, segment: segment }; } return segment; } private getChapterChevron(): HTMLElement { return document.querySelector(".ytp-chapter-title-chevron"); } } export default PreviewBar;