您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Autoplay for Aniworld.to and S.to with lots of functions like Outro skip, Intro skip, persistent volume between providers, remember language, Playback Position Memory and some more
// ==UserScript== // @name Aniworld.to & S.to Autoplay // @name:de Autoplay AniWorld & S.to // @description Autoplay for Aniworld.to and S.to with lots of functions like Outro skip, Intro skip, persistent volume between providers, remember language, Playback Position Memory and some more // @description:de Autoplay für Aniworld.to und S.to mit vielen Funktionen wie Outro-Überspringen, Intro-Überspringen, Sprachspeicherung, Konstante Lautstärke zwischen providern, Wiedergabepositionsspeicher und mehr // @version 4.10.2 // @match https://aniworld.to/* // @match https://s.to/* // @match https://186.2.175.5/ // @match *://*/* // @author AniPlayer // @namespace https://greasyfork.org/users/1400386 // @license GPL-3.0-or-later; https://spdx.org/licenses/GPL-3.0-or-later.html // @icon https://i.imgur.com/CEZGcX6.png // @require https://cdnjs.cloudflare.com/ajax/libs/keyboardjs/2.7.0/keyboard.min.js#sha512-UrxaOZAJw5p38NProL/UrffryqdMdXFcEdyLt6eU89pH0N7KnmAe8G3ghNbH1qW5cDYdnaoEw1TcbHn8wuqAvw== // @require https://cdn.jsdelivr.net/npm/[email protected]/dist/notiflix-aio-3.2.8.min.js#sha512-XsGxeeCSQNP2+WGCUScwIO6sznCBBee4we6n8n6yoFgB+shnCXJZCY2snFqu+fgIbPd79ldRR1/5zQFMUQVSpg== // @require https://cdn.jsdelivr.net/npm/[email protected]/dist/tweakpane.min.js#sha512-ugca4SpzfDh4VV8oj0yscIUlKxZhJd9LD5HOX4o7jOMlI/1iGYr7S4Q4Fnvx/GFXCwAivLrdHOo/7t4iYV4ehw== // @grant GM_addStyle // @grant GM_addValueChangeListener // @grant GM_deleteValue // @grant GM_getValue // @grant GM_listValues // @grant GM_removeValueChangeListener // @grant GM_setValue // @grant GM.getValue // @grant unsafeWindow // @run-at document-body // ==/UserScript== /** * Hi! This script only works with Violentmonkey, as using other managers * causes unexpected bugs. Please, consider installing Violentmonkey, * you can use it along with your current script manager. * * Don't change (or even resave) anything here because * doing so in Tampermonkey will turn off the script updates. * Not sure about other script managers. * This can be restored in settings, but it might be hard to find, * so it's better to reinstall the script if you're not sure. * Hallo! Dieses Skript funktioniert nur mit Violentmonkey, da die Verwendung * anderer Script-Manager zu unerwarteten Fehlern führen kann. Bitte ziehe in Betracht, * Violentmonkey zu installieren – du kannst es zusammen mit deinem aktuellen * Script-Manager verwenden. * * Ändere hier nichts (auch nicht speichern), denn * das führt in Tampermonkey dazu, dass Script-Updates deaktiviert werden. * Bei anderen Script-Managern ist das unklar. * Das lässt sich zwar in den Einstellungen wiederherstellen, * aber es kann schwer zu finden sein – daher ist es besser, * das Skript neu zu installieren, wenn du unsicher bist. */ /* jshint esversion: 11 */ /* global Notiflix, Tweakpane, keyboardJS */ (async function() { 'use strict'; // Localization setup const userLang = navigator.language.startsWith('de') ? 'de' : 'en'; const localizations = { en: { violentmonkeyWarningTitle: `${GM_info.script.name} warning`, violentmonkeyWarningText: 'This script only works with Violentmonkey, as using other script managers causes unexpected bugs. Please, consider installing Violentmonkey, you can use it along with your current script manager. This message won\'t show up again', firstRunInfoTitle: `${GM_info.script.name} info`, firstRunInfoText: (isMobile, largeSkipKey) => `${isMobile ? 'Hold-release' : 'Right click'} the toggle button to open autoplay settings. ${isMobile ? '' : `Press "${largeSkipKey}" when an intro starts to skip it. `}Fullscreen is scrollable, allowing to switch providers on the go`, ok: 'Okay', loading: 'Loading', vidmolyNotReady: 'Vidmoly not ready yet.', couldNotLoad: 'Could not load', hotkeysGuide: 'Hotkeys Guide', close: 'Close', errorSaving: 'There was an error when trying to save the', reportBug: '. The value would reset upon player reload. Please, report the bug, with a mention of a URL of the page you\'re currently on', autoplayError: 'The script got an error trying autoplay. Try again, and if the problem persists, report the bug, or you can try switching video player providers if possible', lastAutoplayError: 'Last autoplay end up with an error, but you should be at the next episode page now. Try again, and if the problem persists, report the bug, or you can try switching video player providers if possible', preferences: 'Preferences', advanced: 'Advanced', apply: 'Apply', providersPriority: 'Providers priority', miscellaneous: 'Miscellaneous', persistentMutedAutoplay: 'Persistent muted autoplay', persistentMutedAutoplayTooltip: 'Seamless autoplay is not always available due to browser restrictions. This setting makes autoplay muted which in turn makes autoplay to be always available (autoplay should be enabled for this to work), but instead it requires user input (click or keypress) to unmute. Keypress works only if a video player is in focus', autoSkipAtStart: 'Auto-skip at start', autoSkipAtStartTooltip: 'Automatically skips the beginning of a video when it starts. Enable this to activate the skip feature.', playbackPositionMemory: 'Playback position memory', playbackPositionMemoryTooltip: 'Saves the last playback position and restores it whenever the video player is reloaded', skipSecondsOnStart: 'Skip seconds on start', skipSecondsOnStartTooltip: 'Number of seconds to skip from the beginning when auto-skip is enabled.', overrideDoubletapBehavior: 'Override double-tap behavior*', overrideDoubletapBehaviorTooltip: 'If enabled, default double-tap behavior (if any) is being overrided: double-tap right/left side of a video player to fast forward/rewind. Double-tap in a middle applies an intro skip. Page reload is required for this setting to take effect!', introSkipSize: 'Intro skip size, sec', introSkipSizeTooltip: 'Intro skip size. This is linked to the title and should stay the same across episodes', outroSkipThreshold: 'Outro skip threshold, sec', outroSkipThresholdTooltip: 'Autoplay triggers when the video player has fewer than THIS number of seconds left to play. It is linked to the title and should stay the same across episodes', resetToDefaults: 'Reset to defaults', hotkeys: 'Hotkeys', fastBackward: 'Fast backward*', fastBackwardTooltip: 'Hotkey for a fast backward. Page reload is required for this setting to take effect!', fastForward: 'Fast forward*', fastForwardTooltip: 'Hotkey for a fast forward. Page reload is required for this setting to take effect!', fullscreen: 'Fullscreen*', fullscreenTooltip: 'Hotkey for a fullscreen mode toggle. Page reload is required for this setting to take effect!', largeSkip: 'Intro skip*', largeSkipTooltip: 'Hotkey for an intro skip. Page reload is required for this setting to take effect!', defaultIntroSkipSize: 'Default intro skip size, sec', defaultIntroSkipSizeTooltip: 'Default intro skip size', defaultOutroSkipThreshold: 'Default outro skip threshold, sec', defaultOutroSkipThresholdTooltip: 'Default outro skip threshold', markWatchedAfter: 'Mark watched after, sec', markWatchedAfterTooltip: 'Number of seconds of approximate playback time after which a video is being marked as watched. Set to 0 to disable and mark only by a triggered autoplay', fastForwardSize: 'Fast forward size, sec', fastForwardSizeTooltip: 'Number of seconds to skip or rewind using double-taps or pressing a corresponding hotkeys', showSkipIntroButton: 'Show Skip Intro Button', showSkipIntroButtonTooltip: 'Toggle visibility of the Skip Intro button on supported players', showSkipIntroButtonSeconds: 'Show Skip Intro Button, sec', showSkipIntroButtonSecondsTooltip: 'How long (in seconds) the Skip Intro button stays visible after loading', preloadOtherProviders: 'Preload other providers*', preloadOtherProvidersTooltip: 'Whether the script should try and built in a providers that are not built in by a default. Might impact network usage. Page reload is required for this setting to take effect!', playOnIntroSkip: 'Play on intro skip', playOnIntroSkipTooltip: 'Intro skip also starts playback', showDeviceSpecificSettings: 'Show device specific settings*', showDeviceSpecificSettingsTooltip: 'Show settings that usually have no use on your device. For example, if you\'re on mobile, hotkeys settings are hidden by default because there is no PC keyboard on mobile. Page reload is required for this setting to take effect!', doubleTapTimingThreshold: 'Double-tap timing threshold, ms*', doubleTapTimingThresholdTooltip: 'Adjusts the maximum time (in milliseconds) allowed between two taps for them to be recognized as a double-tap. A lower value requires faster taps, while a higher value allows more delay. Page reload is required for this setting to take effect!', doubleTapDistanceThreshold: 'Double-tap distance threshold, px*', doubleTapDistanceThresholdTooltip: 'Defines the maximum distance (in pixels) between two taps for them to be considered a double-tap. A smaller value requires taps to be closer together, while a larger value allows more separation. Page reload is required for this setting to take effect!', introSkipCooldown: 'Intro skip cooldown, ms*', introSkipCooldownTooltip: 'Cooldown for an intro skip hotkey, to prevent an accidental double skip. Page reload is required for this setting to take effect!', playbackPositionExpiration: 'Playback position expiration', playbackPositionExpirationTooltip: 'How many DAYS need to pass before a playback position is removed from the memory', corsProxy: 'CORS proxy', corsProxyTooltip: 'To keep possible VOE-to-VOE unmuted autoplay working, the script needs to route a very small number of web requests through its own proxy server. Leave the input empty to disable this or set your own proxy', commlinkPollingInterval: 'Commlink polling interval, ms*', commlinkPollingIntervalTooltip: 'Reflects messaging responsiveness between a player and a top scope. Might impact CPU usage if set too low. 40 should be enough. Page reload is required for this setting to take effect!', skipIntro: 'Skip Intro', autoplayEnabled: 'Autoplay is enabled', autoplayDisabled: 'Autoplay is disabled' }, de: { violentmonkeyWarningTitle: `${GM_info.script.name} Warnung`, violentmonkeyWarningText: 'Dieses Skript funktioniert nur mit Violentmonkey, da die Verwendung anderer Skriptmanager zu unerwarteten Fehlern führt. Bitte erwägen Sie die Installation von Violentmonkey, Sie können es zusammen mit Ihrem aktuellen Skriptmanager verwenden. Diese Nachricht wird nicht erneut angezeigt', firstRunInfoTitle: `${GM_info.script.name} Info`, firstRunInfoText: (isMobile, largeSkipKey) => `${isMobile ? 'Halten und loslassen' : 'Rechtsklick'} Sie auf die Umschalttaste, um die Autoplay-Einstellungen zu öffnen. ${isMobile ? '' : `Drücken Sie "${largeSkipKey}", wenn ein Intro beginnt, um es zu überspringen. `}Der Vollbildmodus ist scrollbar, sodass Sie die Anbieter unterwegs wechseln können`, ok: 'Okay', loading: 'Wird geladen', vidmolyNotReady: 'Vidmoly ist noch nicht bereit.', couldNotLoad: 'Konnte nicht geladen werden', hotkeysGuide: 'Hotkeys-Anleitung', close: 'Schließen', errorSaving: 'Beim Speichern von ist ein Fehler aufgetreten', reportBug: '. Der Wert wird beim Neuladen des Players zurückgesetzt. Bitte melden Sie den Fehler unter Angabe der URL der aktuellen Seite', autoplayError: 'Das Skript hat beim Versuch des Autoplays einen Fehler erhalten. Versuchen Sie es erneut. Wenn das Problem weiterhin besteht, melden Sie den Fehler oder versuchen Sie, den Video-Player-Anbieter zu wechseln, falls möglich', lastAutoplayError: 'Das letzte Autoplay ist mit einem Fehler beendet, aber Sie sollten jetzt auf der Seite der nächsten Episode sein. Versuchen Sie es erneut. Wenn das Problem weiterhin besteht, melden Sie den Fehler oder versuchen Sie, den Video-Player-Anbieter zu wechseln, falls möglich', preferences: 'Einstellungen', advanced: 'Erweitert', apply: 'Anwenden', providersPriority: 'Anbieterpriorität', miscellaneous: 'Sonstiges', persistentMutedAutoplay: 'Dauerhaft stummgeschaltetes Autoplay', persistentMutedAutoplayTooltip: 'Nahtloses Autoplay ist aufgrund von Browsereinschränkungen nicht immer verfügbar. Diese Einstellung schaltet das Autoplay stumm, wodurch das Autoplay immer verfügbar ist (Autoplay muss dafür aktiviert sein), erfordert jedoch eine Benutzereingabe (Klick oder Tastendruck) zum Aufheben der Stummschaltung. Ein Tastendruck funktioniert nur, wenn ein Videoplayer im Fokus ist', autoSkipAtStart: 'Automatisches Überspringen am Anfang', autoSkipAtStartTooltip: 'Überspringt automatisch den Anfang eines Videos, wenn es startet. Aktivieren Sie dies, um die Überspringfunktion zu aktivieren.', playbackPositionMemory: 'Wiedergabepositionsspeicher', playbackPositionMemoryTooltip: 'Speichert die letzte Wiedergabeposition und stellt sie wieder her, wenn der Videoplayer neu geladen wird', skipSecondsOnStart: 'Sekunden am Anfang überspringen', skipSecondsOnStartTooltip: 'Anzahl der Sekunden, die vom Anfang an übersprungen werden sollen, wenn das automatische Überspringen aktiviert ist.', overrideDoubletapBehavior: 'Doppeltipp-Verhalten überschreiben*', overrideDoubletapBehaviorTooltip: 'Wenn aktiviert, wird das standardmäßige Doppeltipp-Verhalten (falls vorhanden) überschrieben: Doppeltippen Sie auf die rechte/linke Seite eines Videoplayers, um schnell vor- oder zurückzuspulen. Ein Doppeltipp in der Mitte wendet einen Intro-Skip an. Ein Neuladen der Seite ist für diese Einstellung erforderlich!', introSkipSize: 'Intro-Skipgröße, Sek', introSkipSizeTooltip: 'Intro-Skipgröße. Dies ist mit dem Titel verknüpft und sollte über alle Episoden hinweg gleich bleiben', outroSkipThreshold: 'Outro-Skipschwelle, Sek', outroSkipThresholdTooltip: 'Autoplay wird ausgelöst, wenn der Videoplayer weniger als DIESE Anzahl von Sekunden zum Abspielen übrig hat. Es ist mit dem Titel verknüpft und sollte über alle Episoden hinweg gleich bleiben', resetToDefaults: 'Auf Standard zurücksetzen', hotkeys: 'Hotkeys', fastBackward: 'Schneller Rücklauf*', fastBackwardTooltip: 'Hotkey für einen schnellen Rücklauf. Ein Neuladen der Seite ist für diese Einstellung erforderlich!', fastForward: 'Schneller Vorlauf*', fastForwardTooltip: 'Hotkey für einen schnellen Vorlauf. Ein Neuladen der Seite ist für diese Einstellung erforderlich!', fullscreen: 'Vollbild*', fullscreenTooltip: 'Hotkey zum Umschalten des Vollbildmodus. Ein Neuladen der Seite ist für diese Einstellung erforderlich!', largeSkip: 'Intro überspringen*', largeSkipTooltip: 'Hotkey für einen Intro-Skip. Ein Neuladen der Seite ist für diese Einstellung erforderlich!', defaultIntroSkipSize: 'Standard-Intro-Skipgröße, Sek', defaultIntroSkipSizeTooltip: 'Standard-Intro-Skipgröße', defaultOutroSkipThreshold: 'Standard-Outro-Skipschwelle, Sek', defaultOutroSkipThresholdTooltip: 'Standard-Outro-Skipschwelle', markWatchedAfter: 'Als angesehen markieren nach, Sek', markWatchedAfterTooltip: 'Anzahl der Sekunden ungefährer Wiedergabezeit, nach der ein Video als angesehen markiert wird. Auf 0 setzen, um zu deaktivieren und nur durch ein ausgelöstes Autoplay zu markieren', fastForwardSize: 'Schnellvorlaufgröße, Sek', fastForwardSizeTooltip: 'Anzahl der Sekunden, die mit Doppeltipps oder durch Drücken einer entsprechenden Hotkey übersprungen oder zurückgespult werden sollen', showSkipIntroButton: 'Intro überspringen-Button anzeigen', showSkipIntroButtonTooltip: 'Sichtbarkeit des Intro überspringen-Buttons auf unterstützten Playern umschalten', showSkipIntroButtonSeconds: 'Intro überspringen-Button anzeigen, Sek', showSkipIntroButtonSecondsTooltip: 'Wie lange (in Sekunden) der Intro überspringen-Button nach dem Laden sichtbar bleibt', preloadOtherProviders: 'Andere Anbieter vorladen*', preloadOtherProvidersTooltip: 'Ob das Skript versuchen soll, Anbieter zu integrieren, die nicht standardmäßig integriert sind. Kann die Netzwerknutzung beeinträchtigen. Ein Neuladen der Seite ist für diese Einstellung erforderlich!', playOnIntroSkip: 'Bei Intro-Skip abspielen', playOnIntroSkipTooltip: 'Intro-Skip startet auch die Wiedergabe', showDeviceSpecificSettings: 'Gerätespezifische Einstellungen anzeigen*', showDeviceSpecificSettingsTooltip: 'Einstellungen anzeigen, die auf Ihrem Gerät normalerweise keine Verwendung haben. Wenn Sie beispielsweise auf einem Mobilgerät sind, sind die Hotkey-Einstellungen standardmäßig ausgeblendet, da auf Mobilgeräten keine PC-Tastatur vorhanden ist. Ein Neuladen der Seite ist für diese Einstellung erforderlich!', doubleTapTimingThreshold: 'Doppeltipp-Timing-Schwelle, ms*', doubleTapTimingThresholdTooltip: 'Passt die maximale Zeit (in Millisekunden) an, die zwischen zwei Tipps erlaubt ist, damit sie als Doppeltipp erkannt werden. Ein niedrigerer Wert erfordert schnellere Tipps, während ein höherer Wert mehr Verzögerung zulässt. Ein Neuladen der Seite ist für diese Einstellung erforderlich!', doubleTapDistanceThreshold: 'Doppeltipp-Distanzschwelle, px*', doubleTapDistanceThresholdTooltip: 'Definiert die maximale Entfernung (in Pixeln) zwischen zwei Tipps, damit sie als Doppeltipp betrachtet werden. Ein kleinerer Wert erfordert, dass die Tipps näher beieinander liegen, während ein größerer Wert mehr Abstand zulässt. Ein Neuladen der Seite ist für diese Einstellung erforderlich!', introSkipCooldown: 'Intro-Skip-Abklingzeit, ms*', introSkipCooldownTooltip: 'Abklingzeit für einen Intro-Skip-Hotkey, um einen versehentlichen Doppelskip zu verhindern. Ein Neuladen der Seite ist für diese Einstellung erforderlich!', playbackPositionExpiration: 'Ablauf der Wiedergabeposition', playbackPositionExpirationTooltip: 'Wie viele TAGE müssen vergehen, bevor eine Wiedergabeposition aus dem Speicher entfernt wird', corsProxy: 'CORS-Proxy', corsProxyTooltip: 'Um ein mögliches VOE-zu-VOE ungestummtes Autoplay zu ermöglichen, muss das Skript eine sehr kleine Anzahl von Webanfragen über einen eigenen Proxyserver leiten. Lassen Sie das Eingabefeld leer, um dies zu deaktivieren oder Ihren eigenen Proxy festzulegen', commlinkPollingInterval: 'Commlink-Abfrageintervall, ms*', commlinkPollingIntervalTooltip: 'Spiegelt die Reaktionsfähigkeit der Nachrichtenübertragung zwischen einem Player und einem Top-Scope wider. Kann die CPU-Auslastung beeinträchtigen, wenn sie zu niedrig eingestellt ist. 40 sollten ausreichen. Ein Neuladen der Seite ist für diese Einstellung erforderlich!', skipIntro: 'Intro überspringen', autoplayEnabled: 'Autoplay ist aktiviert', autoplayDisabled: 'Autoplay ist deaktiviert' } }; const i18n = localizations[userLang]; const VIOLENTMONKEY_WARNING = [ i18n.violentmonkeyWarningTitle, i18n.violentmonkeyWarningText ]; // Domains list the script should work for const TOP_SCOPE_DOMAINS = [ 'aniworld.to', 's.to', '186.2.175.5', ]; // Needed for proper tracking of position memory const TOP_SCOPE_DOMAINS_IDS = { 'aniworld.to': 'aniworld', 's.to': 'sto', '186.2.175.5': 'sto', }; // Names should be the exact same as in the providers list of the website const VIDEO_PROVIDERS_MAP = { Doodstream: 'Doodstream', LoadX: 'LoadX', SpeedFiles: 'SpeedFiles', Vidoza: 'Vidoza', VOE: 'VOE', }; const VIDEO_PROVIDERS_IDS = { '0': VIDEO_PROVIDERS_MAP.LoadX, '1': VIDEO_PROVIDERS_MAP.VOE, '2': VIDEO_PROVIDERS_MAP.SpeedFiles, '3': VIDEO_PROVIDERS_MAP.Vidoza, '4': VIDEO_PROVIDERS_MAP.Doodstream, }; // Providers supported by the script, ordered by a default priority const VIDEO_PROVIDERS_DEFAULT_ORDER = [ VIDEO_PROVIDERS_MAP.LoadX, VIDEO_PROVIDERS_MAP.VOE, VIDEO_PROVIDERS_MAP.Doodstream, VIDEO_PROVIDERS_MAP.SpeedFiles, VIDEO_PROVIDERS_MAP.Vidoza, ]; const CORE_SETTINGS_MAP = { currentLargeSkipSizeS: 'currentLargeSkipSizeS', currentOutroSkipThresholdS: 'currentOutroSkipThresholdS', isAutoplayEnabled: 'isAutoplayEnabled', isMuted: 'isMuted', shouldAutoSkipOnStart: 'shouldAutoSkipOnStart', autoSkipSecondsOnStart: 'autoSkipSecondsOnStart', persistentVolumeLvl: 'persistentVolumeLvl', providersPriority: 'providersPriority', videoLanguagePreferredID: 'videoLanguagePreferredID', }; // Note that defaults are applied only on a very first run of the script const CORE_SETTINGS_DEFAULTS = { // Default value doesn't matter because it fallbacks to // ADVANCED_SETTINGS_DEFAULTS.defaultLargeSkipSizeS anyway [CORE_SETTINGS_MAP.currentLargeSkipSizeS]: 87, [CORE_SETTINGS_MAP.currentOutroSkipThresholdS]: 90, // same logic [CORE_SETTINGS_MAP.shouldAutoSkipOnStart]: true, [CORE_SETTINGS_MAP.autoSkipSecondsOnStart]: 0, [CORE_SETTINGS_MAP.isAutoplayEnabled]: false, [CORE_SETTINGS_MAP.isMuted]: false, [CORE_SETTINGS_MAP.persistentVolumeLvl]: 0.5, [CORE_SETTINGS_MAP.providersPriority]: ( VIDEO_PROVIDERS_DEFAULT_ORDER.map(name => Object.keys(VIDEO_PROVIDERS_IDS).find( key => VIDEO_PROVIDERS_IDS[key] === name )) ), [CORE_SETTINGS_MAP.videoLanguagePreferredID]: '1', }; const HOTKEYS_SETTINGS_MAP = { fastBackward: 'fastBackward', fastForward: 'fastForward', fullscreen: 'fullscreen', largeSkip: 'largeSkip', }; // Note that defaults are applied only on a very first run of the script const HOTKEYS_SETTINGS_DEFAULTS = { [HOTKEYS_SETTINGS_MAP.fastBackward]: 'left', [HOTKEYS_SETTINGS_MAP.fastForward]: 'right', [HOTKEYS_SETTINGS_MAP.fullscreen]: 'f', [HOTKEYS_SETTINGS_MAP.largeSkip]: 'v', }; const MAIN_SETTINGS_MAP = { overrideDoubletapBehavior: 'overrideDoubletapBehavior', playbackPositionMemory: 'playbackPositionMemory', shouldAutoplayMuted: 'shouldAutoplayMuted', }; // Note that defaults are applied only on a very first run of the script const MAIN_SETTINGS_DEFAULTS = { [MAIN_SETTINGS_MAP.overrideDoubletapBehavior]: true, [MAIN_SETTINGS_MAP.playbackPositionMemory]: true, [MAIN_SETTINGS_MAP.shouldAutoplayMuted]: true, }; const ADVANCED_SETTINGS_MAP = { commlinkPollingIntervalMs: 'commlinkPollingIntervalMs', corsProxy: 'corsProxy', defaultLargeSkipSizeS: 'defaultLargeSkipSizeS', defaultOutroSkipThresholdS: 'defaultOutroSkipThresholdS', doubletapDistanceThresholdPx: 'doubletapDistanceThresholdPx', doubletapTimingThresholdMs: 'doubletapTimingThresholdMs', fastForwardSizeS: 'fastForwardSizeS', largeSkipCooldownMs: 'largeSkipCooldownMs', markWatchedAfterS: 'markWatchedAfterS', playOnLargeSkip: 'playOnLargeSkip', playbackPositionExpirationDays: 'playbackPositionExpirationDays', preloadOtherProviders: 'preloadOtherProviders', showSkipIntroButton: 'showSkipIntroButton', showSkipIntroButtonSeconds: 'showSkipIntroButtonSeconds', showDeviceSpecificSettings: 'showDeviceSpecificSettings', }; // Note that defaults are applied only on a very first run of the script const ADVANCED_SETTINGS_DEFAULTS = { [ADVANCED_SETTINGS_MAP.commlinkPollingIntervalMs]: 40, [ADVANCED_SETTINGS_MAP.corsProxy]: 'https://aniworld-to-cors-proxy.fly.dev/', [ADVANCED_SETTINGS_MAP.defaultLargeSkipSizeS]: 87, [ADVANCED_SETTINGS_MAP.defaultOutroSkipThresholdS]: 90, [ADVANCED_SETTINGS_MAP.doubletapDistanceThresholdPx]: 50, [ADVANCED_SETTINGS_MAP.doubletapTimingThresholdMs]: 300, [ADVANCED_SETTINGS_MAP.fastForwardSizeS]: 10, [ADVANCED_SETTINGS_MAP.largeSkipCooldownMs]: 300, [ADVANCED_SETTINGS_MAP.markWatchedAfterS]: 0, [ADVANCED_SETTINGS_MAP.playOnLargeSkip]: true, [ADVANCED_SETTINGS_MAP.playbackPositionExpirationDays]: 30, [ADVANCED_SETTINGS_MAP.preloadOtherProviders]: true, [ADVANCED_SETTINGS_MAP.showSkipIntroButton]: true, [ADVANCED_SETTINGS_MAP.showSkipIntroButtonSeconds]: 240, [ADVANCED_SETTINGS_MAP.showDeviceSpecificSettings]: false, }; const IS_MOBILE = ( /Mobi|Android|iP(hone|[oa]d)/i.test(navigator.userAgent) ); const IS_SAFARI = ( navigator.userAgent.indexOf('Safari') > -1 && !/Chrome|CriOS/.test(navigator.userAgent) ); // Can not handle nested objects class DataStore { constructor(uuid, defaultStorage = {}) { if (typeof uuid !== 'string' && typeof uuid !== 'number') { throw new Error('Expected uuid when creating DataStore'); } this.__uuid = uuid; this.__storage = defaultStorage; try { this.__storage = JSON.parse(GM_getValue(uuid)); } catch { GM_setValue(uuid, JSON.stringify(defaultStorage)); } return new Proxy(this, { get: (obj, prop) => { if (prop === 'destroy') return () => obj.__destroy(); if (prop === 'update') return updates => obj.__update(updates); return obj.__storage[prop]; }, set: (obj, prop, value) => { obj.__storage[prop] = value; GM_setValue(obj.__uuid, JSON.stringify(obj.__storage)); return true; } }); } __update(updates) { if (updates) { Object.assign(this.__storage, updates); GM_setValue(this.__uuid, JSON.stringify(this.__storage)); } else { try { this.__storage = JSON.parse(GM_getValue(this.__uuid)) || {}; } catch { this.__storage = {}; } } } __destroy() { GM_deleteValue(this.__uuid); this.__storage = {}; } } const advancedSettings = new DataStore('advancedSettings', ADVANCED_SETTINGS_DEFAULTS); const coreSettings = new DataStore('coreSettings', CORE_SETTINGS_DEFAULTS); const hotkeysSettings = new DataStore('hotkeysSettings', HOTKEYS_SETTINGS_DEFAULTS); const mainSettings = new DataStore('mainSettings', MAIN_SETTINGS_DEFAULTS); [ [advancedSettings, ADVANCED_SETTINGS_DEFAULTS], [coreSettings, CORE_SETTINGS_DEFAULTS], [hotkeysSettings, HOTKEYS_SETTINGS_DEFAULTS], [mainSettings, MAIN_SETTINGS_DEFAULTS] ].forEach(([settings, defaults]) => { Object.entries(defaults).forEach(([key, value]) => (settings[key] ??= value)); }); if ( Object.keys(VIDEO_PROVIDERS_IDS).sort().toString() !== [...coreSettings[CORE_SETTINGS_MAP.providersPriority]].sort().toString() ) { coreSettings[CORE_SETTINGS_MAP.providersPriority] = [ ...CORE_SETTINGS_DEFAULTS[CORE_SETTINGS_MAP.providersPriority] ]; } // -------------------------------------- /utils --------------------------------------------- const Notiflixx = (() => { GM_addStyle(` [id^=NotiflixBlockWrap], [id^=NotiflixConfirmWrap], [id^=NotiflixLoadingWrap], [id^=NotiflixNotifyWrap], [id^=NotiflixReportWrap] { -webkit-tap-highlight-color: #24242412; } div.notiflix-report-icon { width: 60px !important; height: 60px !important; } div.notiflix-report-content { max-width: 1010px !important; width: unset !important; } .notiflix-hotkeys-guide-modal { max-height: 70vh; overflow-y: auto; padding: 0 15px; } .notiflix-hotkeys-guide-modal h5 { font-size: 19px; margin: 25px 0 10px 0; } .notiflix-hotkeys-guide-modal h5:first-child { margin: 0 0 10px 0; } .notiflix-hotkeys-guide-modal div { color: black; margin-bottom: 5px; } .notiflix-hotkeys-guide-modal pre { background: #243743; border: none; display: inline-block; margin: 1px 0 1px 0; padding: 4px 8px; vertical-align: middle; } `); const notifyDefaultOptions = { closeButton: true, messageMaxLength: 500, plainText: false, position: 'left-top', zindex: 3222222, }; const reportDefaultOptions = { titleMaxLength: 100, zindex: 3222223, }; const disableBodyScroll = () => { // Order is important here document.body.style.paddingRight = ( `${window.innerWidth - document.documentElement.clientWidth}px` ); document.body.style.overflow = 'hidden'; }; const restoreBodyScroll = () => { document.body.style.overflow = ''; document.body.style.paddingRight = ''; }; const createNotifyHandler = (notifyType) => { return (message, customOptions = {}) => { Notiflix.Notify[notifyType](message, { ...notifyDefaultOptions, ...customOptions, }); }; }; const createReportHandler = (reportType) => { return (titleText, messageText, btnText, customOptions = {}) => { disableBodyScroll(); Notiflix.Report[reportType](titleText, messageText, btnText, () => { restoreBodyScroll(); }, { ...reportDefaultOptions, ...customOptions, }); if (customOptions.backOverlayClickToClose) { const backOverlay = document.querySelector( '[id^=NotiflixReportWrap] > div[class*="-overlay"]' ); backOverlay?.addEventListener('click', () => restoreBodyScroll()); } if (customOptions.delayedButton) { const closeBtn = document.querySelector('a#NXReportButton'); closeBtn.style.background = '#b2b2b2'; closeBtn.style.pointerEvents = 'none'; setTimeout(() => { closeBtn.style.background = '#26c0d3'; closeBtn.style.pointerEvents = ''; }, 2000); } }; }; return { notify: { failure: createNotifyHandler('failure'), warning: createNotifyHandler('warning'), }, report: { info: createReportHandler('info'), warning: createReportHandler('warning'), }, }; })(); waitForElement('.inSiteWebStream', { existing: true }, function(container) { (function() { 'use strict'; const heightMap = { Vidmoly: '600px', Luluvdo: '480px', Filemoon: '480px' }; let vidmolyIframe = null; let vidmolyUrl = null; let vidmolyReady = false; function log(...args) { console.log('%c[SmartLoader]', 'color: lime;', ...args); } function spoofVidmolyEnv() { window.adsbygoogle = window.adsbygoogle || []; window.vsd1 = { skip: true, adblock: true }; document.cookie = 'molyast21=1; path=/; domain=.vidmoly.to'; const patch = document.createElement('script'); patch.innerHTML = ` (() => { const og = window.jwplayer; Object.defineProperty(window, 'jwplayer', { configurable: true, get: () => function(id) { const p = og(id); const s = p.setup; p.setup = function(cfg) { if (cfg.advertising) cfg.advertising = {}; return s.call(this, cfg); }; return p; } }); })(); `; document.body.appendChild(patch); } function showLoader(type) { const old = document.querySelector('#loadingMessage'); if (old) old.remove(); const msg = document.createElement('div'); msg.id = 'loadingMessage'; msg.innerText = `${i18n.loading} ${type}...`; Object.assign(msg.style, { background: '#111', color: '#fff', fontFamily: 'sans-serif', padding: '20px', textAlign: 'center' }); container.innerHTML = ''; container.appendChild(msg); } function clearLoader() { const l = document.querySelector('#loadingMessage'); if (l) l.remove(); } function buildIframe(src, type) { const iframe = document.createElement('iframe'); iframe.src = src; iframe.allowFullscreen = true; iframe.frameBorder = '0'; iframe.width = '100%'; iframe.height = heightMap[type] || '500px'; Object.assign(iframe.style, { display: 'block', border: 'none', position: 'relative', margin: '0 auto' }); return iframe; } function injectJWplayer(iframe) { try { const win = iframe.contentWindow; const tryInject = setInterval(() => { try { const player = win?.jwplayer?.(); if (player && typeof player.play === 'function') { player.play(); clearInterval(tryInject); log('JWPlayer play() called inside iframe'); } } catch {} }, 500); } catch (err) { log('JW inject failed:', err); } } function embedVidmoly() { if (!vidmolyReady || !vidmolyIframe || !vidmolyUrl) { alert(i18n.vidmolyNotReady); return; } container.innerHTML = ''; const realIframe = buildIframe(vidmolyUrl, 'Vidmoly'); container.appendChild(realIframe); injectJWplayer(realIframe); } function embedGeneric(url, type, attempt = 1) { showLoader(type); const iframe = buildIframe(url, type); container.innerHTML = ''; container.appendChild(iframe); const timeout = setTimeout(() => { if (!iframe.dataset.loaded && attempt < 2) { log(`Retrying ${type}...`); return setTimeout(() => embedGeneric(url, type, attempt + 1), 1000); } else if (!iframe.dataset.loaded) { clearLoader(); window.open(url, '_blank'); } }, 8000); iframe.onload = () => { clearTimeout(timeout); iframe.dataset.loaded = 'true'; clearLoader(); log(`${type} loaded`); }; } async function preloadVidmoly(url) { spoofVidmolyEnv(); vidmolyIframe = document.createElement('iframe'); vidmolyIframe.src = url; vidmolyIframe.allowFullscreen = true; vidmolyIframe.frameBorder = '0'; vidmolyIframe.width = '100%'; vidmolyIframe.height = heightMap.Vidmoly; vidmolyIframe.style.cssText = 'position:absolute;width:1px;height:1px;left:-9999px;top:-9999px;'; vidmolyIframe.onload = () => { vidmolyReady = true; log('Vidmoly iframe preloaded.'); }; document.body.appendChild(vidmolyIframe); } async function detectVidmoly() { spoofVidmolyEnv(); const anchor = [...document.querySelectorAll('a.watchEpisode')].find(a => { return a.querySelector('i.icon.Vidmoly'); }); const href = anchor?.getAttribute('href'); if (!href) return; const url = new URL(href, location.origin); vidmolyUrl = url.href; await preloadVidmoly(vidmolyUrl); } advancedSettings[ADVANCED_SETTINGS_MAP.preloadOtherProviders] && document.addEventListener('click', async function(e) { const anchor = e.target.closest('a.watchEpisode'); if (!anchor) return; const text = anchor.innerText.toLowerCase(); const isVid = text.includes('vidmoly'); const isLulu = text.includes('luluvdo'); const isMoon = text.includes('filemoon'); if (!isVid && !isLulu && !isMoon) return; e.preventDefault(); const href = anchor.getAttribute('href'); const type = isVid ? 'Vidmoly' : isLulu ? 'Luluvdo' : 'Filemoon'; const fullUrl = new URL(href, location.origin).href; try { if (type === 'Filemoon' && fullUrl.includes('/d/')) { return embedGeneric(fullUrl.replace('/d/', '/e/'), type); } if (type === 'Vidmoly') { vidmolyUrl = fullUrl; return embedVidmoly(); } return embedGeneric(fullUrl, type); } catch (err) { console.warn('Failed to load:', err); alert(`${i18n.couldNotLoad} ${type}`); } }); function waitForElement(selector, opts = {}, cb) { const { interval = 50, timeout = 10000 } = opts; const start = Date.now(); const timer = setInterval(() => { const el = document.querySelector(selector); if (el) { clearInterval(timer); cb(el); } else if (Date.now() - start > timeout) { clearInterval(timer); console.warn(`[SmartLoader] Timed out waiting for ${selector}`); } }, interval); } // wait until .watchEpisode buttons are loaded advancedSettings[ADVANCED_SETTINGS_MAP.preloadOtherProviders] && waitForElement('a.watchEpisode i.icon.Vidmoly', { timeout: 10000 }, () => { log('Vidmoly <a> tag detected, calling detectVidmoly()'); detectVidmoly(); }); // run after .inSiteWebStream is ready! async function checkIframeForLoadXWarning() { const iframe = document.querySelector('.inSiteWebStream iframe'); if (!iframe || !iframe.src) { setTimeout(checkIframeForLoadXWarning, 1000); return; } const proxyUrl = `https://aniworld-to-cors-proxy.fly.dev/${iframe.src.replace(/^\/+/, '')}`; try { const response = await fetch(proxyUrl); const html = await response.text(); const hasWarning = html.includes('<h1>Warning</h1>') && html.includes('The video is not ready yet.'); const has404 = html.includes('<h1>404</h1>') || html.toLowerCase().includes('no video found'); if (!(hasWarning || has404)) return; let providerOrder = ['0', '1', '2', '3', '4']; try { const raw = await GM.getValue('coreSettings'); if (raw) { const parsed = JSON.parse(raw); const dynamicOrder = parsed?.providersPriority; if (Array.isArray(dynamicOrder) && dynamicOrder.length > 0) { providerOrder = dynamicOrder; } } } catch {} const loadXIndex = providerOrder.indexOf('0'); if (loadXIndex === -1) return; for (let i = loadXIndex + 1; i < providerOrder.length; i++) { const providerId = providerOrder[i]; const providerName = getHosterName(providerId); const button = [...document.querySelectorAll('a.watchEpisode')] .find(a => a.href.includes('/redirect/') && a.innerText.includes(providerName)) ?.querySelector('.hosterSiteVideoButton'); if (button) { button.click(); await new Promise(resolve => setTimeout(resolve, 3000)); const iframe = document.querySelector('.inSiteWebStream iframe'); if (!iframe || !iframe.src) continue; const proxyUrl = `https://aniworld-to-cors-proxy.fly.dev/${iframe.src.replace(/^\/+/, '')}`; const response = await fetch(proxyUrl); const html = await response.text(); const hasWarning = html.includes('<h1>Warning</h1>') && html.includes('The video is not ready yet.'); const has404 = html.includes('<h1>404</h1>') || html.toLowerCase().includes('no video found'); if (!hasWarning && !has404) { return; } } } } catch {} } function getHosterName(id) { const map = { '0': 'LoadX', '1': 'VOE', '2': 'SpeedFiles', '3': 'Vidoza', '4': 'Doodstream' }; return map[id] || 'Unknown'; } setTimeout(checkIframeForLoadXWarning, 150); })(); }); // Prevent volume scroll on player, allow page scroll, but still allow volume control window.addEventListener('wheel', function(e) { const volumeBar = e.target.closest('.vjs-volume-bar'); const volumeIcon = e.target.closest('.vjs-mute-control'); const playerWrapper = e.target.closest('.video-js'); if ((volumeBar || volumeIcon)) return; if (playerWrapper) { e.stopImmediatePropagation(); } }, { passive: false, capture: true }); function detectDoubletap(element, callback, { maxIntervalMs = 300, tapsDistanceThresholdPx = 50, validPointerTypes = ['pen', 'touch'], } = { maxIntervalMs: 300, tapsDistanceThresholdPx: 50, validPointerTypes: ['pen', 'touch'], }) { let lastTapTime = 0; let lastTapX = 0; let lastTapY = 0; let tapped = false; element.addEventListener('pointerdown', (ev) => { if (!validPointerTypes.includes(ev.pointerType)) return; const currentTime = Date.now(); const tapInterval = currentTime - lastTapTime; const distance = Math.sqrt( Math.pow(ev.clientX - lastTapX, 2) + Math.pow(ev.clientY - lastTapY, 2) ); if ( tapped && tapInterval < maxIntervalMs && distance <= tapsDistanceThresholdPx ) { callback(ev); tapped = false; lastTapTime = 0; lastTapX = 0; lastTapY = 0; } else { tapped = true; lastTapTime = currentTime; lastTapX = ev.clientX; lastTapY = ev.clientY; } }); } function detectHold(element, callback, { holdTimeMs = 700, validPointerTypes = ['mouse', 'pen', 'touch'], } = { holdTimeMs: 700, validPointerTypes: ['mouse', 'pen', 'touch'], }) { let timer; const clearHold = () => clearTimeout(timer); const startHold = (ev) => { if (validPointerTypes.includes(ev.pointerType)) { timer = setTimeout(() => callback(), holdTimeMs); } }; element.addEventListener('pointerdown', startHold); element.addEventListener('pointerup', clearHold); element.addEventListener('pointercancel', clearHold); element.addEventListener('pointerout', clearHold); element.addEventListener('pointerleave', clearHold); } function isEmbedded() { try { return window.top !== window.self; } catch { return true; } } function isNumeric(n) { return !isNaN(parseFloat(n)) && isFinite(n); } function makeId(length = 16) { const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; let text = ''; for (let i = 0; i < length; i++) { text += chars.charAt(Math.floor(Math.random() * chars.length)); } return text; } async function sleep(ms = 0) { return new Promise(r => setTimeout(r, ms)); } // Create "Skip intro" button function setupSkipIntroButton(player) { const SKIP_BTN_STYLE = ` .SkipIntroBtn { position: fixed; bottom: 75px; right: 5px; padding: 10px; font-size: 16px; font-weight: bold; font-family: sans-serif; color: white; background-color: rgba(0, 0, 0, 0.55); border: 2px solid gray; text-transform: uppercase; cursor: pointer; opacity: 1; transition: background-color 130ms, opacity 200ms; z-index: 9999; } .SkipIntroBtn:hover { background-color: rgba(0, 0, 0, 1); } .SkipIntroBtn.invisible { opacity: 0; pointer-events: none; } `; const button = document.createElement('button'); button.className = 'SkipIntroBtn'; button.textContent = i18n.skipIntro; button.addEventListener('click', () => { player.currentTime += coreSettings[CORE_SETTINGS_MAP.currentLargeSkipSizeS]; if (advancedSettings[ADVANCED_SETTINGS_MAP.playOnLargeSkip]) { player.play(); } button.remove(); }); GM_addStyle(SKIP_BTN_STYLE); const insertButton = () => { const loadX = document.querySelector('.jw-controlbar'); const speedFiles = document.querySelector('#my-video'); const voe = document.querySelector('.jw-controls'); if (loadX) { loadX.appendChild(button); } else if (speedFiles || voe) { document.body.appendChild(button); } }; const observeActivity = (container) => { new MutationObserver(() => { const isActive = ( container.classList.contains('jw-state-paused') || !container.classList.contains('jw-flag-user-inactive') || container.classList.contains('vjs-paused') || !container.classList.contains('vjs-user-inactive') ); if (!buttonDisabled) { button.classList.toggle('invisible', !isActive || !advancedSettings[ADVANCED_SETTINGS_MAP.showSkipIntroButton]); } }).observe(container, { attributes: true, attributeFilter: ['class'], }); }; waitForElement('.jw-controlbar, #my-video, .jw-controls', { existing: true, onceOnly: true }, insertButton); document.addEventListener('fullscreenchange', () => { const isFullscreen = !!document.fullscreenElement; if (isFullscreen) { button.style.bottom = '80px'; } else { button.style.bottom = '57px'; } }); const activityContainer = ( document.querySelector('#player') || document.querySelector('#my-video') || document.querySelector('#a') ); if (activityContainer) observeActivity(activityContainer); const hideAt = advancedSettings[ADVANCED_SETTINGS_MAP.showSkipIntroButtonSeconds]; const timeCheckInterval = () => { if (player.currentTime >= hideAt) { button.remove(); player.removeEventListener('timeupdate', timeCheckInterval); } }; player.addEventListener('timeupdate', timeCheckInterval); } function waitForElement(query, { callbackOnTimeout = false, existing = false, onceOnly = false, rootElement = document.documentElement, timeout, // "attributes" prop is not supported observerOptions = { childList: true, subtree: true, }, }, callback) { if (!query) throw new Error('Query is needed'); if (!callback) throw new Error('Callback is needed'); const handledElements = new WeakSet(); const existingElements = rootElement.querySelectorAll(query); let timeoutId = null; if (existingElements.length) { // Mark all as handled for a proper work when `existing` is false // to ignore them later on for (const node of existingElements) { handledElements.add(node); } if (existing) { if (onceOnly) { try { callback(existingElements[0]); } catch (e) { console.error(e); } return; } else { for (const node of existingElements) { try { callback(node); } catch (e) { console.error(e); } } } } } const observer = new MutationObserver((mutations, observer) => { for (const node of rootElement.querySelectorAll(query)) { if (handledElements.has(node)) continue; handledElements.add(node); try { callback(node); } catch (e) { console.error(e); } if (onceOnly) { observer.disconnect(); if (timeoutId) clearTimeout(timeoutId); return; } } }); observer.observe(rootElement, { attributes: false, childList: observerOptions.childList || false, subtree: observerOptions.subtree || false, }); if (timeout !== undefined) { timeoutId = setTimeout(() => { observer.disconnect(); if (callbackOnTimeout) { try { callback(null); } catch (e) { console.error(e); } } }, timeout); } return observer; } async function waitForUserInteraction() { return new Promise((resolve) => { const handler = () => { document.removeEventListener('pointerup', handler); document.removeEventListener('keydown', handler); resolve(); }; document.addEventListener('pointerup', handler, { once: true }); document.addEventListener('keydown', handler, { once: true }); }); } // -------------------------------------- utils\ --------------------------------------------- /* CommLink.js - Version: 1.0.1 - Author: Haka - Description: A userscript library for cross-window communication via the userscript storage - GitHub: https://github.com/AugmentedWeb/CommLink */ class CommLinkHandler { constructor(commlinkID, configObj) { this.commlinkID = commlinkID; this.singlePacketResponseWaitTime = configObj?.singlePacketResponseWaitTime || 1500; this.maxSendAttempts = configObj?.maxSendAttempts || 3; this.statusCheckInterval = configObj?.statusCheckInterval || 1; this.silentMode = configObj?.silentMode || false; this.commlinkValueIndicator = 'commlink-packet-'; this.commands = {}; this.listeners = []; const missingGrants = [ 'GM_getValue', 'GM_setValue', 'GM_deleteValue', 'GM_listValues', ].filter(grant => !GM_info.script.grant.includes(grant)); if (missingGrants.length > 0 && !this.silentMode) { alert( `[CommLink] The following userscript grants are missing: ${missingGrants.join(', ')}. CommLink will not work.` ); } this.getStoredPackets() .filter(packet => Date.now() - packet.date > 2e4) .forEach(packet => this.removePacketByID(packet.id)); } setIntervalAsync(callback, interval = this.statusCheckInterval) { let running = true; async function loop() { while (running) { try { await callback(); await new Promise((resolve) => setTimeout(resolve, interval)); } catch { continue; } } }; loop(); return { stop: () => { running = false; return false; } }; } getUniqueID() { return ([1e7] + -1e3 + 4e3 + -8e3 + -1e11) .replace(/[018]/g, c => (c ^ crypto.getRandomValues(new Uint8Array(1))[0] & 15 >> c / 4).toString(16)); } getCommKey(packetID) { return this.commlinkValueIndicator + packetID; } getStoredPackets() { return GM_listValues() .filter(key => key.includes(this.commlinkValueIndicator)) .map(key => GM_getValue(key)); } addPacket(packet) { GM_setValue(this.getCommKey(packet.id), packet); } removePacketByID(packetID) { GM_deleteValue(this.getCommKey(packetID)); } findPacketByID(packetID) { return GM_getValue(this.getCommKey(packetID)); } editPacket(newPacket) { GM_setValue(this.getCommKey(newPacket.id), newPacket); } send(platform, cmd, d) { return new Promise(async resolve => { const packetWaitTimeMs = this.singlePacketResponseWaitTime; const maxAttempts = this.maxSendAttempts; let attempts = 0; for (;;) { attempts++; const packetID = this.getUniqueID(); const attemptStartDate = Date.now(); const packet = { command: cmd, data: d, date: attemptStartDate, id: packetID, sender: platform, }; if (!this.silentMode) { console.log(`[CommLink Sender] Sending packet! (#${attempts} attempt):`, packet); } this.addPacket(packet); for (;;) { const poolPacket = this.findPacketByID(packetID); const packetResult = poolPacket?.result; if (poolPacket && packetResult) { if (!this.silentMode) { console.log(`[CommLink Sender] Got result for a packet (${packetID}):`, packetResult); } resolve(poolPacket.result); attempts = maxAttempts; // stop main loop break; } if (!poolPacket || Date.now() - attemptStartDate > packetWaitTimeMs) { break; } await new Promise(res => setTimeout(res, this.statusCheckInterval)); } this.removePacketByID(packetID); if (attempts === maxAttempts) break; } return resolve(null); }); } registerSendCommand(name, obj) { this.commands[name] = async (data) => { return await this.send(obj?.commlinkID || this.commlinkID, name, obj?.data || data); }; } registerListener(sender, commandHandler) { const listener = { sender, commandHandler, intervalObj: this.setIntervalAsync(this.receivePackets.bind(this), this.statusCheckInterval), }; this.listeners.push(listener); } receivePackets() { this.getStoredPackets().forEach(packet => { this.listeners.forEach(listener => { if (packet.sender === listener.sender && !packet.hasOwnProperty('result')) { const result = listener.commandHandler(packet); packet.result = result; this.editPacket(packet); if (!this.silentMode) { if (packet.result === null) { console.log('[CommLink Receiver] Possibly failed to handle packet:', packet); } else { console.log('[CommLink Receiver] Successfully handled a packet:', packet); } } } }); }); } kill() { this.listeners.forEach(listener => listener.intervalObj.stop()); } } class IframeMessenger { constructor() { this.commLink = null; this.topScopeId = null; } static get messages() { return { AUTOPLAY_NEXT: 'AUTOPLAY_NEXT', REQUEST_CURRENT_FRANCHISE_DATA: 'REQUEST_CURRENT_FRANCHISE_DATA', REQUEST_FULLSCREEN_STATE: 'REQUEST_FULLSCREEN_STATE', MARK_CURRENT_VIDEO_WATCHED: 'MARK_CURRENT_VIDEO_WATCHED', OPEN_HOTKEYS_GUIDE: 'OPEN_HOTKEYS_GUIDE', TOGGLE_FULLSCREEN: 'TOGGLE_FULLSCREEN', TOP_NOTIFLIX_REPORT_INFO: 'TOP_NOTIFLIX_REPORT_INFO', UPDATE_CORE_SETTINGS: 'UPDATE_CORE_SETTINGS', }; } async initCrossFrameConnection() { const iframeId = makeId(); const topScopeIdPromise = new Promise((resolve) => { // Top scope using GM_setValue will write its own id using iframeId as a key const valueChangeListenerId = GM_addValueChangeListener(iframeId, ( _key, _oldValue, newValue, ) => { GM_removeValueChangeListener(valueChangeListenerId); GM_deleteValue(iframeId); resolve(newValue); }); }); // This should be almost immediately picked up by a top scope GM_setValue('unboundIframeId', iframeId); const topScopeId = await topScopeIdPromise; if (!iframeId || !topScopeId) throw new Error('Something went wrong'); this.topScopeId = topScopeId; this.commLink = new CommLinkHandler(iframeId, { silentMode: true, statusCheckInterval: advancedSettings[ADVANCED_SETTINGS_MAP.commlinkPollingIntervalMs], }); this.commLink.registerSendCommand(IframeMessenger.messages.AUTOPLAY_NEXT); this.commLink.registerSendCommand(IframeMessenger.messages.REQUEST_CURRENT_FRANCHISE_DATA); this.commLink.registerSendCommand(IframeMessenger.messages.REQUEST_FULLSCREEN_STATE); this.commLink.registerSendCommand(IframeMessenger.messages.MARK_CURRENT_VIDEO_WATCHED); this.commLink.registerSendCommand(IframeMessenger.messages.OPEN_HOTKEYS_GUIDE); this.commLink.registerSendCommand(IframeMessenger.messages.TOGGLE_FULLSCREEN); this.commLink.registerSendCommand(IframeMessenger.messages.TOP_NOTIFLIX_REPORT_INFO); this.commLink.registerSendCommand(IframeMessenger.messages.UPDATE_CORE_SETTINGS); } registerConnectionListener(callback) { return this.commLink.registerListener(this.topScopeId, callback); } sendMessage(message, msgData) { this.commLink.commands[message](msgData); return; } } class IframeInterface { constructor(messenger) { this.commLink = null; this.currentFranchiseId = null; this.currentVideoId = null; this.ignoreMissingFranchiseOnce = true; this.isInFullscreen = null; this.messenger = messenger; this.topScopeDomainId = ''; coreSettings[CORE_SETTINGS_MAP.currentLargeSkipSizeS] = ( advancedSettings[ADVANCED_SETTINGS_MAP.defaultLargeSkipSizeS] ); coreSettings[CORE_SETTINGS_MAP.currentOutroSkipThresholdS] = ( advancedSettings[ADVANCED_SETTINGS_MAP.defaultOutroSkipThresholdS] ); } static get franchiseSpecificDataGMPrefix() { return 'franchiseSpecificData_'; } static makePlaybackPositionGMKey(topScopeDomainId, episodeId) { if (!topScopeDomainId || !episodeId) throw new Error('Something is missing'); return `playbackTimestamp_${topScopeDomainId}_${episodeId}`; } // It is better not to be async handleTopScopeMessages(packet) { (async function() { try { switch (packet.command) { case TopScopeInterface.messages.CURRENT_FRANCHISE_DATA: { // At least one value is going to be present this.currentVideoId = packet.data.currentVideoId || null; this.topScopeDomainId = packet.data.topScopeDomainId || ''; if (packet.data.currentFranchiseId) { this.currentFranchiseId = packet.data.currentFranchiseId; const { largeSkipSizeS, outroSkipThresholdS } = GM_getValue( `${IframeInterface.franchiseSpecificDataGMPrefix}${this.currentFranchiseId}` ) || {}; if (isNumeric(largeSkipSizeS)) { coreSettings[CORE_SETTINGS_MAP.currentLargeSkipSizeS] = largeSkipSizeS; } else { coreSettings[CORE_SETTINGS_MAP.currentLargeSkipSizeS] = ( advancedSettings[ADVANCED_SETTINGS_MAP.defaultLargeSkipSizeS] ); } if (isNumeric(outroSkipThresholdS)) { coreSettings[CORE_SETTINGS_MAP.currentOutroSkipThresholdS] = outroSkipThresholdS; } else { coreSettings[CORE_SETTINGS_MAP.currentOutroSkipThresholdS] = ( advancedSettings[ADVANCED_SETTINGS_MAP.defaultOutroSkipThresholdS] ); } this.settingsPane?.refresh(); this.ignoreMissingFranchiseOnce = false; } break; } case TopScopeInterface.messages.FULLSCREEN_STATE: { if (IS_SAFARI) break; this.isInFullscreen = packet.data.isInFullscreen; this.updateFullscreenBtn({ isInFullscreen: this.isInFullscreen }); break; } default: break; } } catch (e) { console.error(e); } }.bind(this)()); return { status: `${this.constructor.name} received a message`, }; } async init(player) { this.messenger.registerConnectionListener(this.handleTopScopeMessages.bind(this)); this.messenger.sendMessage(IframeMessenger.messages.REQUEST_CURRENT_FRANCHISE_DATA); await this.preparePlayer(player); } createAutoplayButton() { const button = document.createElement('button'); const toggleContainer = document.createElement('div'); const toggleDot = document.createElement('div'); const isAutoplayEnabled = coreSettings[CORE_SETTINGS_MAP.isAutoplayEnabled]; let lastClickTime = 0; button.addEventListener('click', () => { const now = Date.now(); // Prevent double-clicks unwanted behavior if (now - lastClickTime < 300) return; lastClickTime = now; if (!GM_getValue('firstRunTextWasShown')) { GM_setValue('firstRunTextWasShown', true); this.messenger.sendMessage(IframeMessenger.messages.TOP_NOTIFLIX_REPORT_INFO, { args: [ i18n.firstRunInfoTitle, i18n.firstRunInfoText(IS_MOBILE, hotkeysSettings[HOTKEYS_SETTINGS_MAP.largeSkip]), i18n.ok, { delayedButton: true, }, ], }); } const wasEnabled = coreSettings[CORE_SETTINGS_MAP.isAutoplayEnabled]; coreSettings[CORE_SETTINGS_MAP.isAutoplayEnabled] = !wasEnabled; button.setAttribute('aria-checked', (!wasEnabled).toString()); button.title = ( !isAutoplayEnabled ? i18n.autoplayDisabled : i18n.autoplayEnabled ); toggleDot.style.backgroundColor = wasEnabled ? '#e1e1e1' : '#fff'; toggleDot.style.transform = wasEnabled ? 'translateX(0px)' : 'translateX(12px)'; }); button.type = 'button'; button.title = ( !isAutoplayEnabled ? i18n.autoplayDisabled : i18n.autoplayEnabled ); button.appendChild(toggleContainer); button.setAttribute('aria-checked', (isAutoplayEnabled).toString()); button.className = 'Autoplay-button'; toggleContainer.className = 'Autoplay-button--toggle'; toggleContainer.appendChild(toggleDot); toggleDot.className = 'Autoplay-button--toggle-dot'; toggleDot.style.backgroundColor = !isAutoplayEnabled ? '#e1e1e1' : '#fff'; toggleDot.style.transform = ( !isAutoplayEnabled ? 'translateX(0px)' : 'translateX(12px)' ); GM_addStyle([` .Autoplay-button { width: 36px; height: 36px; padding: 0; border-radius: 50%; border: none; background: none; cursor: pointer; top: 0; left: 0; transition: all 0.2s ease; user-select: none; -webkit-user-select: none; } .Autoplay-button[aria-checked="true"] .Autoplay-button--toggle-dot { transform: translateX(12px); } .Autoplay-button--toggle { width: 24px; height: 12px; margin-bottom: 3px; background-color: rgba(221, 221, 221, 0.5); border-radius: 6px; position: relative; display: inline-block; } .Autoplay-button--toggle-dot { width: 12px; height: 12px; background-color: #e1e1e1; border-radius: 50%; position: absolute; top: 0; left: 0; transition: all 0.2s ease; } `][0]); return button; } createSettingsPane() { const pane = new Tweakpane.Pane(); pane.hidden = true; pane.on('change', () => { this.messenger.sendMessage(IframeMessenger.messages.UPDATE_CORE_SETTINGS); }); document.body.addEventListener('click', (ev) => { if (!ev.target.closest('div.tp-dfwv')) pane.hidden = true; }); GM_addStyle([ // Main container ` .tp-dfwv { --tp-font-family: sans-serif; width: 400px; max-width: 100%; top: 0; right: 0; z-index: 99999; } `, // A container one level below the main one ` .tp-rotv { max-height: 85vh; font-size: 12px; overflow-y: scroll; scrollbar-width: thin; scrollbar-color: #6b6c73 #37383d; } `, // Any text input ` .tp-txtv_i, .tp-sglv_i { font-size: 14px !important; padding: 0 8px !important; color: var(--in-fg) !important; background-color: var(--in-bg) !important; opacity: 1 !important; } `, // Checkboxes ` .tp-ckbv_w { width: 80%; margin: auto; } `, ].join(' ')); // Stop leaking events to the player (['keydown', 'keyup', 'keypress'].forEach(event => pane.element.addEventListener(event, (e) => e.stopPropagation()) )); const assignTooltip = (text, object) => { object.element.title = text; if ( object.element.firstElementChild.matches && object.element.firstElementChild.matches('div.tp-lblv_l') ) { object.element.firstElementChild.addEventListener('click', (ev) => { if (!['pen', 'touch'].includes(ev.pointerType)) return; this.messenger.sendMessage(IframeMessenger.messages.TOP_NOTIFLIX_REPORT_INFO, { args: [object.element.firstElementChild.innerText, text, i18n.close, { backOverlayClickToClose: true, }], }); }); } }; const tabs = pane.addTab({ pages: [{ title: i18n.preferences }, { title: i18n.advanced }, ], }); const mainTab = tabs.pages[0]; const advancedTab = tabs.pages[1]; const mainTabApplyBtn = mainTab.addButton({ disabled: true, title: i18n.apply, }); const advancedTabApplyBtn = advancedTab.addButton({ disabled: true, title: i18n.apply, }); for (const btn of [mainTabApplyBtn, advancedTabApplyBtn]) { btn.on('click', () => { setTimeout(() => { mainTabApplyBtn.disabled = true; advancedTabApplyBtn.disabled = true; }); }); } pane.element.addEventListener('click', () => { mainTabApplyBtn.disabled = false; advancedTabApplyBtn.disabled = false; }); const priorityFolder = mainTab.addFolder({ title: i18n.providersPriority }); (() => { const ids = coreSettings[CORE_SETTINGS_MAP.providersPriority]; const buttons = []; ids.forEach((id, index) => { const button = priorityFolder.addButton({ title: `⬆ ${index + 1}) ${VIDEO_PROVIDERS_IDS[id]}`, }); button.on('click', () => { if (index > 0) { [ids[index], ids[index - 1]] = [ids[index - 1], ids[index]]; coreSettings[CORE_SETTINGS_MAP.providersPriority] = ids; ids.forEach((id, index) => { buttons[index].title = `⬆ ${index + 1}) ${VIDEO_PROVIDERS_IDS[id]}`; }); this.messenger.sendMessage(IframeMessenger.messages.UPDATE_CORE_SETTINGS); } }); buttons.push(button); }); })(); const miscellaneousMainFolder = mainTab.addFolder({ title: i18n.miscellaneous }); miscellaneousMainFolder.on('change', (ev) => { if (!ev.last) return; if ( typeof ev.value === 'string' && MAIN_SETTINGS_MAP[ev.presetKey] ) { mainSettings[ev.presetKey] = mainSettings[ev.presetKey].trim(); ev.target.refresh(); } }); assignTooltip(i18n.persistentMutedAutoplayTooltip, miscellaneousMainFolder.addInput(mainSettings, MAIN_SETTINGS_MAP.shouldAutoplayMuted, { label: i18n.persistentMutedAutoplay, }, )); assignTooltip(i18n.autoSkipAtStartTooltip, miscellaneousMainFolder.addInput(coreSettings, CORE_SETTINGS_MAP.shouldAutoSkipOnStart, { label: i18n.autoSkipAtStart, }, )); assignTooltip(i18n.playbackPositionMemoryTooltip, miscellaneousMainFolder.addInput(mainSettings, MAIN_SETTINGS_MAP.playbackPositionMemory, { label: i18n.playbackPositionMemory, }, )); assignTooltip(i18n.skipSecondsOnStartTooltip, miscellaneousMainFolder.addInput(coreSettings, CORE_SETTINGS_MAP.autoSkipSecondsOnStart, { step: 1, min: 0, label: i18n.skipSecondsOnStart, }, )); if (IS_MOBILE || advancedSettings[ADVANCED_SETTINGS_MAP.showDeviceSpecificSettings]) { assignTooltip(i18n.overrideDoubletapBehaviorTooltip, miscellaneousMainFolder.addInput(mainSettings, MAIN_SETTINGS_MAP.overrideDoubletapBehavior, { label: i18n.overrideDoubletapBehavior, }, )); } (() => { for (const { settingKey, errName, inputOptions, tooltip, } of [{ settingKey: CORE_SETTINGS_MAP.currentLargeSkipSizeS, errName: 'Intro skip size', inputOptions: { step: 1, min: 0, label: i18n.introSkipSize, }, tooltip: i18n.introSkipSizeTooltip, }, { settingKey: CORE_SETTINGS_MAP.currentOutroSkipThresholdS, errName: 'Outro skip threshold', inputOptions: { step: 1, min: 0.5, label: i18n.outroSkipThreshold, }, tooltip: i18n.outroSkipThresholdTooltip, }, ]) { const input = ( miscellaneousMainFolder.addInput(coreSettings, settingKey, inputOptions) ); assignTooltip((tooltip), input); input.on('change', (ev) => { if (!ev.last) return; if (!this.currentFranchiseId) { // This is needed because 'change' event is being triggered by pane.refresh() // that is called from CURRENT_FRANCHISE_DATA message handler if (this.ignoreMissingFranchiseOnce) { this.ignoreMissingFranchiseOnce = false; return; } Notiflixx.notify.failure( `${GM_info.script.name}: ${i18n.errorSaving} "${errName}"${i18n.reportBug}` ); return; } GM_setValue(( `${IframeInterface.franchiseSpecificDataGMPrefix}${this.currentFranchiseId}` ), { largeSkipSizeS: coreSettings[CORE_SETTINGS_MAP.currentLargeSkipSizeS], outroSkipThresholdS: coreSettings[CORE_SETTINGS_MAP.currentOutroSkipThresholdS], }); }); } })(); miscellaneousMainFolder.addButton({ title: i18n.resetToDefaults, }).on('click', () => { mainSettings.update(MAIN_SETTINGS_DEFAULTS); coreSettings[CORE_SETTINGS_MAP.currentLargeSkipSizeS] = ( advancedSettings[ADVANCED_SETTINGS_MAP.defaultLargeSkipSizeS] ); coreSettings[CORE_SETTINGS_MAP.currentOutroSkipThresholdS] = ( advancedSettings[ADVANCED_SETTINGS_MAP.defaultOutroSkipThresholdS] ); this.currentFranchiseId && GM_deleteValue( `${IframeInterface.franchiseSpecificDataGMPrefix}${this.currentFranchiseId}` ); pane.refresh(); }); if (!IS_MOBILE || advancedSettings[ADVANCED_SETTINGS_MAP.showDeviceSpecificSettings]) { const hotkeysFolder = advancedTab.addFolder({ title: i18n.hotkeys, expanded: !IS_MOBILE }); hotkeysFolder.on('change', (ev) => { if (!ev.last) return; if ( typeof ev.value === 'string' && HOTKEYS_SETTINGS_MAP[ev.presetKey] ) { hotkeysSettings[ev.presetKey] = hotkeysSettings[ev.presetKey].trim().toLowerCase(); ev.target.refresh(); } }); assignTooltip(i18n.fastBackwardTooltip, hotkeysFolder.addInput(hotkeysSettings, HOTKEYS_SETTINGS_MAP.fastBackward, { label: i18n.fastBackward, }, )); assignTooltip(i18n.fastForwardTooltip, hotkeysFolder.addInput(hotkeysSettings, HOTKEYS_SETTINGS_MAP.fastForward, { label: i18n.fastForward, }, )); assignTooltip(i18n.fullscreenTooltip, hotkeysFolder.addInput(hotkeysSettings, HOTKEYS_SETTINGS_MAP.fullscreen, { label: i18n.fullscreen, }, )); assignTooltip(i18n.largeSkipTooltip, hotkeysFolder.addInput(hotkeysSettings, HOTKEYS_SETTINGS_MAP.largeSkip, { label: i18n.largeSkip, }, )); const hotkeysGuideBtn = hotkeysFolder.addButton({ title: i18n.hotkeysGuide }); hotkeysGuideBtn.on('click', () => { this.messenger.sendMessage(IframeMessenger.messages.OPEN_HOTKEYS_GUIDE); }); hotkeysFolder.addButton({ title: i18n.resetToDefaults, }).on('click', () => { hotkeysSettings.update(HOTKEYS_SETTINGS_DEFAULTS); pane.refresh(); }); } const miscellaneousAdvancedFolder = advancedTab.addFolder({ title: i18n.miscellaneous }); miscellaneousAdvancedFolder.on('change', (ev) => { if (!ev.last) return; if ( typeof ev.value === 'string' && ADVANCED_SETTINGS_MAP[ev.presetKey] ) { advancedSettings[ev.presetKey] = advancedSettings[ev.presetKey].trim(); ev.target.refresh(); } }); assignTooltip(i18n.defaultIntroSkipSizeTooltip, miscellaneousAdvancedFolder.addInput(advancedSettings, ADVANCED_SETTINGS_MAP.defaultLargeSkipSizeS, { step: 1, min: 0, label: i18n.defaultIntroSkipSize, }, )); assignTooltip(i18n.defaultOutroSkipThresholdTooltip, miscellaneousAdvancedFolder.addInput(advancedSettings, ADVANCED_SETTINGS_MAP.defaultOutroSkipThresholdS, { step: 1, min: 0.5, label: i18n.defaultOutroSkipThreshold, }, )); assignTooltip(i18n.markWatchedAfterTooltip, miscellaneousAdvancedFolder.addInput(advancedSettings, ADVANCED_SETTINGS_MAP.markWatchedAfterS, { step: 1, min: 0, label: i18n.markWatchedAfter, }, )); assignTooltip(i18n.fastForwardSizeTooltip, miscellaneousAdvancedFolder.addInput(advancedSettings, ADVANCED_SETTINGS_MAP.fastForwardSizeS, { step: 1, min: 0, label: i18n.fastForwardSize, }, )); const skipIntroToggleInput = miscellaneousAdvancedFolder.addInput( advancedSettings, ADVANCED_SETTINGS_MAP.showSkipIntroButton, { label: i18n.showSkipIntroButton } ); assignTooltip( i18n.showSkipIntroButtonTooltip, skipIntroToggleInput ); // Realtime visibility update skipIntroToggleInput.on('change', ev => { const skipBtn = document.querySelector('.SkipIntroBtn'); if (ev.value) { if (!skipBtn) { const player = document.querySelector('video'); if (player) setupSkipIntroButton(player); } else { skipBtn.classList.remove('invisible'); } } else { if (skipBtn) skipBtn.classList.add('invisible'); } }); assignTooltip( i18n.showSkipIntroButtonSecondsTooltip, miscellaneousAdvancedFolder.addInput(advancedSettings, ADVANCED_SETTINGS_MAP.showSkipIntroButtonSeconds, { label: i18n.showSkipIntroButtonSeconds, step: 1, min: 5, max: 600, } ) ); assignTooltip(i18n.preloadOtherProvidersTooltip, miscellaneousAdvancedFolder.addInput(advancedSettings, ADVANCED_SETTINGS_MAP.preloadOtherProviders, { label: i18n.preloadOtherProviders, }, )); assignTooltip(i18n.playOnIntroSkipTooltip, miscellaneousAdvancedFolder.addInput(advancedSettings, ADVANCED_SETTINGS_MAP.playOnLargeSkip, { label: i18n.playOnIntroSkip, }, )); assignTooltip(i18n.showDeviceSpecificSettingsTooltip, miscellaneousAdvancedFolder.addInput(advancedSettings, ADVANCED_SETTINGS_MAP.showDeviceSpecificSettings, { label: i18n.showDeviceSpecificSettings, }, )); if (IS_MOBILE || advancedSettings[ADVANCED_SETTINGS_MAP.showDeviceSpecificSettings]) { assignTooltip(i18n.doubleTapTimingThresholdTooltip, miscellaneousAdvancedFolder.addInput(advancedSettings, ADVANCED_SETTINGS_MAP.doubletapTimingThresholdMs, { step: 20, min: 100, max: 1000, label: i18n.doubleTapTimingThreshold, }, )); } if (IS_MOBILE || advancedSettings[ADVANCED_SETTINGS_MAP.showDeviceSpecificSettings]) { assignTooltip(i18n.doubleTapDistanceThresholdTooltip, miscellaneousAdvancedFolder.addInput(advancedSettings, ADVANCED_SETTINGS_MAP.doubletapDistanceThresholdPx, { step: 10, min: 10, max: 5000, label: i18n.doubleTapDistanceThreshold, }, )); } if (!IS_MOBILE || advancedSettings[ADVANCED_SETTINGS_MAP.showDeviceSpecificSettings]) { assignTooltip(i18n.introSkipCooldownTooltip, miscellaneousAdvancedFolder.addInput(advancedSettings, ADVANCED_SETTINGS_MAP.largeSkipCooldownMs, { step: 1, min: 0, label: i18n.introSkipCooldown, }, )); } assignTooltip(i18n.playbackPositionExpirationTooltip, miscellaneousAdvancedFolder.addInput(advancedSettings, ADVANCED_SETTINGS_MAP.playbackPositionExpirationDays, { step: 1, min: 1, max: 365, label: i18n.playbackPositionExpiration, }, )); assignTooltip(i18n.corsProxyTooltip, miscellaneousAdvancedFolder.addInput(advancedSettings, ADVANCED_SETTINGS_MAP.corsProxy, { label: i18n.corsProxy, }, )); assignTooltip(i18n.commlinkPollingIntervalTooltip, miscellaneousAdvancedFolder.addInput(advancedSettings, ADVANCED_SETTINGS_MAP.commlinkPollingIntervalMs, { step: 10, min: 10, max: 500, label: i18n.commlinkPollingInterval, }, )); miscellaneousAdvancedFolder.addButton({ title: i18n.resetToDefaults, }).on('click', () => { advancedSettings.update(ADVANCED_SETTINGS_DEFAULTS); pane.refresh(); }); return pane; } async handleAutoplay(player) { if (!coreSettings[CORE_SETTINGS_MAP.isAutoplayEnabled]) return; const playTooSlowErr = 'play() was taking too long'; let muteWasApplied = false; // If play fails it tries to fix it but throws the problem error anyway const playOrFix = async () => { try { await Promise.race([ player.play(), // there is a chance this would hang forever new Promise((_, reject) => { setTimeout(() => reject(new Error(playTooSlowErr)), 50); }), ]); } catch (e) { if (e.name === 'NotAllowedError') { // Muted usually is allowed to play, // and if it's not allowed, nothing could be done here if (player.muted) { console.error('Muted and not allowed'); throw e; } if (mainSettings[MAIN_SETTINGS_MAP.shouldAutoplayMuted] && !muteWasApplied) { player.muted = true; muteWasApplied = true; // Restore setting altered by forced mute. // See this.setupPersistentVolume() setTimeout(() => (coreSettings[CORE_SETTINGS_MAP.isMuted] = false)); // Should not be awaited (async () => { await waitForUserInteraction(); // If interaction was unmute button, try to not overtake it // because it might result in mute -> unmute -> mute again. // Different players require a different delay await sleep(100); if (player.muted) player.muted = false; })(); } } throw e; } }; const startTime = Date.now(); let lastError = null; while ((Date.now() - startTime) < (10 * 1000)) { try { await sleep(200); await playOrFix(); return; } catch (e) { lastError = e; } } throw lastError; } setupDoubletapBehavior(player, doubletapTarget = player) { if (!mainSettings[MAIN_SETTINGS_MAP.overrideDoubletapBehavior]) return; detectDoubletap(doubletapTarget, (ev) => { const xViewport = ev.clientX; const rect = ev.target.getBoundingClientRect(); // Get X relative to the target just in case. // It is not really needed since the player takes the whole size of an iframe const xTarget = xViewport - rect.left; if (xTarget < rect.width * 0.35) { if (advancedSettings[ADVANCED_SETTINGS_MAP.fastForwardSizeS]) { player.currentTime -= advancedSettings[ADVANCED_SETTINGS_MAP.fastForwardSizeS]; } } else if (xTarget > rect.width - (rect.width * 0.35)) { if (advancedSettings[ADVANCED_SETTINGS_MAP.fastForwardSizeS]) { player.currentTime += advancedSettings[ADVANCED_SETTINGS_MAP.fastForwardSizeS]; } } else { if (coreSettings[CORE_SETTINGS_MAP.currentLargeSkipSizeS]) { player.currentTime += coreSettings[CORE_SETTINGS_MAP.currentLargeSkipSizeS]; if (advancedSettings[ADVANCED_SETTINGS_MAP.playOnLargeSkip]) { player.play(); } } } }, { maxIntervalMs: advancedSettings[ADVANCED_SETTINGS_MAP.doubletapTimingThresholdMs], tapsDistanceThresholdPx: ( advancedSettings[ADVANCED_SETTINGS_MAP.doubletapDistanceThresholdPx] ), }); } setupHotkeys(player) { keyboardJS.bind('space', () => player.paused ? player.play() : player.pause()); if (hotkeysSettings[HOTKEYS_SETTINGS_MAP.fastForward]) { keyboardJS.bind(hotkeysSettings[HOTKEYS_SETTINGS_MAP.fastForward], () => { if (advancedSettings[ADVANCED_SETTINGS_MAP.fastForwardSizeS]) { player.currentTime += advancedSettings[ADVANCED_SETTINGS_MAP.fastForwardSizeS]; } }); } if (hotkeysSettings[HOTKEYS_SETTINGS_MAP.fastBackward]) { keyboardJS.bind(hotkeysSettings[HOTKEYS_SETTINGS_MAP.fastBackward], () => { if (advancedSettings[ADVANCED_SETTINGS_MAP.fastForwardSizeS]) { player.currentTime -= advancedSettings[ADVANCED_SETTINGS_MAP.fastForwardSizeS]; } }); } if (hotkeysSettings[HOTKEYS_SETTINGS_MAP.fullscreen]) { keyboardJS.bind(hotkeysSettings[HOTKEYS_SETTINGS_MAP.fullscreen], (ev) => { ev.preventRepeat(); this.messenger.sendMessage(IframeMessenger.messages.TOGGLE_FULLSCREEN); }); } if (hotkeysSettings[HOTKEYS_SETTINGS_MAP.largeSkip]) { const cooldownTime = advancedSettings[ADVANCED_SETTINGS_MAP.largeSkipCooldownMs]; let lastSkipTime = 0; keyboardJS.bind(hotkeysSettings[HOTKEYS_SETTINGS_MAP.largeSkip], () => { if (coreSettings[CORE_SETTINGS_MAP.currentLargeSkipSizeS]) { const now = Date.now(); if (now - lastSkipTime < cooldownTime) return; lastSkipTime = now; player.currentTime += coreSettings[CORE_SETTINGS_MAP.currentLargeSkipSizeS]; const skipBtn = document.querySelector('.SkipIntroBtn'); if (skipBtn) { skipBtn.classList.add('invisible'); window.__skipIntroButtonDisabled = true; } if (advancedSettings[ADVANCED_SETTINGS_MAP.playOnLargeSkip]) { player.play(); } } }); } } setupOutroSkipHandling(player) { let outroHasBeenReached = false; setInterval(() => { if (outroHasBeenReached || !coreSettings[CORE_SETTINGS_MAP.isAutoplayEnabled]) return; const timeLeft = player.duration - player.currentTime; if (timeLeft <= coreSettings[CORE_SETTINGS_MAP.currentOutroSkipThresholdS]) { outroHasBeenReached = true; this.messenger.sendMessage(IframeMessenger.messages.AUTOPLAY_NEXT); } }, 250); } setupPersistentVolume(player) { player.muted = coreSettings[CORE_SETTINGS_MAP.isMuted]; player.volume = coreSettings[CORE_SETTINGS_MAP.persistentVolumeLvl]; player.addEventListener('volumechange', () => { coreSettings[CORE_SETTINGS_MAP.isMuted] = player.muted; coreSettings[CORE_SETTINGS_MAP.persistentVolumeLvl] = player.volume; }); } setupWatchedStateLabeling(player) { const intervalMs = 250; let approximatePlayTimeS = 0; let currentVideoWasWatched = false; let lastPlayerTime = player.currentTime; setInterval(() => { if (player.currentTime === lastPlayerTime) return; lastPlayerTime = player.currentTime; approximatePlayTimeS += intervalMs / 1000; if ( !currentVideoWasWatched && advancedSettings[ADVANCED_SETTINGS_MAP.markWatchedAfterS] && approximatePlayTimeS >= advancedSettings[ADVANCED_SETTINGS_MAP.markWatchedAfterS] ) { currentVideoWasWatched = true; this.messenger.sendMessage(IframeMessenger.messages.MARK_CURRENT_VIDEO_WATCHED); } }, intervalMs); } async setupVideoPlaybackPositionMemory(player) { const self = this; await (async function waitForVideoData(start = Date.now()) { if (!self.currentVideoId || !self.topScopeDomainId) { if ((Date.now() - start) > (10 * 1000)) { throw new Error('Video data didn\'t arrive in time'); } await sleep(); return waitForVideoData(start); } }()); // This has to wait indefinitely because players like VOE do not have the value // until the play button has been pressed or an autoplay has been triggered await (async function waitForVideoDuration() { if (!player.duration) { await sleep(); return waitForVideoDuration(); } }()); const timestampDataGMKey = ( IframeInterface.makePlaybackPositionGMKey(this.topScopeDomainId, this.currentVideoId) ); const timestampData = GM_getValue(timestampDataGMKey, {}); if (timestampData.value) { const elapsedTime = Date.now() - timestampData.updateDate; const expirationThreshold = advancedSettings[ ADVANCED_SETTINGS_MAP.playbackPositionExpirationDays ] * 24 * 60 * 60 * 1000; if (elapsedTime < expirationThreshold) { const outroSkipThresholdS = coreSettings[CORE_SETTINGS_MAP.currentOutroSkipThresholdS]; const potentialTimeLeftToPlay = player.duration - timestampData.value; // Skip saved playback position if it's in a range of (outroSkipThresholdS + 20) if (potentialTimeLeftToPlay > (outroSkipThresholdS + 20)) { player.currentTime = timestampData.value; } } } let lastCheckedTime = player.currentTime; setInterval(() => { if ( !mainSettings[MAIN_SETTINGS_MAP.playbackPositionMemory] || (player.currentTime === lastCheckedTime) ) return; lastCheckedTime = player.currentTime; GM_setValue(timestampDataGMKey, { value: lastCheckedTime, updateDate: Date.now(), }); }, 1000); } } class DoodstreamIframeInterface extends IframeInterface { constructor(messenger) { super(messenger); const unwantedElements = [ '#checkresume_div', // prompt to restore built in playback position memory 'div[style*="z-index: 2147483647"]', // ads ]; // Remove on-screen controls to avoid double-tap conflicts if (IS_MOBILE && mainSettings[MAIN_SETTINGS_MAP.overrideDoubletapBehavior]) { unwantedElements.push('button.vjs-seek-button'); } waitForElement(unwantedElements.join(', '), { existing: true, }, (el) => el.remove()); (function() { const originalAddEventListener = EventTarget.prototype.addEventListener; EventTarget.prototype.addEventListener = function(type, listener, options) { // Get rid of ads if ( ['click', 'mousedown', 'mouseup', 'contextmenu'].includes(type) && (this === document || this === unsafeWindow) ) { return; } return originalAddEventListener.call(this, type, listener, options); }; }()); } static get queries() { return { fullscreenBtn: 'button.vjs-fullscreen-control', player: 'video#video_player_html5_api', }; } async preparePlayer(player) { this.setupDoubletapBehavior(player); this.setupHotkeys(player); if (advancedSettings[ADVANCED_SETTINGS_MAP.showSkipIntroButton]) { setupSkipIntroButton(player); } this.setupOutroSkipHandling(player); this.setupWatchedStateLabeling(player); this.setupVideoPlaybackPositionMemory(player); this.restylePlayer(player); let hasSkippedInitial = false; player.addEventListener('timeupdate', function skipFirst30s() { if (!hasSkippedInitial && player.currentTime < 30) { player.currentTime = 30; hasSkippedInitial = true; } }); this.setupPersistentVolume(player); this.handleAutoplay(player); // should go after setupPersistentVolume // Attach autoplay button and change fullscreen button behavior... waitForElement(DoodstreamIframeInterface.queries.fullscreenBtn, { existing: true, onceOnly: true, }, (fsBtn) => { // Prevent focused buttons from being toggled by pressing space/enter fsBtn.parentElement.addEventListener('keydown', (ev) => ev.preventDefault()); fsBtn.parentElement.addEventListener('keyup', (ev) => ev.preventDefault()); const newFsBtn = fsBtn.cloneNode(true); const autoplayBtn = this.createAutoplayButton(); const settingsPane = this.settingsPane = this.createSettingsPane(); autoplayBtn.style.width = 'auto'; autoplayBtn.style.height = 'auto'; autoplayBtn.style.padding = '20px 13px 20px 20px'; fsBtn.before(autoplayBtn); IS_SAFARI ? fsBtn.remove() : fsBtn.replaceWith(newFsBtn); const toggleSettingsPane = (ev) => { ev?.preventDefault(); ev?.stopImmediatePropagation(); settingsPane.hidden = !settingsPane.hidden; return false; }; if (IS_MOBILE) { autoplayBtn.oncontextmenu = () => false; detectHold(autoplayBtn, toggleSettingsPane); } else { autoplayBtn.oncontextmenu = toggleSettingsPane; } if (IS_SAFARI === false) { newFsBtn.addEventListener('click', () => { this.messenger.sendMessage(IframeMessenger.messages.TOGGLE_FULLSCREEN); }); this.messenger.sendMessage(IframeMessenger.messages.REQUEST_FULLSCREEN_STATE); } }); } restylePlayer() { const newStyles = [ ` div.vjs-volume-panel { margin-right: auto !important; } div.vjs-brand-container { display: none; } div.video-js .vjs-remaining-time { padding-right: 4px; } div.video-js .vjs-control-bar .vjs-progress-control { left: 12px; right: 78px; } `, ]; GM_addStyle(newStyles.join(' ')); } updateFullscreenBtn({ isInFullscreen }) { const player = document.querySelector(DoodstreamIframeInterface.queries.player); if (isInFullscreen) { player.parentElement.classList.add('vjs-fullscreen'); } else { player.parentElement.classList.remove('vjs-fullscreen'); } } } class LoadXIframeInterface extends IframeInterface { constructor(messenger) { super(messenger); // Remove on-screen controls to avoid double-tap conflicts if (mainSettings[MAIN_SETTINGS_MAP.overrideDoubletapBehavior]) { waitForElement([ 'div[class*=display-icon-rewind], div[class*=display-icon-next]', ].join(', '), { existing: true, }, (controls) => controls.remove()); } (function() { const originalAddEventListener = EventTarget.prototype.addEventListener; EventTarget.prototype.addEventListener = function(type, listener, options) { if ( // Get rid of ads (['click', 'mousedown'].includes(type) && this === document) || // Intercept original hotkeys to avoid conflicts with the script hotkeys (type === 'keydown' && this.matches && this.matches('div#player')) ) { return; } // Intercept double-tap to fullscreen handler if ( IS_MOBILE && mainSettings[MAIN_SETTINGS_MAP.overrideDoubletapBehavior] && (type === 'click' && this.matches && this.matches('div#player > div > div.jw-media')) ) { let timerId = null; return originalAddEventListener.call(this, type, () => { clearTimeout(timerId); const playerContainer = document.querySelector('div#player'); if (playerContainer.classList.contains('jw-flag-user-inactive')) { playerContainer.classList.remove('jw-flag-user-inactive'); timerId = setTimeout(() => { playerContainer.classList.add('jw-flag-user-inactive'); }, 2000); } else { playerContainer.classList.add('jw-flag-user-inactive'); } }, options); } return originalAddEventListener.call(this, type, listener, options); }; }()); } static get queries() { return { fullscreenBtn: 'div.jw-tooltip-fullscreen', player: 'video.jw-video', }; } async preparePlayer(player) { this.setupDoubletapBehavior(player); this.setupHotkeys(player); if (advancedSettings[ADVANCED_SETTINGS_MAP.showSkipIntroButton]) { setupSkipIntroButton(player); } this.setupOutroSkipHandling(player); this.setupWatchedStateLabeling(player); this.setupVideoPlaybackPositionMemory(player); let hasSkippedInitial = false; player.addEventListener('timeupdate', function autoStartSkip() { if (!hasSkippedInitial && coreSettings[CORE_SETTINGS_MAP.shouldAutoSkipOnStart]) { const skipSeconds = Number(coreSettings[CORE_SETTINGS_MAP.autoSkipSecondsOnStart]) || 0; if (player.currentTime < skipSeconds) { player.currentTime = skipSeconds; } hasSkippedInitial = true; } }); this.setupPersistentVolume(player); this.handleAutoplay(player); // should go after setupPersistentVolume // Attach autoplay button and change fullscreen button behavior... waitForElement(LoadXIframeInterface.queries.fullscreenBtn, { existing: true, onceOnly: true, }, (fsBtn) => { fsBtn = fsBtn.parentElement; const newFsBtn = fsBtn.cloneNode(true); const autoplayBtn = this.createAutoplayButton(); const settingsPane = this.settingsPane = this.createSettingsPane(); autoplayBtn.style.width = '44px'; autoplayBtn.style.height = '44px'; fsBtn.before(autoplayBtn); IS_SAFARI ? fsBtn.remove() : fsBtn.replaceWith(newFsBtn); const toggleSettingsPane = (ev) => { ev?.preventDefault(); ev?.stopImmediatePropagation(); settingsPane.hidden = !settingsPane.hidden; return false; }; if (IS_MOBILE) { autoplayBtn.oncontextmenu = () => false; detectHold(autoplayBtn, toggleSettingsPane); } else { autoplayBtn.oncontextmenu = toggleSettingsPane; } if (IS_SAFARI === false) { newFsBtn.addEventListener('click', () => { this.messenger.sendMessage(IframeMessenger.messages.TOGGLE_FULLSCREEN); }); this.messenger.sendMessage(IframeMessenger.messages.REQUEST_FULLSCREEN_STATE); } }); } updateFullscreenBtn({ isInFullscreen }) { const fsBtn = document.querySelector(LoadXIframeInterface.queries.fullscreenBtn); if (isInFullscreen) { fsBtn.parentElement.classList.add('jw-off'); } else { fsBtn.parentElement.classList.remove('jw-off'); } } } class VidozaIframeInterface extends IframeInterface { constructor(messenger) { super(messenger); waitForElement([ 'div[id^=asg-]', 'div.prevent-first-click', 'div.vjs-adblock-overlay', 'iframe[data-asg-handled^="asg-"]', 'iframe[style*="z-index: 2147483647"]', ].join(', '), { existing: true, }, (ads) => ads.remove()); (function() { const originalAddEventListener = EventTarget.prototype.addEventListener; EventTarget.prototype.addEventListener = function(type, listener, options) { // Get rid of ads if (type === 'mousedown' && (this === document || this === unsafeWindow)) { return; } return originalAddEventListener.call(this, type, listener, options); }; }()); } static get queries() { return { fullscreenBtn: 'button.vjs-fullscreen-control', player: 'video#player_html5_api.vjs-tech', }; } async preparePlayer(player) { this.setupDoubletapBehavior(player); this.setupHotkeys(player); if (advancedSettings[ADVANCED_SETTINGS_MAP.showSkipIntroButton]) { setupSkipIntroButton(player); } this.setupOutroSkipHandling(player); this.setupWatchedStateLabeling(player); this.setupVideoPlaybackPositionMemory(player); this.restylePlayer(player); let hasSkippedInitial = false; player.addEventListener('timeupdate', function autoStartSkip() { if (!hasSkippedInitial && coreSettings[CORE_SETTINGS_MAP.shouldAutoSkipOnStart]) { const skipSeconds = Number(coreSettings[CORE_SETTINGS_MAP.autoSkipSecondsOnStart]) || 0; if (player.currentTime < skipSeconds) { player.currentTime = skipSeconds; } hasSkippedInitial = true; } }); this.setupPersistentVolume(player); this.handleAutoplay(player); // should go after setupPersistentVolume // Attach autoplay button and change fullscreen button behavior... waitForElement(VidozaIframeInterface.queries.fullscreenBtn, { existing: true, onceOnly: true, }, (fsBtn) => { // Prevent focused buttons from being toggled by pressing space/enter fsBtn.parentElement.addEventListener('keydown', (ev) => ev.preventDefault()); fsBtn.parentElement.addEventListener('keyup', (ev) => ev.preventDefault()); const newFsBtn = fsBtn.cloneNode(true); const autoplayBtn = this.createAutoplayButton(); const settingsPane = this.settingsPane = this.createSettingsPane(); autoplayBtn.style.paddingBottom = '1px'; fsBtn.before(autoplayBtn); IS_SAFARI ? fsBtn.remove() : fsBtn.replaceWith(newFsBtn); const toggleSettingsPane = (ev) => { ev?.preventDefault(); ev?.stopImmediatePropagation(); settingsPane.hidden = !settingsPane.hidden; return false; }; if (IS_MOBILE) { autoplayBtn.oncontextmenu = () => false; detectHold(autoplayBtn, toggleSettingsPane); } else { autoplayBtn.oncontextmenu = toggleSettingsPane; } if (IS_SAFARI === false) { newFsBtn.addEventListener('click', () => { this.messenger.sendMessage(IframeMessenger.messages.TOGGLE_FULLSCREEN); }); this.messenger.sendMessage(IframeMessenger.messages.REQUEST_FULLSCREEN_STATE); } }); } restylePlayer() { GM_addStyle([ ` div.vjs-resolution-button, button.vjs-disable-ads-button { display: none !important; } `, ` div.video-js div.vjs-control-bar { background-color: unset !important; } `, ` div.video-js .vjs-slider { background-color: rgb(112, 112, 112, 0.8) !important; } `, ` div.video-js .vjs-play-progress { background-color: #2979ff !important; border-radius: 1em !important; height: 0.4em !important; } div.video-js .vjs-play-progress:before { font-size: 0.9em !important; top: -.25em !important; } `, ` div.video-js .vjs-load-progress { background-color: #808080 !important; height: 0.4em !important; } `, ` div.video-js .vjs-progress-control .vjs-progress-holder { height: 0.4em !important; } `, ` div.video-js .vjs-time-control, div.vjs-playback-rate .vjs-playback-rate-value, div.vjs-resolution-button .vjs-resolution-button-label { line-height: 3em !important; } `, ` div.video-js .vjs-big-play-button { background-color: rgb(0 132 255 / 75%) !important; } div.video-js .vjs-big-play-button:hover { background-color: rgb(40 160 255 / 95%) !important; } `, ` div.video-js .vjs-progress-control:hover .vjs-mouse-display:after, div.video-js .vjs-progress-control:hover .vjs-play-progress:after, div.video-js .vjs-progress-control:hover .vjs-time-tooltip, div.vjs-volume-panel .vjs-volume-control.vjs-volume-vertical, div.vjs-menu-button-popup .vjs-menu .vjs-menu-content { background-color: rgb(0 132 255 / 75%) !important; } `, ` #vplayer .video-js .vjs-time-control { padding-right: 3.5em !important; } `, ` div.video-js .vjs-play-control { margin-left: 0.5em !important; } `, ` div.video-js .vjs-progress-control { margin-left: 0.8em !important; } `, ` div.video-js .vjs-fullscreen-control { margin-right: 0.5em !important; } `, ].join(' ')); const currentTime = document.querySelector('div.vjs-current-time'); const remainingTime = document.querySelector('div.vjs-remaining-time'); remainingTime.replaceWith(currentTime); } updateFullscreenBtn({ isInFullscreen }) { const player = document.querySelector(VidozaIframeInterface.queries.player); if (isInFullscreen) { player.parentElement.classList.add('vjs-fullscreen'); } else { player.parentElement.classList.remove('vjs-fullscreen'); } } } class VOEJWPIframeInterface extends IframeInterface { constructor(messenger) { super(messenger); const playbackPositionStorageKey = ( `skip-forward-${location.pathname.split('/').pop()}` ); try { this.builtinPlaybackPositionMemory = JSON.parse(localStorage.getItem( playbackPositionStorageKey )); } catch {} localStorage.removeItem(playbackPositionStorageKey); waitForElement([ 'div.guestMode', 'iframe[style*="z-index: 2147483647"]', ].join(', '), { existing: true, }, (ads) => ads.remove()); (function() { const originalAddEventListener = EventTarget.prototype.addEventListener; EventTarget.prototype.addEventListener = function(type, listener, options) { if ( // Get rid of ads (['click', 'mousedown'].includes(type) && this === document) || // Intercept original hotkeys to avoid conflicts with the script hotkeys (type === 'keydown' && this.matches && this.matches('div#vp')) ) { return; } // Intercept double-tap to fullscreen handler if ( IS_MOBILE && mainSettings[MAIN_SETTINGS_MAP.overrideDoubletapBehavior] && (type === 'click' && this.matches && this.matches('div#vp > div > div.jw-media')) ) { let timerId = null; return originalAddEventListener.call(this, type, () => { clearTimeout(timerId); const playerContainer = document.querySelector('div#vp'); if (playerContainer.classList.contains('jw-flag-user-inactive')) { playerContainer.classList.remove('jw-flag-user-inactive'); timerId = setTimeout(() => { playerContainer.classList.add('jw-flag-user-inactive'); }, 2000); } else { playerContainer.classList.add('jw-flag-user-inactive'); } }, options); } return originalAddEventListener.call(this, type, listener, options); }; }()); // Add hotkeys from the second script if (document.querySelector('meta[name="keywords"][content^="VOE"]')) { const keyQueryMap = { KeyF: 'div.jw-controlbar div.jw-icon-fullscreen[role="button"]', Space: 'div.jw-controlbar div.jw-icon-playback[role="button"]', }; const heldKeys = new Set(); window.addEventListener('keyup', ev => heldKeys.delete(ev.code), true); window.addEventListener('keydown', (ev) => { if (heldKeys.has(ev.code)) return; heldKeys.add(ev.code); document.querySelector(keyQueryMap[ev.code])?.click(); }, true); } } static get queries() { return { fullscreenBtn: 'div.jw-tooltip-fullscreen', player: 'video.jw-video', }; } async handleAutoplay(player) { if (!coreSettings[CORE_SETTINGS_MAP.isAutoplayEnabled]) return; const playTooSlowErr = 'play() was taking too long'; let muteWasApplied = false; let playBtnWasClicked = false; // If play fails it tries to fix it but throws the problem error anyway const playOrFix = async () => { try { // VOE play() either errors immediately // or never resolves until a play button click await Promise.race([ player.play(), new Promise((_, reject) => { setTimeout(() => reject(new Error(playTooSlowErr)), 150); }), ]); } catch (e) { if (e.message === playTooSlowErr) { if (playBtnWasClicked) throw e; document.querySelector('div.jw-icon-display').click(); playBtnWasClicked = true; } else if (e.name === 'NotAllowedError') { // Muted usually is allowed to play, // and if it's not allowed, nothing could be done here if (player.muted) { console.error('Muted and not allowed'); throw e; } if (mainSettings[MAIN_SETTINGS_MAP.shouldAutoplayMuted] && !muteWasApplied) { player.muted = true; muteWasApplied = true; // Restore setting altered by forced mute. // See this.setupPersistentVolume() setTimeout(() => (coreSettings[CORE_SETTINGS_MAP.isMuted] = false)); // Should not be awaited (async () => { await waitForUserInteraction(); // If interaction was unmute button, try to not overtake it // because it might result in mute -> unmute -> mute again. // Different players require a different delay await sleep(100); if (player.muted) player.muted = false; })(); } } throw e; } }; const startTime = Date.now(); let lastError = null; while ((Date.now() - startTime) < (10 * 1000)) { try { await sleep(200); await playOrFix(); return; } catch (e) { lastError = e; } } throw lastError; } async preparePlayer(player) { this.setupDoubletapBehavior(player); this.setupHotkeys(player); if (advancedSettings[ADVANCED_SETTINGS_MAP.showSkipIntroButton]) { setupSkipIntroButton(player); } this.setupOutroSkipHandling(player); this.setupWatchedStateLabeling(player); this.setupVideoPlaybackPositionMemory(player); let hasSkippedInitial = false; player.addEventListener('timeupdate', function autoStartSkip() { if (!hasSkippedInitial && coreSettings[CORE_SETTINGS_MAP.shouldAutoSkipOnStart]) { const skipSeconds = Number(coreSettings[CORE_SETTINGS_MAP.autoSkipSecondsOnStart]) || 0; if (player.currentTime < skipSeconds) { player.currentTime = skipSeconds; } hasSkippedInitial = true; } }); this.setupPersistentVolume(player); this.handleAutoplay(player); // should go after setupPersistentVolume // Attach autoplay button and change fullscreen button behavior... waitForElement(VOEJWPIframeInterface.queries.fullscreenBtn, { existing: true, onceOnly: true, }, (fsBtn) => { fsBtn = fsBtn.parentElement; const newFsBtn = fsBtn.cloneNode(true); const autoplayBtn = this.createAutoplayButton(); const settingsPane = this.settingsPane = this.createSettingsPane(); autoplayBtn.style.width = '44px'; autoplayBtn.style.height = '44px'; autoplayBtn.style.paddingTop = '3px'; autoplayBtn.style.flex = '0 0 auto'; autoplayBtn.style.outline = 'none'; fsBtn.before(autoplayBtn); IS_SAFARI ? fsBtn.remove() : fsBtn.replaceWith(newFsBtn); const toggleSettingsPane = (ev) => { ev?.preventDefault(); ev?.stopImmediatePropagation(); settingsPane.hidden = !settingsPane.hidden; return false; }; if (IS_MOBILE) { autoplayBtn.oncontextmenu = () => false; detectHold(autoplayBtn, toggleSettingsPane); } else { autoplayBtn.oncontextmenu = toggleSettingsPane; } if (IS_SAFARI === false) { newFsBtn.addEventListener('click', () => { this.messenger.sendMessage(IframeMessenger.messages.TOGGLE_FULLSCREEN); }); this.messenger.sendMessage(IframeMessenger.messages.REQUEST_FULLSCREEN_STATE); } }); } async setupVideoPlaybackPositionMemory(player) { const self = this; await (async function waitForVideoData(start = Date.now()) { if (!self.currentVideoId || !self.topScopeDomainId) { if ((Date.now() - start) > (10 * 1000)) { throw new Error('Video data didn\'t arrive in time'); } await sleep(); return waitForVideoData(start); } }()); const timestampDataGMKey = ( IframeInterface.makePlaybackPositionGMKey(this.topScopeDomainId, this.currentVideoId) ); if ( this.builtinPlaybackPositionMemory && this.builtinPlaybackPositionMemory.value ) { const { expire, value } = this.builtinPlaybackPositionMemory; let updateDate = Date.now(); // 10 days is the built in position memory expiration time if (expire) { updateDate = ( new Date((new Date(expire)).getTime() - 10 * 24 * 60 * 60 * 1000).getTime() ); } GM_setValue(timestampDataGMKey, { value, updateDate }); } // This has to wait indefinitely because players like VOE do not have the value // until the play button has been pressed or an autoplay has been triggered await (async function waitForVideoDuration() { if (!player.duration) { await sleep(); return waitForVideoDuration(); } }()); const timestampData = GM_getValue(timestampDataGMKey, {}); if (timestampData.value) { const elapsedTime = Date.now() - timestampData.updateDate; const expirationThreshold = advancedSettings[ ADVANCED_SETTINGS_MAP.playbackPositionExpirationDays ] * 24 * 60 * 60 * 1000; if (elapsedTime < expirationThreshold) { const outroSkipThresholdS = coreSettings[CORE_SETTINGS_MAP.currentOutroSkipThresholdS]; const potentialTimeLeftToPlay = player.duration - timestampData.value; // Skip saved playback position if it's in a range of (outroSkipThresholdS + 20) if (potentialTimeLeftToPlay > (outroSkipThresholdS + 20)) { player.currentTime = timestampData.value; } } } let lastCheckedTime = player.currentTime; setInterval(() => { if ( !mainSettings[MAIN_SETTINGS_MAP.playbackPositionMemory] || (player.currentTime === lastCheckedTime) ) return; lastCheckedTime = player.currentTime; GM_setValue(timestampDataGMKey, { value: lastCheckedTime, updateDate: Date.now(), }); }, 1000); } updateFullscreenBtn({ isInFullscreen }) { const fsBtn = document.querySelector(VOEJWPIframeInterface.queries.fullscreenBtn); if (isInFullscreen) { fsBtn.parentElement.classList.add('jw-off'); } else { fsBtn.parentElement.classList.remove('jw-off'); } } } class SpeedfilesIframeInterface extends IframeInterface { constructor(messenger) { super(messenger); waitForElement([ 'iframe[style*="z-index: 2147483647"]', ].join(', '), { existing: true, }, (ads) => ads.remove()); (function() { const originalAddEventListener = EventTarget.prototype.addEventListener; EventTarget.prototype.addEventListener = function(type, listener, options) { if ( type === 'dblclick' && this.matches && this.matches('#my-video_html5_api') ) { return; } return originalAddEventListener.call(this, type, listener, options); }; }()); } static get queries() { return { fullscreenBtn: 'button.vjs-fullscreen-control', player: 'video#my-video_html5_api.vjs-tech', }; } async preparePlayer(player) { this.setupDoubletapBehavior(player); this.setupHotkeys(player); if (advancedSettings[ADVANCED_SETTINGS_MAP.showSkipIntroButton]) { setupSkipIntroButton(player); } this.setupOutroSkipHandling(player); this.setupWatchedStateLabeling(player); this.setupVideoPlaybackPositionMemory(player); let hasSkippedInitial = false; player.addEventListener('timeupdate', function autoStartSkip() { if (!hasSkippedInitial && coreSettings[CORE_SETTINGS_MAP.shouldAutoSkipOnStart]) { const skipSeconds = Number(coreSettings[CORE_SETTINGS_MAP.autoSkipSecondsOnStart]) || 0; if (player.currentTime < skipSeconds) { player.currentTime = skipSeconds; } hasSkippedInitial = true; } }); this.setupPersistentVolume(player); this.handleAutoplay(player); // should go after setupPersistentVolume // Attach autoplay button and change fullscreen button behavior... waitForElement(SpeedfilesIframeInterface.queries.fullscreenBtn, { existing: true, onceOnly: true, }, (fsBtn) => { const newFsBtn = fsBtn.cloneNode(true); const autoplayBtn = this.createAutoplayButton(); const settingsPane = this.settingsPane = this.createSettingsPane(); autoplayBtn.style.width = '40px'; autoplayBtn.style.paddingBottom = '1px'; fsBtn.before(autoplayBtn); IS_SAFARI ? fsBtn.remove() : fsBtn.replaceWith(newFsBtn); // Prevent focused buttons from being toggled by pressing space/enter newFsBtn.addEventListener('keydown', (ev) => ev.preventDefault()); newFsBtn.addEventListener('keyup', (ev) => ev.preventDefault()); const toggleSettingsPane = (ev) => { ev?.preventDefault(); ev?.stopImmediatePropagation(); settingsPane.hidden = !settingsPane.hidden; return false; }; if (IS_MOBILE) { autoplayBtn.oncontextmenu = () => false; detectHold(autoplayBtn, toggleSettingsPane); } else { autoplayBtn.oncontextmenu = toggleSettingsPane; } if (IS_SAFARI === false) { newFsBtn.addEventListener('click', () => { this.messenger.sendMessage(IframeMessenger.messages.TOGGLE_FULLSCREEN); }); this.messenger.sendMessage(IframeMessenger.messages.REQUEST_FULLSCREEN_STATE); } }); } updateFullscreenBtn({ isInFullscreen }) { const player = document.querySelector(SpeedfilesIframeInterface.queries.player); if (isInFullscreen) { player.parentElement.classList.add('vjs-fullscreen'); } else { player.parentElement.classList.remove('vjs-fullscreen'); } } } class TopScopeInterface { constructor() { this.commLink = null; this.currentIframeId = null; this.domainId = TOP_SCOPE_DOMAINS_IDS[location.hostname] || ''; this.iframeSrcChangesListener = null; this.id = makeId(); this.ignoreIframeSrcChangeOnce = false; this.isPendingConnection = false; // Ugly shitcode fix for a playback positions. This assigns their value // to both the aniworld and s.to at the same time. // This is needed because these prefixes were missing before v4.8.3 // causing saved positions being shared between different websites if (!GM_getValue('playbackPositionsMemory482wereFixed', false)) { this.applyPlaybackPositionsFix(); GM_setValue('playbackPositionsMemory482wereFixed', true); } } static get messages() { return { CURRENT_FRANCHISE_DATA: 'CURRENT_FRANCHISE_DATA', FULLSCREEN_STATE: 'FULLSCREEN_STATE', }; } static get queries() { return { animeTitle: 'div.hostSeriesTitle', episodeDedicatedLink: 'div.hosterSiteVideo a.watchEpisode', episodeTitle: 'div.hosterSiteTitle', hostersPlayerContainer: 'div.hosterSiteVideo', navLinksContainer: 'div#stream.hosterSiteDirectNav', playerIframe: 'div.inSiteWebStream iframe', providerChangeBtn: 'div.generateInlinePlayer', providerName: 'div.hosterSiteVideo > ul a > h4', providersList: 'div.hosterSiteVideo > ul', selectedLanguageBtn: 'img.selectedLanguage', }; } applyPlaybackPositionsFix() { const oldPlaybackPositionsGMPrefix = 'playbackTimestamp_'; const oldPlaybackPositionsKeys = ( GM_listValues().filter( v => v.startsWith(oldPlaybackPositionsGMPrefix) && v.split('_').length === 2 ) ); const uniqueTopScopeDomainsIds = [...new Set(Object.values(TOP_SCOPE_DOMAINS_IDS))]; for (const oldKey of oldPlaybackPositionsKeys) { const episodeId = oldKey.slice(oldPlaybackPositionsGMPrefix.length); const oldValue = GM_getValue(oldKey); for (const domainId of uniqueTopScopeDomainsIds) { const newKey = IframeInterface.makePlaybackPositionGMKey(domainId, episodeId); GM_setValue(newKey, oldValue); } GM_deleteValue(oldKey); } } // It is better not to be async handleIframeMessages(packet) { (async function() { try { switch (packet.command) { case IframeMessenger.messages.AUTOPLAY_NEXT: { // This is here because it bugges out the episodes navigation panel // if try and use MARK_CURRENT_VIDEO_WATCHED. Watched episode is being // marked as non watched try { await this.markCurrentVideoWatched(); } catch (e) { console.error(e); } try { await this.goToNextVideo(); } catch (e) { console.error(e); Notiflixx.notify.warning( `${GM_info.script.name}: ${i18n.autoplayError}` ); } break; } case IframeMessenger.messages.REQUEST_CURRENT_FRANCHISE_DATA: { const episodeId = document.querySelector( TopScopeInterface.queries.episodeTitle ).dataset.episodeId; const releaseYear = document.querySelector( 'div.series-title span[itemprop="startDate"]' ).innerText; const title = document.querySelector('div.series-title > h1').innerText; const currentFranchiseId = ( title ? `${title}${releaseYear ? `::${releaseYear}` : ''}` : null ); if (currentFranchiseId || episodeId) { this.commLink.commands[ TopScopeInterface.messages.CURRENT_FRANCHISE_DATA ]({ currentFranchiseId, currentVideoId: episodeId || null, topScopeDomainId: this.domainId, }); } break; } // Would not work on Safari // but this should not be called on Safari anyway case IframeMessenger.messages.REQUEST_FULLSCREEN_STATE: { if (IS_SAFARI) break; this.commLink.commands[TopScopeInterface.messages.FULLSCREEN_STATE]({ isInFullscreen: !!document.fullscreenElement, }); break; } case IframeMessenger.messages.MARK_CURRENT_VIDEO_WATCHED: { await this.markCurrentVideoWatched(); break; } case IframeMessenger.messages.OPEN_HOTKEYS_GUIDE: { let content = [ '<h5>🔹 Basic hotkeys</h5>', '<div><b>Single key: </b><pre>a</pre> → Triggers when <pre>a</pre> is pressed</div>', '<div><b>Combo keys: </b><pre>ctrl + shift + a</pre> → Triggers when all keys are held together</div>', '<h5>🔹 Sequences (pressing keys in order)</h5>', '<div><b>Sequence: </b><pre>a > b</pre> → Press <pre>a</pre>, then <pre>b</pre></div>', '<div><b>Chained sequence: </b><pre>ctrl + a > b</pre> → Hold <pre>ctrl</pre>, press <pre>a</pre>, release, then press <pre>b</pre></div>', '<h5>🔹 Multiple options</h5>', '<div><pre>a + b > c, x + y > z</pre> → Either <pre>a</pre> & <pre>b</pre> then <pre>c</pre> OR <pre>x</pre> & <pre>y</pre> then <pre>z</pre></div>', '<h5>🔹 Special keys (most of them)</h5>', ].join(''); content += [ 'cancel', 'backspace', 'tab', 'clear', 'enter', 'shift', 'ctrl', 'alt', 'menu', 'pause', 'break', 'capslock', 'pageup', 'pagedown', 'space', 'spacebar', 'escape', 'esc', 'end', 'home', 'left', 'up', 'right', 'down', 'select', 'printscreen', 'execute', 'snapshot', 'insert', 'ins', 'delete', 'del', 'help', 'scrolllock', 'scroll', 'comma', ',', 'period', '.', 'openbracket', '[', 'backslash', '\\', 'slash', 'forwardslash', '/', 'closebracket', ']', 'apostrophe', '\'', 'zero', '0', 'one', '1', 'two', '2', 'three', '3', 'four', '4', 'five', '5', 'six', '6', 'seven', '7', 'eight', '8', 'nine', '9', 'numzero', 'num0', 'numone', 'num1', 'numtwo', 'num2', 'numthree', 'num3', 'numfour', 'num4', 'numfive', 'num5', 'numsix', 'num6', 'numseven', 'num7', 'numeight', 'num8', 'numnine', 'num9', 'nummultiply', 'num*', 'numadd', 'num+', 'numenter', 'numsubtract', 'num-', 'numdecimal', 'num.', 'numdivide', 'num/', 'numlock', 'num', 'f1', 'f2', 'f3', 'f4', 'f5', 'f6', 'f7', 'f8', 'f9', 'f10', 'f11', 'f12', 'f13', 'f14', 'f15', 'f16', 'f17', 'f18', 'f19', 'f20', 'f21', 'f22', 'f23', 'f24', 'tilde', '~', 'exclamation', 'exclamationpoint', '!', 'at', '@', 'number', '#', 'dollar', 'dollars', 'dollarsign', '$', 'percent', '%', 'caret', '^', 'ampersand', 'and', '&', 'asterisk', '*', 'openparen', '(', 'closeparen', ')', 'underscore', '_', 'plus', '+', 'opencurlybrace', 'opencurlybracket', '{', 'closecurlybrace', 'closecurlybracket', '}', 'verticalbar', '|', 'colon', ':', 'quotationmark', '\'', 'openanglebracket', '<', 'closeanglebracket', '>', 'questionmark', '?', 'semicolon', ';', 'dash', '-', 'equal', 'equalsign', '=', ].map(s => `<pre>${s}</pre>`).join(' '); const modal = document.createElement('div'); modal.className = 'notiflix-hotkeys-guide-modal'; modal.innerHTML = content; Notiflixx.report.info(i18n.hotkeysGuide, modal.outerHTML, i18n.close, { backOverlayClickToClose: true, messageMaxLength: Infinity, plainText: false, }); break; } // Would not work on Safari // but this should not be called from Safari anyway case IframeMessenger.messages.TOGGLE_FULLSCREEN: { if (IS_SAFARI) break; // Notice how this then triggers a listener from this.init() if (document.fullscreenElement) { await document.exitFullscreen(); } else { await document.documentElement.requestFullscreen(); } break; } case IframeMessenger.messages.TOP_NOTIFLIX_REPORT_INFO: { Notiflixx.report.info(...packet.data.args); break; } // Not sure if anything except providersPriority needs to be in sync witn an iframe case IframeMessenger.messages.UPDATE_CORE_SETTINGS: { coreSettings.update(); break; } default: break; } } catch (e) { console.error(e); } }.bind(this)()); return { status: `${this.constructor.name} received a message`, }; } async init(iframe) { this.iframeSrcChangesListener = new MutationObserver((mutations) => { for (const mutation of mutations) { if (mutation.attributeName === 'src') { if (this.ignoreIframeSrcChangeOnce) { this.ignoreIframeSrcChangeOnce = false; return; } this.unregisterCommlinkListener(); this.initCrossFrameConnection(); } } }).observe(iframe, { attributes: true }); await this.initCrossFrameConnection(); if (IS_SAFARI) { this.adaptFakeFullscreen(); window.addEventListener('orientationchange', () => { setTimeout(() => this.adaptFakeFullscreen(), 100); }); } else { document.addEventListener('fullscreenchange', () => { this.adaptFakeFullscreen(); this.commLink.commands[TopScopeInterface.messages.FULLSCREEN_STATE]({ isInFullscreen: !!document.fullscreenElement, }); }); } } async initCrossFrameConnection() { if (this.isPendingConnection) throw new Error('Connecting already'); this.isPendingConnection = true; let timeoutId; const iframeId = this.currentIframeId = await new Promise((resolve, reject) => { const valueChangeListenerId = GM_addValueChangeListener('unboundIframeId', ( _key, _oldValue, newValue, ) => { const iframe = document.querySelector(TopScopeInterface.queries.playerIframe); // Skip if top scope is a wrong one if (!iframe) return; GM_removeValueChangeListener(valueChangeListenerId); clearTimeout(timeoutId); resolve(newValue); }); timeoutId = setTimeout(() => { this.isPendingConnection = false; GM_removeValueChangeListener(valueChangeListenerId); reject(new Error('Iframe connection timeout')); }, 4 * 1000); }); GM_setValue(iframeId, this.id); this.commLink = new CommLinkHandler(this.id, { silentMode: true, statusCheckInterval: advancedSettings[ADVANCED_SETTINGS_MAP.commlinkPollingIntervalMs], }); this.commLink.registerSendCommand(TopScopeInterface.messages.CURRENT_FRANCHISE_DATA); this.commLink.registerSendCommand(TopScopeInterface.messages.FULLSCREEN_STATE); this.commLink.registerListener(iframeId, this.handleIframeMessages.bind(this)); this.isPendingConnection = false; } adaptFakeFullscreen() { const Q = TopScopeInterface.queries; const hostersPlayerContainer = document.querySelector(Q.hostersPlayerContainer); const playerIframe = document.querySelector(Q.playerIframe); // Consider landscape mode as fullscreen on Safari const isInFullscreen = ( IS_SAFARI ? window.innerWidth > window.innerHeight : !!document.fullscreenElement ); if (isInFullscreen) { document.body.style.overflow = 'hidden'; playerIframe.style.setProperty('height', '100vh', 'important'); hostersPlayerContainer.firstElementChild.style.display = 'none'; hostersPlayerContainer.style.cssText = ( 'z-index: 100; position: fixed; top: 0; left: 0; padding: 0; height: 100vh; overflow-y: scroll; scrollbar-width: none;' ); } else { document.body.style.overflow = ''; playerIframe.style.height = ''; // scrollTop reset must go before the cssText, it won't work otherwise hostersPlayerContainer.firstElementChild.style.display = ''; hostersPlayerContainer.scrollTop = 0; hostersPlayerContainer.style.cssText = ''; } } async announceEpisodeWatched(id) { if (!id) throw new Error('Episode ID is missing'); await fetch(`${location.protocol}//${location.hostname}/ajax/lastseen`, { method: 'POST', body: `episode=${id}`, headers: { 'content-type': 'application/x-www-form-urlencoded; charset=UTF-8', }, }); } async goToNextVideo() { const Q = TopScopeInterface.queries; const [seasonsNav, episodesNav] = document.querySelectorAll(`${Q.navLinksContainer} > ul`); const episodesNavLinks = [...episodesNav.querySelectorAll('a')]; const seasonNavLinks = [...seasonsNav.querySelectorAll('a')]; const currentEpisodeIndex = episodesNavLinks.findIndex(el => el.classList.contains('active')); const currentSeasonIndex = seasonNavLinks.findIndex(el => el.classList.contains('active')); let nextEpisodeHref = null; if (currentEpisodeIndex < episodesNavLinks.length - 1) { nextEpisodeHref = episodesNavLinks[currentEpisodeIndex + 1].href; } else if (currentSeasonIndex < seasonNavLinks.length - 1) { // Do not proceed if this is a last movie // so it wont hop in to a season from a movie if (seasonNavLinks[currentSeasonIndex].href.endsWith('/filme')) return; const nextSeasonHref = seasonNavLinks[currentSeasonIndex + 1].href; const nextSeasonHtml = await (await fetch(nextSeasonHref)).text(); const nextSeasonDom = (new DOMParser()).parseFromString(nextSeasonHtml, 'text/html'); const firstEpisodeLink = nextSeasonDom.querySelector( `${Q.navLinksContainer} > ul a[data-episode-id]` ); nextEpisodeHref = firstEpisodeLink.href; } // Seems like the last episode was reached if (!nextEpisodeHref) return; const nextEpisodeHtml = await (await fetch(nextEpisodeHref)).text(); const nextEpisodeDom = (new DOMParser()).parseFromString(nextEpisodeHtml, 'text/html'); // Update current DOM from a next episode DOM ([ 'div#wrapper > div.seriesContentBox > div.container.marginBottom > ul', 'div#wrapper > div.seriesContentBox > div.container.marginBottom > div.cf', 'div.changeLanguageBox', `${Q.episodeTitle} > ul`, Q.animeTitle, Q.episodeTitle, Q.navLinksContainer, Q.providersList, ]).forEach((query) => { const currentElement = document.querySelector(query); const newElement = nextEpisodeDom.querySelector(query); if (currentElement && newElement) { currentElement.outerHTML = newElement.outerHTML; } }); document.title = nextEpisodeDom.title; history.pushState({}, '', nextEpisodeHref); try { // The website code copypasta to try and restore various buttons functionality (function repairWebsiteFeatures() { document.querySelectorAll(Q.providerChangeBtn).forEach((btn) => { btn.addEventListener('click', (ev) => { ev.preventDefault(); const parent = btn.parentElement; const linkTarget = parent.getAttribute('data-link-target'); const hosterTarget = parent.getAttribute('data-external-embed') === 'true'; const fakePlayer = document.querySelector('.fakePlayer'); const inSiteWebStream = document.querySelector('.inSiteWebStream'); const iframe = inSiteWebStream.querySelector('iframe'); if (hosterTarget) { fakePlayer.style.display = 'block'; inSiteWebStream.style.display = 'inline-block'; iframe.style.display = 'none'; } else { fakePlayer.style.display = 'none'; inSiteWebStream.style.display = 'inline-block'; iframe.src = linkTarget; iframe.style.display = 'inline-block'; } }); }); }()); const { selectedLanguage } = this.updateVideoLanguageProcessing(); const preferredProvidersButtons = [ ...document.querySelectorAll(TopScopeInterface.queries.providerChangeBtn) ].filter(el => el.parentElement.dataset.langKey === selectedLanguage); let nextProviderName = null; let nextVideoLink = null; if (preferredProvidersButtons.length) { outer: for (const id of coreSettings[CORE_SETTINGS_MAP.providersPriority]) { const preferredProviderName = VIDEO_PROVIDERS_IDS[id]; for (const btn of preferredProvidersButtons) { const link = btn.firstElementChild; const providerName = link.querySelector( TopScopeInterface.queries.providerName ).innerText; if (providerName === preferredProviderName) { nextProviderName = providerName; nextVideoLink = link; break outer; } } } } let nextVideoHref = nextVideoLink?.href; // VOE has an additional redirect page, // so need to extract the video href from there first // in order to keep VOE-to-VOE autoplay unmuted if (nextVideoHref && nextProviderName === VIDEO_PROVIDERS_MAP.VOE) { const corsProxy = advancedSettings[ADVANCED_SETTINGS_MAP.corsProxy]; if (corsProxy) { nextVideoHref = /location\.href = '(https:\/\/.+)';/.exec( await (await fetch(corsProxy + nextVideoLink.href)).text() )[1]; } } if (!nextVideoHref) throw new Error('Embedded providers are missing or not supported'); document.querySelector(Q.playerIframe).src = nextVideoHref; } catch { GM_setValue('lastAutoplayError', { date: Date.now() }); // At that point, refresh should load the next episode if the website even has it. // The problem is it is not seamless location.href = location.href; } } async markCurrentVideoWatched() { const episodeId = document.querySelector( TopScopeInterface.queries.episodeTitle ).dataset.episodeId; await this.announceEpisodeWatched(episodeId); } unregisterCommlinkListener() { if (!this.currentIframeId) return; this.commLink.listeners = this.commLink.listeners.filter((listener) => { if (listener.sender === this.currentIframeId) { listener.intervalObj.stop(); return false; } return true; }); this.currentIframeId = null; } // Partly consist of the website code updateVideoLanguageProcessing() { let changeLanguageButtons = [...document.querySelectorAll('.changeLanguageBox img')]; let selectedLanguage = coreSettings[CORE_SETTINGS_MAP.videoLanguagePreferredID]; const availableLangIDs = [...new Set(changeLanguageButtons.map(img => img.dataset.langKey))]; // Checks preferred language and if it is missing, it takes first available. // Returns if found zero buttons with language IDs if (!selectedLanguage || !availableLangIDs.includes(selectedLanguage)) { if (availableLangIDs.length) { selectedLanguage = availableLangIDs[0]; } else { return null; } } // Hides/unhides providers buttons based on language document.querySelectorAll('.hosterSiteVideo ul li[data-lang-key]').forEach((el) => { el.style.display = el.dataset.langKey === selectedLanguage ? 'block' : 'none'; }); // Highlights/unhighlights change language buttons changeLanguageButtons.forEach((btn) => { btn.classList.toggle('selectedLanguage', btn.dataset.langKey === selectedLanguage); btn.outerHTML = btn.outerHTML; }); // HTML reset removes the nodes from the DOM so need to get them here once again changeLanguageButtons = [...document.querySelectorAll('.changeLanguageBox img')]; changeLanguageButtons.forEach((btn) => { btn.addEventListener('click', function() { const selectedLanguage = coreSettings[ CORE_SETTINGS_MAP.videoLanguagePreferredID ] = this.getAttribute('data-lang-key'); // Highlights/unhighlights change language buttons document.querySelectorAll('.changeLanguageBox img').forEach((btn) => { btn.classList.toggle('selectedLanguage', btn.dataset.langKey === selectedLanguage); }); // Hides/unhides providers buttons based on language document.querySelectorAll('.hosterSiteVideo ul li[data-lang-key]').forEach((el) => { el.style.display = el.dataset.langKey === selectedLanguage ? 'block' : 'none'; }); const preferredProvidersButtons = [ ...document.querySelectorAll(TopScopeInterface.queries.providerChangeBtn) ].filter(el => el.parentElement.dataset.langKey === selectedLanguage); if (preferredProvidersButtons.length) { outer: for (const id of coreSettings[CORE_SETTINGS_MAP.providersPriority]) { const preferredProviderName = VIDEO_PROVIDERS_IDS[id]; for (const btn of preferredProvidersButtons) { const providerName = btn.firstElementChild.querySelector( TopScopeInterface.queries.providerName ).innerText; if (providerName === preferredProviderName) { btn.click(); break outer; } } } } else { document.querySelectorAll('.inSiteWebStream').forEach((el) => { el.style.display = 'none'; }); this.unregisterCommlinkListener(); if (this.iframeSrcChangesListener) this.ignoreIframeSrcChangeOnce = true; document.querySelector(TopScopeInterface.queries.playerIframe).src = 'about:blank'; } }); }); return { selectedLanguage }; } } // If context is top scope if (!isEmbedded()) { if (!TOP_SCOPE_DOMAINS.includes(location.hostname)) return; // Recolor episodes links visited before, excluding the current or watched ones GM_addStyle(` div#stream.hosterSiteDirectNav a[data-episode-id]:visited:not([class]) { background: #ffdd00; } `); // Wait for DOM await new Promise((resolve) => { if (['complete'].includes(document.readyState)) { resolve(); } else { document.addEventListener('DOMContentLoaded', resolve, { once: true }); } }); try { if (!IS_SAFARI && !GM_getValue('violentmonkeyWarningTextWasShown')) { const showWarning = () => { if (document.visibilityState === 'visible') { Notiflixx.report.warning(...VIOLENTMONKEY_WARNING, i18n.ok); setTimeout(() => GM_setValue('violentmonkeyWarningTextWasShown', true), 500); document.removeEventListener('visibilitychange', showWarning); } }; if (document.visibilityState === 'visible') { showWarning(); } else { document.addEventListener('visibilitychange', showWarning); } } const lastAutoplayError = GM_getValue('lastAutoplayError'); if (lastAutoplayError && ((Date.now() - lastAutoplayError.date) <= (60 * 1000))) { GM_deleteValue('lastAutoplayError'); Notiflixx.notify.warning( `${GM_info.script.name}: ${i18n.lastAutoplayError}` ); } } catch (e) { console.error(e); } const topScopeInterface = new TopScopeInterface(); const iframe = document.querySelector(TopScopeInterface.queries.playerIframe); // Not a video page? if (!iframe) return; // Remove the website logic responsible for marking episodes as watched. // since the script would handle it instead. Awaiting is unnecessary (async function waitForWatchedFunction(start = Date.now()) { if (unsafeWindow.markAsWatched) { unsafeWindow.markAsWatched = () => {}; } else { if ((Date.now() - start) > (10 * 1000)) { throw new Error('Watched function didn\'t arrive in time'); } await sleep(); return waitForWatchedFunction(start); } }()); iframe.addEventListener('load', async () => { await topScopeInterface.init(iframe); }, { once: true }); // Wait for the website main code to finish await new Promise((resolve) => { waitForElement(TopScopeInterface.queries.selectedLanguageBtn, { existing: true, onceOnly: true, callbackOnTimeout: true, timeout: 10 * 1000, }, resolve); }); await sleep(); const { selectedLanguage } = topScopeInterface.updateVideoLanguageProcessing(); const preferredProvidersButtons = [ ...document.querySelectorAll(TopScopeInterface.queries.providerChangeBtn) ].filter(el => el.parentElement.dataset.langKey === selectedLanguage); if (preferredProvidersButtons.length) { for (const id of coreSettings[CORE_SETTINGS_MAP.providersPriority]) { const preferredProviderName = VIDEO_PROVIDERS_IDS[id]; for (const btn of preferredProvidersButtons) { const providerName = btn.firstElementChild.querySelector( TopScopeInterface.queries.providerName ).innerText; if (providerName === preferredProviderName) { btn.click(); return; } } } } } // If context is iframe scope else { const isItDoodstream = document.title.toLowerCase().endsWith('doodstream'); const isItLoadX = !!(document.querySelector('title')?.textContent === 'LoadX'); const isItVOEJWP = !!document.querySelector('meta[name="keywords"][content^="VOE"]'); const isItVidoza = !!document.querySelector('meta[content*="Vidoza"]'); const isItSpeedfiles = !!document.querySelector( 'meta[content*="https://speedfiles.net"]' ); if ([isItDoodstream, isItLoadX, isItVidoza, isItSpeedfiles, isItVOEJWP].every(e => !e)) return; const iframeMessenger = new IframeMessenger(); for (const { condition, interface: Interface } of [{ condition: isItDoodstream, interface: DoodstreamIframeInterface }, { condition: isItLoadX, interface: LoadXIframeInterface }, { condition: isItVidoza, interface: VidozaIframeInterface }, { condition: isItVOEJWP, interface: VOEJWPIframeInterface }, { condition: isItSpeedfiles, interface: SpeedfilesIframeInterface }, ]) { if (!condition) continue; // Call early to get rid of ads and intercept listeners const iframeInterface = new Interface(iframeMessenger); window.addEventListener('load', async () => { // Give a little bit of a time for the TopScopeInterface to prepare await sleep(4); await iframeMessenger.initCrossFrameConnection(); waitForElement(Interface.queries.player, { existing: true, onceOnly: true, }, async (player) => { // Prevent fullscreen triggering by a playback start, on Safari player.setAttribute('playsinline', ''); player.setAttribute('webkit-playsinline', ''); // Attempt to fix a Safari bug when the video controls get duplicated GM_addStyle(` video::-webkit-media-controls-panel, video::-webkit-media-controls-play-button, video::-webkit-media-controls-start-playback-button { display: none !important; -webkit-appearance: none; opacity: 0; visibility: hidden; } `); await iframeInterface.init(player); }); }, { once: true }); break; } } }());