Clean up Preview Bar

Fixes:
- Segments hidden by longer segments
- Duration with skips not accounting for segment overlaps
- Duration with skips not accounting for user's skip choices
- Segment category text in preview tooltip overlaps the seek bar
- Segment category text in preview tooltip breaks for timestamps over one hour
- `previewBar.ts` lacks function argument and return types
- Tooltip label not cleaned up on remove
- General code style issues
This commit is contained in:
opl-
2020-11-08 06:09:51 +01:00
parent 59826aae6d
commit 7078e1f033
4 changed files with 345 additions and 252 deletions

View File

@@ -11,11 +11,6 @@
z-index: 40; z-index: 40;
} }
.sbHidden {
display: none !important;
}
.previewbar { .previewbar {
display: inline-block; display: inline-block;
height: 100%; height: 100%;
@@ -23,12 +18,29 @@
/* Preview Bar page hacks */ /* Preview Bar page hacks */
.sbTooltipTwoTitleThumbnailOffset { .ytp-tooltip:not(.sponsorCategoryTooltipVisible) .sponsorCategoryTooltip {
bottom: -5px !important; display: none;
} }
.sbTooltipOneTitleThumbnailOffset { .ytp-tooltip.sponsorCategoryTooltipVisible {
bottom: 10px !important; transform: translateY(-1em);
}
.ytp-big-mode .ytp-tooltip.sponsorCategoryTooltipVisible {
transform: translateY(-2em);
}
#movie_player:not(.ytp-big-mode) .ytp-tooltip.sponsorCategoryTooltipVisible > .ytp-tooltip-text-wrapper {
transform: translateY(1em);
}
.ytp-big-mode .ytp-tooltip.sponsorCategoryTooltipVisible > .ytp-tooltip-text-wrapper {
transform: translateY(0.5em);
}
.ytp-big-mode .ytp-tooltip.sponsorCategoryTooltipVisible > .ytp-tooltip-text-wrapper > .ytp-tooltip-text {
display: block;
transform: translateY(1em);
} }
/* */ /* */

View File

@@ -8,7 +8,7 @@ const utils = new Utils();
import runThePopup from "./popup"; import runThePopup from "./popup";
import PreviewBar from "./js-components/previewBar"; import PreviewBar, {PreviewBarSegment} from "./js-components/previewBar";
import SkipNotice from "./render/SkipNotice"; import SkipNotice from "./render/SkipNotice";
import SkipNoticeComponent from "./components/SkipNoticeComponent"; import SkipNoticeComponent from "./components/SkipNoticeComponent";
import SubmissionNotice from "./render/SubmissionNotice"; import SubmissionNotice from "./render/SubmissionNotice";
@@ -252,7 +252,7 @@ function resetValues() {
//empty the preview bar //empty the preview bar
if (previewBar !== null) { if (previewBar !== null) {
previewBar.set([], [], 0); previewBar.clear();
} }
//reset sponsor data found check //reset sponsor data found check
@@ -368,8 +368,6 @@ async function videoIDChange(id) {
} }
function handleMobileControlsMutations(): void { function handleMobileControlsMutations(): void {
const mobileYouTubeSelector = ".progress-bar-background";
updateVisibilityOfPlayerControlsButton().then((createdButtons) => { updateVisibilityOfPlayerControlsButton().then((createdButtons) => {
if (createdButtons) { if (createdButtons) {
if (sponsorTimesSubmitting != null && sponsorTimesSubmitting.length > 0 && sponsorTimesSubmitting[sponsorTimesSubmitting.length - 1].segment.length >= 2) { if (sponsorTimesSubmitting != null && sponsorTimesSubmitting.length > 0 && sponsorTimesSubmitting[sponsorTimesSubmitting.length - 1].segment.length >= 2) {
@@ -381,10 +379,14 @@ function handleMobileControlsMutations(): void {
} }
} }
}); });
if (previewBar !== null) { if (previewBar !== null) {
if (document.body.contains(previewBar.container)) { if (document.body.contains(previewBar.container)) {
updatePreviewBarPositionMobile(document.getElementsByClassName(mobileYouTubeSelector)[0]); const progressBarBackground = document.querySelector<HTMLElement>(".progress-bar-background");
if (progressBarBackground !== null) {
updatePreviewBarPositionMobile(progressBarBackground);
}
return; return;
} else { } else {
@@ -415,11 +417,11 @@ function createPreviewBar(): void {
]; ];
for (const selector of progressElementSelectors) { for (const selector of progressElementSelectors) {
const el = document.querySelectorAll(selector); const el = document.querySelector<HTMLElement>(selector);
if (el) {
previewBar = new PreviewBar(el, onMobileYouTube, onInvidious);
if (el && el.length && el[0]) {
previewBar = new PreviewBar(el[0], onMobileYouTube, onInvidious);
updatePreviewBar(); updatePreviewBar();
break; break;
@@ -819,46 +821,58 @@ function getYouTubeVideoID(url: string) {
/** /**
* This function is required on mobile YouTube and will keep getting called whenever the preview bar disapears * This function is required on mobile YouTube and will keep getting called whenever the preview bar disapears
*/ */
function updatePreviewBarPositionMobile(parent: Element) { function updatePreviewBarPositionMobile(parent: HTMLElement) {
if (document.getElementById("previewbar") === null) { if (document.getElementById("previewbar") === null) {
previewBar.updatePosition(parent); previewBar.updatePosition(parent);
} }
} }
function updatePreviewBar() { function updatePreviewBar() {
if(isAdPlaying) { if (previewBar === null) return;
previewBar.set([], [], 0);
if (isAdPlaying) {
previewBar.clear();
return; return;
} }
if (previewBar === null || video === null) return; if (video === null) return;
let localSponsorTimes = sponsorTimes; const previewBarSegments: PreviewBarSegment[] = [];
if (localSponsorTimes == null) localSponsorTimes = [];
const allSponsorTimes = localSponsorTimes.concat(sponsorTimesSubmitting); if (sponsorTimes) {
sponsorTimes.forEach((segment) => {
//create an array of the sponsor types if (segment.hidden !== SponsorHideType.Visible) return;
const types = [];
for (let i = 0; i < localSponsorTimes.length; i++) { previewBarSegments.push({
if (localSponsorTimes[i].hidden === SponsorHideType.Visible) { timestamps: segment.segment as [number, number],
types.push(localSponsorTimes[i].category); category: segment.category,
} else { preview: false,
// Don't show this sponsor });
types.push(null); });
}
}
for (let i = 0; i < sponsorTimesSubmitting.length; i++) {
types.push("preview-" + sponsorTimesSubmitting[i].category);
} }
previewBar.set(utils.getSegmentsFromSponsorTimes(allSponsorTimes), types, video.duration) sponsorTimesSubmitting.forEach((segment) => {
previewBarSegments.push({
timestamps: segment.segment as [number, number],
category: segment.category,
preview: true,
});
});
previewBar.set(previewBarSegments, video.duration)
if (Config.config.showTimeWithSkips) { if (Config.config.showTimeWithSkips) {
showTimeWithoutSkips(allSponsorTimes); const skippedSegments = previewBarSegments.filter((segment) => {
// Count the segment only if the category is autoskipped
return utils.getCategorySelection(segment.category)?.option === CategorySkipOption.AutoSkip;
});
const skippedDuration = utils.getTimestampsDuration(skippedSegments.map(({timestamps}) => timestamps));
showTimeWithoutSkips(skippedDuration);
} }
//update last video id // Update last video id
lastPreviewBarUpdate = sponsorVideoID; lastPreviewBarUpdate = sponsorVideoID;
} }
@@ -1626,37 +1640,28 @@ function updateAdFlag() {
} }
} }
function showTimeWithoutSkips(allSponsorTimes): void { function showTimeWithoutSkips(skippedDuration: number): void {
if (onMobileYouTube || onInvidious) return; if (onMobileYouTube || onInvidious) return;
let skipDuration = 0; if (isNaN(skippedDuration) || skippedDuration < 0) {
skippedDuration = 0;
// Calculate skipDuration based from the segments in the preview bar }
for (let i = 0; i < allSponsorTimes.length; i++) {
// If an end time exists // YouTube player time display
if (allSponsorTimes[i].segment[1]) { const display = document.querySelector(".ytp-time-display.notranslate");
skipDuration += allSponsorTimes[i].segment[1] - allSponsorTimes[i].segment[0]; if (!display) return;
}
const durationID = "sponsorBlockDurationAfterSkips";
}
// YouTube player time display
const display = document.getElementsByClassName("ytp-time-display notranslate")[0];
if (!display) return;
const formatedTime = utils.getFormattedTime(video.duration - skipDuration);
const durationID = "sponsorBlockDurationAfterSkips";
let duration = document.getElementById(durationID); let duration = document.getElementById(durationID);
// Create span if needed // Create span if needed
if(duration === null) { if (duration === null) {
duration = document.createElement('span'); duration = document.createElement('span');
duration.id = durationID; duration.id = durationID;
duration.classList.add("ytp-time-duration"); duration.classList.add("ytp-time-duration");
display.appendChild(duration); display.appendChild(duration);
} }
duration.innerText = (skipDuration <= 0 || isNaN(skipDuration) || formatedTime.includes("NaN")) ? "" : " ("+formatedTime+")"; duration.innerText = skippedDuration <= 0 ? "" : " (" + utils.getFormattedTime(video.duration - skippedDuration) + ")";
} }

View File

@@ -1,6 +1,6 @@
/* /*
This is based on code from VideoSegments. This is based on code from VideoSegments.
https://github.com/videosegments/videosegments/commits/f1e111bdfe231947800c6efdd51f62a4e7fef4d4/segmentsbar/segmentsbar.js https://github.com/videosegments/videosegments/commits/f1e111bdfe231947800c6efdd51f62a4e7fef4d4/segmentsbar/segmentsbar.js
*/ */
'use strict'; 'use strict';
@@ -9,179 +9,218 @@ import Config from "../config";
import Utils from "../utils"; import Utils from "../utils";
const utils = new Utils(); const utils = new Utils();
class PreviewBar { const TOOLTIP_VISIBLE_CLASS = 'sponsorCategoryTooltipVisible';
container: HTMLUListElement;
parent: any;
onMobileYouTube: boolean;
onInvidious: boolean;
timestamps: number[][]; export interface PreviewBarSegment {
types: string[]; timestamps: [number, number];
category: string;
constructor(parent: any, onMobileYouTube: boolean, onInvidious: boolean) { preview: boolean;
this.container = document.createElement('ul');
this.container.id = 'previewbar';
this.parent = parent;
this.onMobileYouTube = onMobileYouTube;
this.onInvidious = onInvidious;
this.updatePosition(parent);
this.setupHoverText();
}
setupHoverText(): void {
if (this.onMobileYouTube || this.onInvidious) return;
const seekBar = document.querySelector(".ytp-progress-bar-container");
// Create label placeholder
const tooltipTextWrapper = document.querySelector(".ytp-tooltip-text-wrapper");
const titleTooltip = document.querySelector(".ytp-tooltip-title");
const categoryTooltip = document.createElement("div");
categoryTooltip.className = "sbHidden ytp-tooltip-title";
categoryTooltip.id = "sponsor-block-category-tooltip"
tooltipTextWrapper.insertBefore(categoryTooltip, titleTooltip.nextSibling);
let mouseOnSeekBar = false;
seekBar.addEventListener("mouseenter", (event) => {
mouseOnSeekBar = true;
});
seekBar.addEventListener("mouseleave", (event) => {
mouseOnSeekBar = false;
categoryTooltip.classList.add("sbHidden");
});
const observer = new MutationObserver((mutations, observer) => {
if (!mouseOnSeekBar) return;
// See if mutation observed is only this ID (if so, ignore)
if (mutations.length == 1 && (mutations[0].target as HTMLElement).id === "sponsor-block-category-tooltip") {
return;
}
const tooltips = document.querySelectorAll(".ytp-tooltip-text");
for (const tooltip of tooltips) {
const splitData = tooltip.textContent.split(":");
if (splitData.length === 2 && !isNaN(parseInt(splitData[0])) && !isNaN(parseInt(splitData[1]))) {
// Add label
const timeInSeconds = parseInt(splitData[0]) * 60 + parseInt(splitData[1]);
// Find category at that location
let category = null;
for (let i = 0; i < this.timestamps?.length; i++) {
if (this.timestamps[i][0] < timeInSeconds && this.timestamps[i][1] > timeInSeconds){
category = this.types[i];
}
}
if (category === null && !categoryTooltip.classList.contains("sbHidden")) {
categoryTooltip.classList.add("sbHidden");
tooltipTextWrapper.classList.remove("sbTooltipTwoTitleThumbnailOffset");
tooltipTextWrapper.classList.remove("sbTooltipOneTitleThumbnailOffset");
} else if (category !== null) {
categoryTooltip.classList.remove("sbHidden");
categoryTooltip.textContent = utils.shortCategoryName(category)
|| (chrome.i18n.getMessage("preview") + " " + utils.shortCategoryName(category.split("preview-")[1]));
// There is a title now
tooltip.classList.remove("ytp-tooltip-text-no-title");
// Add the correct offset for the number of titles there are
if (titleTooltip.textContent !== "") {
if (!tooltipTextWrapper.classList.contains("sbTooltipTwoTitleThumbnailOffset")) {
tooltipTextWrapper.classList.add("sbTooltipTwoTitleThumbnailOffset");
}
} else if (!tooltipTextWrapper.classList.contains("sbTooltipOneTitleThumbnailOffset")) {
tooltipTextWrapper.classList.add("sbTooltipOneTitleThumbnailOffset");
}
}
break;
}
}
});
observer.observe(tooltipTextWrapper, {
childList: true,
subtree: true
});
}
updatePosition(parent: any): void {
//below the seek bar
// this.parent.insertAdjacentElement("afterEnd", this.container);
this.parent = parent;
if (this.onMobileYouTube) {
parent.style.backgroundColor = "rgba(255, 255, 255, 0.3)";
parent.style.opacity = "1";
this.container.style.transform = "none";
}
//on the seek bar
this.parent.insertAdjacentElement("afterBegin", this.container);
}
updateColor(segment: string, color: string, opacity: string): void {
const bars = <NodeListOf<HTMLElement>> document.querySelectorAll('[data-vs-segment-type=' + segment + ']');
for (const bar of bars) {
bar.style.backgroundColor = color;
bar.style.opacity = opacity;
}
}
set(timestamps: number[][], types: string[], duration: number): void {
while (this.container.firstChild) {
this.container.removeChild(this.container.firstChild);
}
if (!timestamps || !types) {
return;
}
this.timestamps = timestamps;
this.types = types;
// to avoid rounding error resulting in width more than 100%
duration = Math.floor(duration * 100) / 100;
let width;
for (let i = 0; i < timestamps.length; i++) {
if (types[i] == null) continue;
width = (timestamps[i][1] - timestamps[i][0]) / duration * 100;
width = Math.floor(width * 100) / 100;
const bar = this.createBar();
bar.setAttribute('data-vs-segment-type', types[i]);
bar.style.backgroundColor = Config.config.barTypes[types[i]].color;
if (!this.onMobileYouTube) bar.style.opacity = Config.config.barTypes[types[i]].opacity;
bar.style.width = width + '%';
bar.style.left = (timestamps[i][0] / duration * 100) + "%";
bar.style.position = "absolute"
this.container.insertAdjacentElement("beforeend", bar);
}
}
createBar(): HTMLLIElement {
const bar = document.createElement('li');
bar.classList.add('previewbar');
bar.innerHTML = '&nbsp;';
return bar;
}
remove(): void {
this.container.remove();
this.container = undefined;
}
} }
export default PreviewBar; class PreviewBar {
container: HTMLUListElement;
categoryTooltip?: HTMLDivElement;
tooltipContainer?: HTMLElement;
parent: HTMLElement;
onMobileYouTube: boolean;
onInvidious: boolean;
segments: PreviewBarSegment[] = [];
videoDuration = 0;
constructor(parent: HTMLElement, onMobileYouTube: boolean, onInvidious: boolean) {
this.container = document.createElement('ul');
this.container.id = 'previewbar';
this.parent = parent;
this.onMobileYouTube = onMobileYouTube;
this.onInvidious = onInvidious;
this.updatePosition(parent);
this.setupHoverText();
}
setupHoverText(): void {
if (this.onMobileYouTube || this.onInvidious) return;
// Create label placeholder
this.categoryTooltip = document.createElement("div");
this.categoryTooltip.className = "ytp-tooltip-title sponsorCategoryTooltip";
const tooltipTextWrapper = document.querySelector(".ytp-tooltip-text-wrapper");
if (!tooltipTextWrapper || !tooltipTextWrapper.parentElement) return;
// Grab the tooltip from the text wrapper as the tooltip doesn't have its classes on init
this.tooltipContainer = tooltipTextWrapper.parentElement;
const titleTooltip = tooltipTextWrapper.querySelector(".ytp-tooltip-title");
if (!this.tooltipContainer || !titleTooltip) return;
tooltipTextWrapper.insertBefore(this.categoryTooltip, 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.tooltipContainer) return;
// If the mutation observed is only for our tooltip text, ignore
if (mutations.length === 1 && (mutations[0].target as HTMLElement).classList.contains("sponsorCategoryTooltip")) {
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 = utils.getFormattedTimeToSeconds(tooltipText);
if (timeInSeconds !== null) break;
}
if (timeInSeconds === null) return;
// Find the segment at that location, using the shortest if multiple found
let segment: PreviewBarSegment | null = null;
let currentSegmentLength = Infinity;
for (const seg of this.segments) {
if (seg.timestamps[0] <= timeInSeconds && seg.timestamps[1] > timeInSeconds) {
const segmentLength = seg.timestamps[1] - seg.timestamps[0];
if (segmentLength < currentSegmentLength) {
currentSegmentLength = segmentLength;
segment = seg;
}
}
}
if (segment === null && this.tooltipContainer.classList.contains(TOOLTIP_VISIBLE_CLASS)) {
this.tooltipContainer.classList.remove(TOOLTIP_VISIBLE_CLASS);
} else if (segment !== null) {
this.tooltipContainer.classList.add(TOOLTIP_VISIBLE_CLASS);
if (segment.preview) {
this.categoryTooltip.textContent = chrome.i18n.getMessage("preview") + " " + utils.shortCategoryName(segment.category);
} else {
this.categoryTooltip.textContent = utils.shortCategoryName(segment.category);
}
// Use the class if the timestamp text uses it to prevent overlapping
this.categoryTooltip.classList.toggle("ytp-tooltip-text-no-title", noYoutubeChapters);
}
});
observer.observe(tooltipTextWrapper, {
childList: true,
subtree: true,
});
}
updatePosition(parent: HTMLElement): void {
this.parent = parent;
if (this.onMobileYouTube) {
parent.style.backgroundColor = "rgba(255, 255, 255, 0.3)";
parent.style.opacity = "1";
this.container.style.transform = "none";
}
// On the seek bar
this.parent.prepend(this.container);
}
// TODO: call on config changes
updateColor(segmentType: string, color: string, opacity: number): void {
const bars = <NodeListOf<HTMLElement>> document.querySelectorAll('[data-vs-segment-type=' + segmentType + ']');
for (const bar of bars) {
bar.style.backgroundColor = color;
bar.style.opacity = String(opacity);
}
}
clear(): void {
this.videoDuration = 0;
this.segments = [];
while (this.container.firstChild) {
this.container.removeChild(this.container.firstChild);
}
}
set(segments: PreviewBarSegment[], videoDuration: number): void {
this.clear();
if (!segments) return;
this.segments = segments;
this.videoDuration = videoDuration;
this.segments.sort(({timestamps: a}, {timestamps: b}) => {
// Sort longer segments before short segments to make shorter segments render later
return (b[1] - b[0]) - (a[1] - a[0]);
}).forEach((segment) => {
const bar = this.createBar(segment);
this.container.appendChild(bar);
});
}
createBar({category, preview, timestamps}: PreviewBarSegment): HTMLLIElement {
const bar = document.createElement('li');
bar.classList.add('previewbar');
bar.innerHTML = '&nbsp;';
const barSegmentType = (preview ? 'preview-' : '') + category;
bar.setAttribute('data-vs-segment-type', barSegmentType);
bar.style.backgroundColor = Config.config.barTypes[barSegmentType].color;
if (!this.onMobileYouTube) bar.style.opacity = Config.config.barTypes[barSegmentType].opacity;
bar.style.position = "absolute";
bar.style.width = this.timeToPercentage(timestamps[1] - timestamps[0]);
bar.style.left = this.timeToPercentage(timestamps[0]);
return bar;
}
remove(): void {
this.container.remove();
if (this.categoryTooltip) {
this.categoryTooltip.remove();
this.categoryTooltip = undefined;
}
if (this.tooltipContainer) {
this.tooltipContainer.classList.remove(TOOLTIP_VISIBLE_CLASS);
this.tooltipContainer = undefined;
}
}
timeToPercentage(time: number): string {
return Math.min(100, time / this.videoDuration * 100) + '%';
}
}
export default PreviewBar;

View File

@@ -158,17 +158,54 @@ class Utils {
} }
/** /**
* Gets just the timestamps from a sponsorTimes array * Merges any overlapping timestamp ranges into single segments and returns them as a new array.
*
* @param sponsorTimes
*/ */
getSegmentsFromSponsorTimes(sponsorTimes: SponsorTime[]): number[][] { getMergedTimestamps(timestamps: number[][]): [number, number][] {
const segments: number[][] = []; let deduped: [number, number][] = [];
for (const sponsorTime of sponsorTimes) {
segments.push(sponsorTime.segment);
}
return segments; // Cases ([] = another segment, <> = current range):
// [<]>, <[>], <[]>, [<>], [<][>]
timestamps.forEach((range) => {
// Find segments the current range overlaps
const startOverlaps = deduped.findIndex((other) => range[0] >= other[0] && range[0] <= other[1]);
const endOverlaps = deduped.findIndex((other) => range[1] >= other[0] && range[1] <= other[1]);
if (~startOverlaps && ~endOverlaps) {
// [<][>] Both the start and end of this range overlap another segment
// [<>] This range is already entirely contained within an existing segment
if (startOverlaps === endOverlaps) return;
// Remove the range with the higher index first to avoid the index shifting
const other1 = deduped.splice(Math.max(startOverlaps, endOverlaps), 1)[0];
const other2 = deduped.splice(Math.min(startOverlaps, endOverlaps), 1)[0];
// Insert a new segment spanning the start and end of the range
deduped.push([Math.min(other1[0], other2[0]), Math.max(other1[1], other2[1])]);
} else if (~startOverlaps) {
// [<]> The start of this range overlaps another segment, extend its end
deduped[startOverlaps][1] = range[1];
} else if (~endOverlaps) {
// <[>] The end of this range overlaps another segment, extend its beginning
deduped[endOverlaps][0] = range[0];
} else {
// No overlaps, just push in a copy
deduped.push(range.slice() as [number, number]);
}
// <[]> Remove other segments contained within this range
deduped = deduped.filter((other) => !(other[0] > range[0] && other[1] < range[1]));
});
return deduped;
}
/**
* Returns the total duration of the timestamps, taking into account overlaps.
*/
getTimestampsDuration(timestamps: number[][]): number {
return this.getMergedTimestamps(timestamps).reduce((acc, range) => {
return acc + range[1] - range[0];
}, 0);
} }
getSponsorIndexFromUUID(sponsorTimes: SponsorTime[], UUID: string): number { getSponsorIndexFromUUID(sponsorTimes: SponsorTime[], UUID: string): number {