commit e383aa4738bbd7b4fbb5d4da31081004ef123bd8 Author: Jade Macho Date: Fri Mar 1 21:01:22 2024 +0100 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4ea1c0b --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +.htaccess +/priv/ +/.vscode/ diff --git a/Jadefin.js b/Jadefin.js new file mode 100644 index 0000000..8d310fd --- /dev/null +++ b/Jadefin.js @@ -0,0 +1,318 @@ +//@ts-check + +import JadefinIntegrity from "./JadefinIntegrity.js"; + +import JadefinMod from "./JadefinMod.js"; +import JadefinModules from "./JadefinModules.js"; +import JadefinUtils from "./JadefinUtils.js"; + +export default JadefinIntegrity("Jadefin", import.meta.url, () => window["Jadefin"] = new (class Jadefin extends JadefinMod { + modUrl = import.meta.url; + root = this.modUrl.substring(0, this.modUrl.lastIndexOf("/")); + + /** @type {{[id: string]: JadefinMod}} */ + mods = {}; + + /** @type {Set} */ + _loadingMods_dedupe = new Set(); + /** @type {{module: JadefinMod, name: String, url: String}[]} */ + _loadingMods_all = []; + /** @type {Promise} */ + _loadingMods; + + /** @type {any} */ + _webpackModuleFuncs; + + constructor() { + super(); + + this.log.i(`Loading from ${this.modUrl}`); + this.log.v("Test Verbose"); + this.log.w("Test Warning"); + this.log.e("Test Error"); + + this.initEarly(); + + JadefinModules.Jadefin = this; + + this._loadingMods = this._loadMods(); + + window["_JadefinLoaded"](); + } + + initEarly() { + // Required for mods to be able to replace some things. + // Might or might not horribly wreck everything. + const defineProperty = this._Object_defineProperty = Object.defineProperty.bind(Object); + const defineProperties = this._Object_defineProperties = Object.defineProperties.bind(Object); + + Object.defineProperty = (obj, prop, descriptor) => { + if (descriptor && prop != "prototype") { + descriptor.configurable = true; + } + + return defineProperty(obj, prop, descriptor); + }; + + Object.defineProperties = (obj, props) => { + if (props) { + for (const prop of Object.keys(props)) { + const descriptor = props[prop]; + + if (descriptor && prop != "prototype") { + descriptor.configurable = true; + } + } + } + + return defineProperties(obj, props); + }; + + // Required for mods to be able to perform certain worker shenanigans. + window.Worker = (class Worker extends window.Worker { + constructor(scriptURL, options) { + const args = {scriptURL, options}; + + JadefinUtils.events.dispatchEvent(new CustomEvent(JadefinUtils.eventTypes.WORKER_CREATING, { + detail: { + args + } + })); + + super(args.scriptURL, args.options); + + JadefinUtils.events.dispatchEvent(new CustomEvent(JadefinUtils.eventTypes.WORKER_CREATED, { + detail: { + args, + worker: this + } + })); + } + }); + } + + async init(base) { + await super.init("Jadefin", this.modUrl); + + // Wait until webpackChunk and Emby exist. + // It might be delayed due to the main jellyfin bundle being replaced by an inject script on Android. + await JadefinUtils.waitUntil(() => window["webpackChunk"] && window["Emby"]); + + window["webpackChunk"].push([["custom_init"], {}, a => { + /** @type {((id: number | string) => any & {[key: string]: any})} */ + this.webpackLoad = a; + }]); + + if (!this.webpackLoad) { + this.log.e("Couldn't obtain webpackLoad"); + return; + } + + // Wait until webpackChunk and Emby exist. + // It might be delayed due to the main jellyfin bundle being replaced by an inject script on Android. + await JadefinUtils.waitUntil(() => this.webpackModuleFuncs); + + const initing = [ + this.initHookHtmlVideoPlayer(), + ]; + + await this._loadingMods; + await this.initMods(); + + await Promise.all(initing); + + this.log.i("Ready"); + } + + async initHookHtmlVideoPlayer() { + const self = this; + + await JadefinUtils.waitUntil(() => JadefinModules.htmlVideoPlayer); + + const _createMediaElement = JadefinModules.htmlVideoPlayer.prototype.createMediaElement; + JadefinModules.htmlVideoPlayer.prototype.createMediaElement = function(options) { + if (!JadefinUtils.htmlVideoPlayers.delete(this)) { + self.log.i("Adding new htmlVideoPlayer"); + self.log.dir(this); + + JadefinUtils.htmlVideoPlayers.add(this); + JadefinUtils.htmlVideoPlayer = this; + JadefinUtils.events.dispatchEvent(new CustomEvent(JadefinUtils.eventTypes.HTML_VIDEO_PLAYER_CHANGED, { + detail: { + alive: true, + player: this + } + })); + } + + return _createMediaElement.call(this, options); + } + + const _destroy = JadefinModules.htmlVideoPlayer.prototype.destroy; + JadefinModules.htmlVideoPlayer.prototype.destroy = function() { + if (JadefinUtils.htmlVideoPlayers.delete(this)) { + self.log.i("Removing old htmlVideoPlayer"); + self.log.dir(this); + + JadefinUtils.events.dispatchEvent(new CustomEvent(JadefinUtils.eventTypes.HTML_VIDEO_PLAYER_CHANGED, { + detail: { + alive: false, + player: this + } + })); + } + + return _destroy.call(this); + } + } + + get webpackModuleFuncs() { + return this._webpackModuleFuncs ??= this.webpackLoad ? Object.values(this.webpackLoad).find(v => typeof(v) == "object" && Object.keys(v).length > 10) : null; + } + + /** + * @param {(e: any) => any} cb + */ + findWebpackRawLoad(cb) { + return Object.keys(this.webpackModuleFuncs).map(id => this.webpackLoad?.(id)).filter(e => e && cb(e)); + } + + /** + * @param {(e: any) => any} cb + */ + findWebpackModules(cb) { + return Object.keys(this.webpackModuleFuncs).map(id => this.webpackLoad?.(id)?.default).filter(e => e && cb(e)); + } + + /** + * @param {(e: any) => any} cb + */ + findWebpackFunctions(cb) { + return Object.keys(this.webpackModuleFuncs).map(id => this.webpackLoad?.(id)).filter(e => e && e instanceof Function && cb(e)); + } + + /** + * @param {string} name + */ + getMod(name) { + let found = this.mods[name]; + + if (found) { + return found; + } + + for (let mod of Object.values(this.mods)) { + if (mod.isMod(name)) { + return mod; + } + } + + return undefined; + } + + /** + * @param {string[]} [list] + */ + async _loadMods(list) { + if (!list) { + // HACK: I'm hosting Jadefin on my own Jellyfin subdomain, but want my own mods... oops! + let listUrl = window.location.host == "jellyfin.0x0a.de" ? `${this.root}/mods_jade.json` : `${this.root}/mods.json`; + listUrl = localStorage.getItem("jadefin-mods") || listUrl; + + this.log.i(`Loading main mod list ${listUrl}`); + + list = window["JadefinModList"] || (await fetch(listUrl).then(r => r.json())); + } + + if (!list) { + return; + } + + await Promise.all(list.map(async (/** @type {string} */ entry) => { + let isList = false; + + if (entry.startsWith("list:")) { + isList = true; + entry = entry.substring("list:".length); + } + + let name = entry; + let url = `${this.root}/mods/${entry}`; + + if (entry.startsWith("//")) { + url = window.location.protocol + entry; + + } else if (entry.startsWith("/")) { + url = window.location.origin + entry; + + } else if (entry.indexOf(":") < entry.indexOf("?")) { + url = entry; + } + + let split = name.indexOf("?"); + if (split != -1) { + name = name.substring(0, split); + } + + split = name.lastIndexOf("/"); + if (split != -1) { + name = name.substring(split + 1); + } + + split = name.lastIndexOf("."); + if (split != -1) { + name = name.substring(0, split); + } + + if (isList) { + this.log.i(`Loading mod list ${url}`); + + const list = await fetch(url).then(r => r.json()); + + if (list) { + await this._loadMods(list); + } + + return; + } + + this.log.i(`Loading mod ${name} from ${url}`); + + try { + /** @type {{default: JadefinMod}} */ + const mod = await import(`${url}`); + const module = mod.default; + + if (this._loadingMods_dedupe.delete(module)) { + this._loadingMods_dedupe.add(module); + throw new Error("Duplicate module"); + } + + this._loadingMods_dedupe.add(module); + + this.mods[name] = module; + + this._loadingMods_all.push({module, name, url}); + } catch (e) { + this.log.e(`Failed to load ${name}`); + this.log.dir(e); + } + })); + + } + + async initMods(list) { + await Promise.all(this._loadingMods_all.map(async (mod) => { + this.log.i(`Initializing mod ${mod.name}`); + + try { + await mod.module.init(mod.name, mod.url); + } catch (e) { + this.log.e(`Failed to initialize ${mod.name}`); + this.log.dir(e); + } + })); + + } + +})()); diff --git a/JadefinIntegrity.js b/JadefinIntegrity.js new file mode 100644 index 0000000..4ac4101 --- /dev/null +++ b/JadefinIntegrity.js @@ -0,0 +1,54 @@ +//@ts-check + +const ID = "JadefinIntegrity"; + +class JadefinIntegrity { + /** @type {Map} */ + loaded = new Map(); + + constructor() { + this.getOrAdd(ID, import.meta.url, () => this); + } + + /** + * @template T + * @param {string} id + * @param {string} [url] + * @param {() => T} [cb] + * @returns {T} + */ + getOrAdd(id, url, cb) { + let entry = this.loaded.get(id); + + if (entry) { + if (url && url != entry.url) { + window["Jadefin"].log.w(`INTEGRITY CONFLICT for "${id}", loaded vs requested: \n ${entry.url} \n ${url}`); + } + + } else if (!url || !cb) { + throw new Error(`"${id}" not loaded`); + + } else { + entry = { + url, + loaded: cb() + }; + + this.loaded.set(id, entry); + } + + return entry.loaded; + } + +} + +if (window[ID]) +{ + window[ID].getOrAdd(ID, import.meta.url, () => null); +} + +/** @type {JadefinIntegrity} */ +const integrity = window[ID] ??= new JadefinIntegrity(); + +/** @type {typeof JadefinIntegrity.prototype.getOrAdd} */ +export default integrity.getOrAdd.bind(integrity); diff --git a/JadefinLog.js b/JadefinLog.js new file mode 100644 index 0000000..fcce9ea --- /dev/null +++ b/JadefinLog.js @@ -0,0 +1,84 @@ +//@ts-check + +import JadefinIntegrity from "./JadefinIntegrity.js"; + +import cyrb53a from "./utils/cyrb53.js"; + +export default JadefinIntegrity("JadefinLog", import.meta.url, () => class JadefinLog { + tag; + + /** @type {{[key: string]: string}} */ + style = { + v: `background: black; color: gray; font-weight: 300; font-family: monospace; font-size: 1em; line-height: 2em;`, + i: `background: black; color: white; font-weight: 300; font-family: monospace; font-size: 1em; line-height: 2em;`, + w: `background: black; color: yellow; font-weight: 300; font-family: monospace; font-size: 1em; line-height: 2em;`, + e: `background: #ff0020; color: black; font-weight: 300; font-family: monospace; font-size: 1em; line-height: 2em;` + }; + + /** @type {(data: string) => void} */ + v; + /** @type {(data: string) => void} */ + i; + /** @type {(data: string) => void} */ + w; + /** @type {(data: string) => void} */ + e; + + /** + * @param {string} tag + */ + constructor(tag) { + this.tag = tag; + + let fg = "black"; + const bgInt = Math.floor((Math.abs(Math.sin(cyrb53a(tag)) * 16777215))); + const bgR = (bgInt >> 16) & 0xff; + const bgG = (bgInt >> 8) & 0xff; + const bgB = (bgInt >> 0) & 0xff; + const bgLuma = 0.2126 * bgR + 0.7152 * bgG + 0.0722 * bgB; + + if (bgLuma < 40) { + fg = "white"; + } + + const bg = "#" + bgInt.toString(16); + + this.style.tag = `background: ${bg}; color: ${fg}; font-weight: 600; font-family: monospace; font-size: 1.5em; line-height: 1.25em;`; + + this.update(); + } + + update() { + this.v = console.info.bind(console, `%c ${this.tag} %c %s `, this.style.tag, this.style.v); + this.i = console.info.bind(console, `%c ${this.tag} %c %s `, this.style.tag, this.style.i); + this.w = console.info.bind(console, `%c ${this.tag} %c %s `, this.style.tag, this.style.w); + this.e = console.info.bind(console, `%c ${this.tag} %c %s `, this.style.tag, this.style.e); + } + + /** + * @param {any[]} args + * @returns {this} + */ + dir(...args) { + if (args.length == 0) { + return this; + } + + if (args.length == 1) { + console.dir(args[0]); + + return this; + } + + console.groupCollapsed(`${args.length} objects`); + + for (let arg of args) { + console.dir(arg); + } + + console.groupEnd(); + + return this; + } + +}); diff --git a/JadefinMod.js b/JadefinMod.js new file mode 100644 index 0000000..dc2d13e --- /dev/null +++ b/JadefinMod.js @@ -0,0 +1,61 @@ +//@ts-check + +import JadefinIntegrity from "./JadefinIntegrity.js"; + +import JadefinLog from "./JadefinLog.js"; +import JadefinStorage from "./JadefinStorage.js"; + +import { rd, rdom, rd$, RDOMListHelper } from "./utils/rdom.js"; + +class JadefinMod { + log = new JadefinLog(this.constructor.name); + storage = new JadefinStorage(name => `jadefin.${this.modName}.${name}`); + + /** + * @param {string} name + * @param {string} url + */ + async init(name, url) { + this.modName = name; + this.modUrl = url; + } + + initStyle() { + document.head.appendChild(rd$()``); + } + + /** + * @param {string} name + */ + isMod(name) { + return this.modName == name || this.constructor.name == name; + } + + /** + * @param {string} name + */ + getUrl(name) { + if (!this.modUrl) { + return ""; + } + + const base = this.modUrl.substring(0, this.modUrl.lastIndexOf("/") + 1); + let ext = ""; + + let split = this.modUrl.indexOf("?"); + if (split != -1) { + ext = this.modUrl.substring(split); + } + + if (name.startsWith(".")) { + return `${base}${this.modName}${name}`; + } + + return `${base}${name}`; + } + +} + +// @ts-ignore +JadefinMod = JadefinIntegrity("JadefinMod", import.meta.url, () => JadefinMod); +export default JadefinMod; diff --git a/JadefinModules.js b/JadefinModules.js new file mode 100644 index 0000000..418c5ab --- /dev/null +++ b/JadefinModules.js @@ -0,0 +1,238 @@ +//@ts-check + +import JadefinIntegrity from "./JadefinIntegrity.js"; + +export default JadefinIntegrity("JadefinModules", import.meta.url, () => window["JadefinModules"] = new (class JadefinModules { + /** @type {{[id: string]: any}} */ + _ = {}; + + /** + * @param {boolean} [border] + */ + _test_actionSheet(border) { + this.actionSheet.show({ + border: !!border, /* Divider between every item, if not disabled by theme. */ + dialogClass: "test", + title: "Test", + text: `This is a test dialog\nborder: ${!!border}`, + positionTo: null, + items: [ + {id: "test1", name: "Test 1"}, + {divider: true}, + {id: "test2", name: "Test 2", selected: true}, + {id: "test3", name: "Test 3", icon: "add", asideText: "Auto"}, + {id: "test4", name: "Test 4", icon: "remove", selected: true, secondaryText: "Do something", asideText: "1x", itemClass: "test"}, + ], + // showCancel: true, // Seemingly unused, looks wrong + timeout: false + }).then(id => { + if (!id) { + return; + } + + console.log(`test selected ${id} (${typeof(id)})`); + + this._test_actionSheet(!border); + }); + } + + /** @type {import("./Jadefin.js").default} */ + get Jadefin() { + if (!this._.Jadefin) { + throw new Error("JadefinModules.Jadefin == null!"); + } + + return this._.Jadefin; + } + set Jadefin(value) { + if (!value) { + throw new Error("JadefinModules.Jadefin = null!"); + } + + if (this._.Jadefin) { + value.log.w("Replacing JadefinModules.Jadefin"); + } + + this._.Jadefin = value; + } + + // Emby + /** + @return { + any + } + */ + get Emby() { + return this._.Emby ??= window["Emby"]; + } + + // apiClient + /** + @return { + any + } + */ + get ApiClient() { + return this._.ApiClient ??= window["ApiClient"]; + } + + // ActionSheet + /** + @return {{ + show: (options: any) => Promise + }} + */ + get actionSheet() { + return this._.actionSheet ??= this.Jadefin.findWebpackModules(e => (e.show?.toString()?.indexOf(`actionSheetContent`) || -1) != -1)[0]; + } + + // escape-html + /** @return {(text: string) => string} */ + get escapeHtml() { + return this._.escapeHtml ??= this.Jadefin.findWebpackFunctions(e => e.toString().indexOf(`{switch(r.charCodeAt(a)){case 34`) != -1)[0]; + } + + // toast + /** @return {(text: string) => void} */ + get toast() { + return this._.toast ??= this.Jadefin.findWebpackRawLoad(e => (e.Z?.toString().indexOf(`toast"),t.textContent=`) || -1) != -1)[0]?.Z; + } + + // plugins/syncPlay/core/index + /** + @return {{ + Helper: any, + Manager: any, + PlayerFactory: any, + Players: any + }} + */ + get syncPlay() { + return this._.syncPlay ??= this.Jadefin.findWebpackRawLoad(e => e.Z?.Manager?.isSyncPlayEnabled)[0]?.Z; + } + + // inputManager + /** + @return {{ + handleCommand: (commandName: string, options: any) => void, + notify: () => void, + notifyMouseMove: () => void, + idleTime: () => number, + on: (scope: string, fn: Function) => void, + off: (scope: string, fn: Function) => void + }} + */ + get inputManager() { + return this._.inputManager ??= this.Jadefin.findWebpackModules(e => e.handleCommand && e.notify && e.idleTime)[0]; + } + + // playbackManager + /** + @return {any} + */ + get playbackManager() { + return this._.playbackManager ??= this.Jadefin.findWebpackRawLoad(e => e.O?.canHandleOffsetOnCurrentSubtitle)[0]?.O; + } + + // plugins/htmlVideoPlayer/plugin.js + /** + @return {any} + */ + get htmlVideoPlayer() { + return this._.htmlVideoPlayer ??= this.Jadefin.findWebpackModules(e => e.getDeviceProfileInternal)[0]; + } + + // taskbutton + /** + @return { + (options: { + mode: string, + taskKey: string, + button: Element, + panel?: Element | undefined | null, + progressElem?: Element | undefined | null, + lastResultElem?: Element | undefined | null + }) => void + } + */ + get taskButton() { + return this._.taskButton ??= this.Jadefin.findWebpackRawLoad(e => (e.Z?.toString().indexOf(`sendMessage("ScheduledTasksInfoStart","1000,1000")`) || -1) != -1)[0]?.Z; + } + + // browser + /** + @return {{ + chrome: boolean, + edg: boolean, + edga: boolean, + edgios: boolean, + edge: boolean, + opera: boolean, + opr: boolean, + safari: boolean, + firefox: boolean, + mozilla: boolean, + version: boolean, + ipad: boolean, + iphone: boolean, + windows: boolean, + android: boolean, + versionMajor: number, + edgeChromium: boolean, + osx: boolean, + ps4: boolean, + tv: boolean, + mobile: boolean, + xboxOne: boolean, + animate: boolean, + hisense: boolean, + tizen: boolean, + vidaa: boolean, + web0s: boolean, + edgeUwp: boolean, + tizenVersion?: number, + orsay: boolean, + operaTv: boolean, + slow: boolean, + touch: boolean, + keyboard: boolean, + supportsCssAnimation: (allowPrefix: boolean) => boolean, + iOS: boolean, + iOSVersion: string + }} + */ + get browser() { + return this._.browser ??= this.Jadefin.findWebpackRawLoad(e => e.Z && "supportsCssAnimation" in e.Z && "version" in e.Z)[0]?.Z; + } + + // datetime + /** + @return {{ + parseISO8601Date: (s: string, [toLocal]: boolean) => Date, + getDisplayDuration: (ticks: number) => string, + getDisplayRunningTime: (ticks: number) => string, + toLocaleString: (date: Date, options: any) => string, + toLocaleDateString: (date: Date, options: any) => string, + toLocaleTimeString: (date: Date, options: any) => string, + getDisplayTime: (date: Date) => string, + isRelativeDay: (date: Date, offsetInDays: number) => boolean, + supportsLocalization: () => boolean + }} + */ + get datetime() { + return this._.datetime ??= this.Jadefin.findWebpackRawLoad(e => e.ZP?.getDisplayRunningTime)[0]?.ZP; + } + + // events + /** + @return {{ + on: (obj: any, type: string, fn: (e: Event, ...args: any[]) => void) => void, + off: (obj: any, type: string, fn: (e: Event, ...args: any[]) => void) => void, + trigger: (obj: any, type: string, args: any[] = []) => void + }} + */ + get Events() { + return this._.events ??= this.Jadefin.findWebpackRawLoad(e => e.Events?.on && e.Events?.off && e.Events?.trigger)[0]?.Events; + } + +})()); diff --git a/JadefinStorage.js b/JadefinStorage.js new file mode 100644 index 0000000..67e1b77 --- /dev/null +++ b/JadefinStorage.js @@ -0,0 +1,50 @@ +//@ts-check + +import JadefinIntegrity from "./JadefinIntegrity.js"; + +class JadefinStorage { + getKey; + cache = []; + + /** + * @param {(name: string) => string} getKey + */ + constructor(getKey) { + this.getKey = getKey; + } + + /** + * @param {string} name + * @param {any} [fallback] + */ + get(name, fallback) { + let value = this.cache[name]; + + if (value != null && value != undefined) { + return value; + } + + value = localStorage.getItem(this.getKey(name)); + + if (value != null && value != undefined) { + try { + return this.cache[name] = JSON.parse(value); + } catch { + } + } + + return this.cache[name] = fallback; + } + + /** + * @param {string} name + * @param {any} value + */ + set(name, value) { + localStorage.setItem(this.getKey(name), JSON.stringify(this.cache[name] = value)); + } +} + +// @ts-ignore +JadefinStorage = JadefinIntegrity("JadefinStorage", import.meta.url, () => JadefinStorage); +export default JadefinStorage; diff --git a/JadefinUtils.js b/JadefinUtils.js new file mode 100644 index 0000000..9312c09 --- /dev/null +++ b/JadefinUtils.js @@ -0,0 +1,93 @@ +//@ts-check + +import JadefinIntegrity from "./JadefinIntegrity.js"; + +import JadefinModules from "./JadefinModules.js"; + +export default JadefinIntegrity("JadefinUtils", import.meta.url, () => window["JadefinUtils"] = new (class JadefinUtils { + events = new EventTarget(); + eventTypes = { + WORKER_CREATING: "workerCreating", + WORKER_CREATED: "workerCreated", + HTML_VIDEO_PLAYER_CHANGED: "htmlVideoPlayerChanged" + }; + + htmlVideoPlayers = new Set(); + /** @type {any} */ + htmlVideoPlayer = null; + + get isInMovie() { + return !!document.querySelector(".osdHeader"); + } + + get video() { + return /** @type {HTMLVideoElement} */ (document.querySelector(".videoPlayerContainer > video.htmlvideoplayer")); + } + + get assCanvas() { + return /** @type {HTMLCanvasElement} */ (document.querySelector(".videoPlayerContainer > .libassjs-canvas-parent > canvas.libassjs-canvas")); + } + + get routePath() { + return JadefinModules.Emby.Page.currentRouteInfo.route.path; + } + + get routePathIsVideo() { + return this.routePath == "playback/video/index.html"; + } + + get currentPlayer() { + return JadefinModules.playbackManager._currentPlayer; + } + + /** + * @param {() => any} cb + * @param {number} [tries] + * @param {number} [time] + */ + waitUntil(cb, tries, time) { + let triesLeft = tries || Number.POSITIVE_INFINITY; + + return new Promise((resolve, reject) => { + const interval = setInterval(() => { + const v = cb(); + + if (v) { + resolve(v); + clearInterval(interval); + } else if ((--triesLeft) <= 0) { + reject(v); + clearInterval(interval); + } + }, time || 100); + }); + } + + /** + * @template T + * @param {T | null | undefined} value + * @returns {T} + */ + notnull(value) { + if (value === null || value === undefined) { + throw new Error("notnull encountered null"); + } + + return value; + } + + /** + * @param {Node | null} el + * @param {Node} check + */ + hasParent(el, check) { + for (let curr = el; curr; curr = curr?.parentNode) { + if (curr == check) { + return true; + } + } + + return false; + } + +})()); diff --git a/README.md b/README.md new file mode 100644 index 0000000..f391672 --- /dev/null +++ b/README.md @@ -0,0 +1,6 @@ +# jadefin +Collection of random Jellyfin mods, mainly used on 0x0ade's private jellyfin instance, +but shared with the world because why not ¯\\_(ツ)\_/¯ + +## Usage instructions +WIP. diff --git a/init.js b/init.js new file mode 100644 index 0000000..286ca3c --- /dev/null +++ b/init.js @@ -0,0 +1,105 @@ +//@ts-check + +let jadefinInit = /** @type {HTMLScriptElement} */ (document.currentScript); + +(() => { + let loadedJellyfin = false; + let initedJadefin = false; + + function loadJadefin() { + return new Promise((resolve, reject) => { + console.log("[jadefin-init] loadJadefin"); + + const loader = document.createElement("script"); + this.document.head.appendChild(loader); + + window["_JadefinLoaded"] = () => resolve(true); + window["_JadefinErrored"] = (e) => reject(e); + + const modUrl = jadefinInit.src; + console.log("[jadefin-init] loadJadefin src", modUrl); + + const rootAuto = modUrl.substring(0, modUrl.lastIndexOf("/")); + console.log("[jadefin-init] loadJadefin root auto", rootAuto); + + // Set to https://jellyfin.0x0a.de/jadefin/dev to use the dev branch. + const rootManual = localStorage.getItem("jadefin-root"); + if (rootManual) { + console.log("[jadefin-init] loadJadefin root manual", rootManual); + } + + window["_JadefinRoot"] = rootManual || rootAuto; + + loader.type = "module"; + loader.innerHTML = ` + try { + import(window["_JadefinRoot"] + "/Jadefin.js"); + } catch (e) { + console.error(e); + window["_JadefinErrored"](); + } + + delete window["_JadefinErrored"]; + delete window["_JadefinRoot"]; + `; + }); + } + + function loadJellyfin() { + return new Promise((resolve, reject) => { + console.log("[jadefin-init] loadJellyfin"); + + if (loadedJellyfin) { + resolve(false); + return; + } + + loadedJellyfin = true; + + const self = document.querySelector("[data-main-jellyfin-bundle]"); + if (!self) { + console.error("[jadefin-init] loadJellyfin couldn't find data-main-jellyfin-bundle") + resolve(false); + return; + } + + const loader = document.createElement("script"); + this.document.head.appendChild(loader); + + loader.addEventListener("load", () => resolve(true)); + loader.addEventListener("error", (e) => reject(e)); + + loader.defer = true; + loader.src = self.getAttribute("data-main-jellyfin-bundle") || "main.jellyfin.bundle.js"; + }); + } + + function initJadefin() { + return new Promise((resolve, reject) => { + console.log("[jadefin-init] initJadefin"); + + const cb = () => { + if (initedJadefin) { + return; + } + + initedJadefin = true; + + try { + window["Jadefin"].init(); + resolve(true); + } catch (e) { + console.error(e); + reject(e); + } + }; + + window.addEventListener("load", cb); + if (document.readyState == "complete") { + cb(); + } + }) + } + + loadJadefin().then(loadJellyfin).then(initJadefin).finally(loadJellyfin); +})(); diff --git a/mods.json b/mods.json new file mode 100644 index 0000000..1da8c56 --- /dev/null +++ b/mods.json @@ -0,0 +1,9 @@ +[ + "ExtrasMenu.js", + "VolumeBoost.js", + "FixStuck.js", + "VersionCheck.js", + "Screenshot.js", + "InputEater.js", + "Transcript.js" +] \ No newline at end of file diff --git a/mods/ExtrasMenu.css b/mods/ExtrasMenu.css new file mode 100644 index 0000000..c9e3252 --- /dev/null +++ b/mods/ExtrasMenu.css @@ -0,0 +1,14 @@ +.extrasMenuOptions .navMenuOptionTextBlock { + margin-top: 0; + text-align: start; +} + +.extrasMenuOptions .navMenuOptionTextSubtext { + font-size: 0.75em; + opacity: 0.8; +} + +.extrasMenuPopup button:disabled { + pointer-events: none; + opacity: 0.5; +} diff --git a/mods/ExtrasMenu.js b/mods/ExtrasMenu.js new file mode 100644 index 0000000..e792d6c --- /dev/null +++ b/mods/ExtrasMenu.js @@ -0,0 +1,203 @@ +//@ts-check + +import JadefinIntegrity from '../JadefinIntegrity.js'; + +import JadefinMod from "../JadefinMod.js"; +import JadefinModules from "../JadefinModules.js"; +import JadefinUtils from "../JadefinUtils.js"; + +import { rd, rdom, rd$, RDOMListHelper } from "../utils/rdom.js"; + +export default JadefinIntegrity("ExtrasMenu", import.meta.url, () => new (class ExtrasMenu extends JadefinMod { + IN_ALL = 0; + IN_CUSTOM = 1; + IN_DRAWER = 2; + IN_LIBRARY = 4; + IN_MOVIE = 8; + + _popupId = 0; + _items = []; + + /** @type {typeof this._items} */ + items = new Proxy(this._items, { + deleteProperty: (target, property) => { + delete target[property]; + + this.update(); + + return true; + }, + + set: (target, property, value, receiver) => { + target[property] = value; + + this.update(); + + return true; + } + }); + + headerExtrasEl = rd$()` + + `; + + drawerExtrasEl = rd$()` +
+
+ `; + + constructor() { + super(); + + this.items.push({ + name: "Reload", + secondaryText: "Fully reload and update Jellyfin", + icon: "update", + in: this.IN_LIBRARY, + cb: async () => { + try { + await window.caches.delete("embydata"); + } catch (e) { + } + try { + await window.caches.delete(`workbox-precache-v2-${JadefinModules.Emby.Page.baseRoute}/`); + } catch (e) { + } + window.location.reload(); + } + }); + + this.headerExtrasEl.addEventListener("click", e => this.openExtrasPopup()); + } + + async init(name, url) { + await super.init(name, url); + + this.initStyle(); + this.initHeaderExtras(); + this.initDrawerExtras(); + + this.log.i("Ready"); + } + + initHeaderExtras() { + document.querySelector(".headerRight")?.appendChild(this.headerExtrasEl); + } + + /** + * @param {boolean} [silentWarn] + */ + initDrawerExtras(silentWarn) { + const drawer = document.querySelector(".mainDrawer > .mainDrawer-scrollContainer"); + const userMenuOptions = drawer?.querySelector("& > .userMenuOptions"); + + if (!userMenuOptions) { + if (!silentWarn) { + this.log.w("Couldn't find mainDrawer / home userMenuOptions, retrying"); + } + + setTimeout(() => this.initDrawerExtras(true), 1500); + + return; + } + + drawer?.insertBefore(this.drawerExtrasEl, userMenuOptions); + + this.update(); + } + + openExtrasPopup() { + const currentVisibility = JadefinUtils.isInMovie ? this.IN_MOVIE : this.IN_LIBRARY; + + const dialogClass = `extrasMenuPopup-${this._popupId++}`; + + const items = this._items.map((item, i) => Object.assign({ + id: i + }, item)).filter(item => this.checkVisibility(currentVisibility, item)); + + const p = JadefinModules.actionSheet.show({ + dialogClass, + title: "Extras", + positionTo: this.headerExtrasEl, + items + }); + + p.then(id => { + if (!id) { + return; + } + + this._items[id]?.cb?.(); + }); + + const dialogEl = document.querySelector(`.${dialogClass}`); + if (!dialogEl) { + this.log.e(`Couldn't find .${dialogClass}`); + return; + } + + this.log.i(`Opened .${dialogClass}`); + this.log.dir(dialogEl); + + dialogEl.classList.add("extrasMenuPopup"); + + const dialogButtons = dialogEl.querySelectorAll("button"); + + for (let i in items) { + items[i].cbEl?.(dialogButtons[i], currentVisibility, true); + } + + p.finally(() => { + for (let i in items) { + items[i].cbEl?.(dialogButtons[i], currentVisibility, false); + } + }); + } + + update() { + if (this.drawerExtrasEl) { + this.drawerExtrasEl.innerHTML = `

Extras

`; + + for (let item of this._items) { + if (!this.checkVisibility(this.IN_DRAWER, item)) { + continue; + } + + let itemEl = rd$()` + + + + + `; + + itemEl.addEventListener("click", e => item.cb?.()); + + item.cbEl?.(itemEl, this.IN_DRAWER); + + this.drawerExtrasEl.appendChild(itemEl); + } + } + } + + /** + * @param {number} current + * @param {any} item + */ + checkVisibility(current, item) { + if ((item.in || this.IN_ALL) == this.IN_ALL) { + return true; + } + + if ((item.in & this.IN_CUSTOM) == this.IN_CUSTOM) { + return item.inCustom(current, item); + } + + return (item.in & current) == current; + } + +})()); diff --git a/mods/FixStuck.js b/mods/FixStuck.js new file mode 100644 index 0000000..4a2a8be --- /dev/null +++ b/mods/FixStuck.js @@ -0,0 +1,39 @@ +//@ts-check + +import JadefinIntegrity from '../JadefinIntegrity.js'; + +import JadefinMod from "../JadefinMod.js"; + +export default JadefinIntegrity("FixStuck", import.meta.url, () => new (class FixStuck extends JadefinMod { + constructor() { + super(); + } + + async init(name, url) { + await super.init(name, url); + + const time = + window.location.hash.startsWith("#!/dialog?") ? 500 : + 2500; + + setTimeout(() => { + if (this.isStuck) { + this.log.w("Checked, stuck - reloading"); + window.location.hash = ""; + window.location.reload(); + } else { + this.log.i("Checked, not stuck"); + } + }, time); + + this.log.i("Ready"); + } + + get isStuck() { + return ( + (document.querySelector(".mainAnimatedPages.skinBody")?.childElementCount ?? 0) == 0 && + !document.querySelector(".mainDrawer > .mainDrawer-scrollContainer > .userMenuOptions") + ); + } + +})()); diff --git a/mods/InputEater.css b/mods/InputEater.css new file mode 100644 index 0000000..c8c4406 --- /dev/null +++ b/mods/InputEater.css @@ -0,0 +1,27 @@ +body[input-eaten=true] div#videoOsdPage { + --disabled: 0.5; +} +body[input-eaten=true] div#videoOsdPage::before { + content: ""; + background: transparent; + position: fixed; + top: 0; + left: 0; + margin-top: 64px; + width: 100vw; + height: 100vh; +} +body[input-eaten=true] div#videoOsdPage > .videoOsdBottom > .osdControls > .flex > .sliderContainer > input { + pointer-events: none; + opacity: var(--disabled); +} +body[input-eaten=true] div#videoOsdPage > .videoOsdBottom > .osdControls > .buttons .btnRecord, +body[input-eaten=true] div#videoOsdPage > .videoOsdBottom > .osdControls > .buttons .btnPreviousTrack, +body[input-eaten=true] div#videoOsdPage > .videoOsdBottom > .osdControls > .buttons .btnPreviousChapter, +body[input-eaten=true] div#videoOsdPage > .videoOsdBottom > .osdControls > .buttons .btnRewind, +body[input-eaten=true] div#videoOsdPage > .videoOsdBottom > .osdControls > .buttons .btnPause, +body[input-eaten=true] div#videoOsdPage > .videoOsdBottom > .osdControls > .buttons .btnFastForward, +body[input-eaten=true] div#videoOsdPage > .videoOsdBottom > .osdControls > .buttons .btnNextChapter, +body[input-eaten=true] div#videoOsdPage > .videoOsdBottom > .osdControls > .buttons .btnNextTrack { + display: none; +}/*# sourceMappingURL=InputEater.css.map */ \ No newline at end of file diff --git a/mods/InputEater.css.map b/mods/InputEater.css.map new file mode 100644 index 0000000..4ab21e7 --- /dev/null +++ b/mods/InputEater.css.map @@ -0,0 +1 @@ +{"version":3,"sources":["InputEater.scss","InputEater.css"],"names":[],"mappings":"AAAA;EACI,eAAA;ACCJ;ADCI;EACI,WAAA;EACA,uBAAA;EACA,eAAA;EACA,MAAA;EACA,OAAA;EACA,gBAAA;EACA,YAAA;EACA,aAAA;ACCR;ADGQ;EACI,oBAAA;EACA,wBAAA;ACDZ;ADKY;;;;;;;;EAQI,aAAA;ACHhB","file":"InputEater.css","sourcesContent":["body[input-eaten=\"true\"] div#videoOsdPage {\n --disabled: 0.5;\n\n &::before {\n content: \"\";\n background: transparent;\n position: fixed;\n top: 0;\n left: 0;\n margin-top: 64px;\n width: 100vw;\n height: 100vh;\n }\n\n > .videoOsdBottom > .osdControls {\n > .flex > .sliderContainer > input {\n pointer-events: none;\n opacity: var(--disabled);\n }\n\n > .buttons {\n .btnRecord,\n .btnPreviousTrack,\n .btnPreviousChapter,\n .btnRewind,\n .btnPause,\n .btnFastForward,\n .btnNextChapter,\n .btnNextTrack {\n display: none;\n }\n }\n }\n}\n","body[input-eaten=true] div#videoOsdPage {\n --disabled: 0.5;\n}\nbody[input-eaten=true] div#videoOsdPage::before {\n content: \"\";\n background: transparent;\n position: fixed;\n top: 0;\n left: 0;\n margin-top: 64px;\n width: 100vw;\n height: 100vh;\n}\nbody[input-eaten=true] div#videoOsdPage > .videoOsdBottom > .osdControls > .flex > .sliderContainer > input {\n pointer-events: none;\n opacity: var(--disabled);\n}\nbody[input-eaten=true] div#videoOsdPage > .videoOsdBottom > .osdControls > .buttons .btnRecord,\nbody[input-eaten=true] div#videoOsdPage > .videoOsdBottom > .osdControls > .buttons .btnPreviousTrack,\nbody[input-eaten=true] div#videoOsdPage > .videoOsdBottom > .osdControls > .buttons .btnPreviousChapter,\nbody[input-eaten=true] div#videoOsdPage > .videoOsdBottom > .osdControls > .buttons .btnRewind,\nbody[input-eaten=true] div#videoOsdPage > .videoOsdBottom > .osdControls > .buttons .btnPause,\nbody[input-eaten=true] div#videoOsdPage > .videoOsdBottom > .osdControls > .buttons .btnFastForward,\nbody[input-eaten=true] div#videoOsdPage > .videoOsdBottom > .osdControls > .buttons .btnNextChapter,\nbody[input-eaten=true] div#videoOsdPage > .videoOsdBottom > .osdControls > .buttons .btnNextTrack {\n display: none;\n}"]} \ No newline at end of file diff --git a/mods/InputEater.js b/mods/InputEater.js new file mode 100644 index 0000000..7d86c2d --- /dev/null +++ b/mods/InputEater.js @@ -0,0 +1,371 @@ +//@ts-check + +import JadefinIntegrity from '../JadefinIntegrity.js'; + +import Jadefin from "../Jadefin.js"; +import JadefinMod from "../JadefinMod.js"; +import JadefinModules from "../JadefinModules.js"; +import JadefinUtils from "../JadefinUtils.js"; + +import { rd, rdom, rd$, RDOMListHelper } from "../utils/rdom.js"; + +export default JadefinIntegrity("InputEater", import.meta.url, () => new (class InputEater extends JadefinMod { + hasShownPopupThisSession = false; + canBeEating = false; + + _isEnabled = true; + _isEating = false; + + /** @type {Element | undefined} */ + _lastOsdPage; + + _btnRemote = rd$()` + + `; + + constructor() { + super(); + + this._btnRemote.addEventListener("click", () => this.isEnabled = false); + + this.initHookDocumentAddEventListener(); + this.initHookDocumentRemoveEventListener(); + } + + get isEnabled() { + return this._isEnabled; + } + + set isEnabled(value) { + this._isEnabled = value; + + this.updateIsEating(); + } + + get shouldShowPopup() { + return this.storage.get("shouldShowPopup", true); + } + + set shouldShowPopup(value) { + this.storage.set("shouldShowPopup", value); + } + + get isEating() { + return this._isEating; + } + + set isEating(value) { + if (this._isEating == value) { + return; + } + + this.log.i(`isEating = ${value}`); + this._isEating = value; + + this.update(); + } + + async init(name, url) { + await super.init(name, url); + + this.initStyle(); + this.initHookSyncPlayEnabled(); + this.initHookSyncPlayDisabled(); + this.initHookMediaSessionHandlers(); + this.initHookInputManagerHandler(); + + document.addEventListener("viewshow", () => { + this.updateIsEating(); + }); + + const ExtrasMenu = /** @type {import("./ExtrasMenu.js").default} */ (Jadefin.getMod("ExtrasMenu")); + + ExtrasMenu.items.push({ + name: "Grab remote", + secondaryText: "Take control over the party", + icon: "settings_remote", + in: ExtrasMenu.IN_CUSTOM, + inCustom: (current, item) => { + if (this.isEnabled) { + item.name = "Grab remote"; + item.secondaryText = "Take control over the party"; + } else { + item.name = "Disable controls"; + item.secondaryText = "Give up control over the party"; + } + + return this.canBeEating; + }, + cb: () => { + this.isEnabled = !this.isEnabled; + } + }); + + this.log.i("Ready"); + } + + initHookSyncPlayEnabled() { + const orig = this._enableSyncPlay = JadefinModules.syncPlay.Manager.enableSyncPlay.bind(JadefinModules.syncPlay.Manager); + JadefinModules.syncPlay.Manager.enableSyncPlay = (apiClient, groupInfo, showMessage) => { + const rv = orig(apiClient, groupInfo, showMessage); + + this.updateIsEating(); + + return rv; + }; + } + + initHookSyncPlayDisabled() { + const orig = this._disableSyncPlay = JadefinModules.syncPlay.Manager.disableSyncPlay.bind(JadefinModules.syncPlay.Manager); + JadefinModules.syncPlay.Manager.disableSyncPlay = (showMessage) => { + const rv = orig(showMessage); + + this.updateIsEating(); + + return rv; + }; + } + + initHookDocumentAddEventListener() { + const orig = this._addEventListener = document.addEventListener.bind(document); + document.addEventListener = (type, listener, options) => { + if (type == "keydown") { + const listenerStr = listener.toString(); + + // Anonymous function in playback-video + if (listenerStr.indexOf(".btnPause") != -1 && + listenerStr.indexOf("32") != -1 && + listenerStr.indexOf("playPause") != -1) { + this.log.i("Wrapping playback-video keydown listener"); + this.log.dir(listener); + + const origListener = this._playbackKeyDown = listener; + listener = this._playbackKeyDownWrap = (e) => { + if (this.isEating) { + if (e.keyCode == 32) { + return; + } + + switch (e.key) { + case "ArrowLeft": + case "ArrowRight": + case "Enter": + case "Escape": + case "Back": + case "k": + case "l": + case "ArrowRight": + case "Right": + case "j": + case "ArrowLeft": + case "Left": + case "p": + case "P": + case "n": + case "N": + case "NavigationLeft": + case "GamepadDPadLeft": + case "GamepadLeftThumbstickLeft": + case "NavigationRight": + case "GamepadDPadRight": + case "GamepadLeftThumbstickRight": + case "Home": + case "End": + case "0": + case "1": + case "2": + case "3": + case "4": + case "5": + case "6": + case "7": + case "8": + case "9": + case ">": + case "<": + case "PageUp": + case "PageDown": + return; + } + } + + return origListener(e); + }; + } + } + + return orig(type, listener, options); + }; + } + + initHookDocumentRemoveEventListener() { + const orig = this._removeEventListener = document.removeEventListener.bind(document); + document.removeEventListener = (type, listener, options) => { + if (listener == this._playbackKeyDown) { + listener = this._playbackKeyDownWrap; + } + + return orig(type, listener, options); + }; + } + + initHookMediaSessionHandlers() { + const owner = Jadefin.findWebpackRawLoad(e => e?.O?.nextTrack && e?.O?.fastForward)[0].O; + + if (!owner || !("mediaSession" in navigator)) { + this.log.e("Couldn't hook media session handlers"); + return; + } + + const basic = (name, e) => { + if (this.isEating) { + return; + } + + owner[name](owner.getCurrentPlayer()); + } + + const handlerTuples = [ + ["previoustrack", (e) => basic("previousTrack", e)], + ["nexttrack", (e) => basic("nextTrack", e)], + ["play", (e) => basic("unpause", e)], + ["pause", (e) => basic("pause", e)], + ["seekbackward", (e) => basic("rewind", e)], + ["seekforward", (e) => basic("fastForward", e)], + ["seekto", (e) => { + if (this.isEating) { + return; + } + + const player = owner.getCurrentPlayer(); + const item = owner.getPlayerState(player).NowPlayingItem; + const currentTime = Math.floor(item.RunTimeTicks ? item.RunTimeTicks / 10000 : 0); + const seekTime = 1000 * e.seekTime; + owner.seekPercent(seekTime / currentTime * 100, player); + }] + ]; + + const handlers = this._handlers = {}; + + for (let handlerTuple of handlerTuples) { + this.log.i(`Replacing media session action handler ${handlerTuple[0]}`); + // @ts-ignore + navigator.mediaSession.setActionHandler(handlerTuple[0], handlers[handlerTuple[0]] = handlerTuple[1].bind(this)); + } + } + + initHookInputManagerHandler() { + const orig = this._handleCommand = JadefinModules.inputManager.handleCommand.bind(JadefinModules.inputManager); + JadefinModules.inputManager.handleCommand = (commandName, options) => { + if (this.isEating) { + switch (commandName) { + case "nextchapter": + case "next": + case "nexttrack": + case "previous": + case "previoustrack": + case "previouschapter": + case "play": + case "pause": + case "playpause": + case "stop": + case "increaseplaybackrate": + case "decreaseplaybackrate": + case "fastforward": + case "rewind": + case "seek": + case "repeatnone": + case "repeatall": + case "repeatone": + return; + } + } + + return orig(commandName, options); + }; + } + + /** + * @param {boolean} [force] + */ + showPopup(force) { + if (!force && (!this.shouldShowPopup || this.hasShownPopupThisSession)) { + return false; + } + + this.hasShownPopupThisSession = true; + + const ExtrasMenu = /** @type {import("./ExtrasMenu.js").default} */ (Jadefin.getMod("ExtrasMenu")); + + JadefinModules.actionSheet.show({ + dialogClass: "inputEaterInfo", + positionTo: ExtrasMenu.headerExtrasEl, + title: "Controls disabled.", + text: "You can toggle it in this corner.", + items: [ + {name: "Okay", icon: "tune"} + ] + }); + + return true; + } + + async updateIsEating() { + let isSyncPlayEnabled = JadefinModules.syncPlay.Manager.isSyncPlayEnabled(); + let isSyncPlayOwner = true; + + if (isSyncPlayEnabled) { + const groupInfo = JadefinModules.syncPlay.Manager.groupInfo; + const currentUser = await JadefinModules.ApiClient.getCurrentUser(); + + isSyncPlayOwner = groupInfo.GroupName.indexOf(currentUser.Name) != -1; + } + + this.canBeEating = isSyncPlayEnabled && !isSyncPlayOwner && JadefinUtils.routePathIsVideo; + this.isEating = this.isEnabled && this.canBeEating; + } + + update() { + document.body.setAttribute("input-eaten", this.isEating ? "true" : "false"); + + if (this.isEating) { + this.canBeEating = true; + + if (!this.showPopup()) { + JadefinModules.toast("Controls disabled."); + } + } + + this._btnRemote.classList.toggle("hide", !this.isEating); + + const videoOsdPage = document.querySelector("div#videoOsdPage:not(.hide)"); + if (!videoOsdPage) { + return; + } + + if (this._lastOsdPage == videoOsdPage) { + return; + } + this._lastOsdPage = videoOsdPage; + + this.log.i("Adding event listener to videoOsdPage"); + this.log.dir(videoOsdPage); + + videoOsdPage.addEventListener(window.PointerEvent ? "pointerdown" : "click", e => { + if (this.isEating) { + e.stopPropagation(); + } + }, true); + + const buttons = videoOsdPage.querySelector(".osdControls > .buttons"); + if (this._btnRemote.parentElement != buttons && buttons) { + this.log.i("Adding remote button to osd buttons"); + this.log.dir(buttons); + + buttons.insertBefore(this._btnRemote, buttons.firstChild); + } + } + +})()); diff --git a/mods/InputEater.scss b/mods/InputEater.scss new file mode 100644 index 0000000..76cca1e --- /dev/null +++ b/mods/InputEater.scss @@ -0,0 +1,34 @@ +body[input-eaten="true"] div#videoOsdPage { + --disabled: 0.5; + + &::before { + content: ""; + background: transparent; + position: fixed; + top: 0; + left: 0; + margin-top: 64px; + width: 100vw; + height: 100vh; + } + + > .videoOsdBottom > .osdControls { + > .flex > .sliderContainer > input { + pointer-events: none; + opacity: var(--disabled); + } + + > .buttons { + .btnRecord, + .btnPreviousTrack, + .btnPreviousChapter, + .btnRewind, + .btnPause, + .btnFastForward, + .btnNextChapter, + .btnNextTrack { + display: none; + } + } + } +} diff --git a/mods/Screenshot.js b/mods/Screenshot.js new file mode 100644 index 0000000..271a466 --- /dev/null +++ b/mods/Screenshot.js @@ -0,0 +1,72 @@ +//@ts-check + +import JadefinIntegrity from '../JadefinIntegrity.js'; + +import Jadefin from "../Jadefin.js"; +import JadefinMod from "../JadefinMod.js"; +import JadefinUtils from "../JadefinUtils.js"; + +export default JadefinIntegrity("Screenshot", import.meta.url, () => new (class Screenshot extends JadefinMod { + canvas = document.createElement("canvas"); + + constructor() { + super(); + } + + async init(name, url) { + await super.init(name, url); + + const ExtrasMenu = /** @type {import("./ExtrasMenu.js").default} */ (Jadefin.getMod("ExtrasMenu")); + + ExtrasMenu.items.push({ + name: "Screenshot", + secondaryText: "Copy to clipboard", + icon: "camera", + in: ExtrasMenu.IN_MOVIE, + cb: () => { + this.copy(); + } + }); + } + + copy() { + const video = JadefinUtils.video; + if (!video) { + this.log.e("No video"); + return; + } + + this.canvas.width = video.videoWidth; + this.canvas.height = video.videoHeight; + + const ctx = this.canvas.getContext("2d"); + if (!ctx) { + this.log.e("No 2D context"); + return; + } + + ctx.drawImage(video, 0, 0); + + /* + const assCanvas = JadefinUtils.assCanvas; + if (assCanvas) { + ctx.drawImage(assCanvas, 0, 0); + } + */ + + this.canvas.toBlob(blob => { + if (!blob) { + this.log.e("No blob"); + return; + } + + navigator.clipboard.write([ + // @ts-ignore + new ClipboardItem({ + [blob.type]: blob + }) + ]); + }); + } + +})()); diff --git a/mods/Transcript.css b/mods/Transcript.css new file mode 100644 index 0000000..225f5ce --- /dev/null +++ b/mods/Transcript.css @@ -0,0 +1,87 @@ +div#videoOsdPage > .videoOsdBottom > .osdControls > .buttons .btnTranscript.enabled { + color: rgba(var(--accent), 0.8) !important; +} +div#videoOsdPage .osdTranscript { + position: absolute; + right: 3em; + bottom: 0; + -webkit-backdrop-filter: blur(16px); + backdrop-filter: blur(16px); + transition: opacity 0.1s ease-in-out; + opacity: 0; + pointer-events: none; + text-shadow: 0 0 1em black, 0 0 1em black, 0 0 0.5em black, 0 0 0.5em black, 0 0 0.25em black, 0 0 0.25em black, 0 0 3px black, 0 0 3px black, 0 0 2px black, 0 0 2px black; + font-weight: 600; +} +div#videoOsdPage .osdTranscript.enabled { + opacity: 1; + pointer-events: all; +} +div#videoOsdPage .osdTranscript .transcriptLogWrap { + max-width: min(40vw, 30em); + max-height: min(50vh, 30em); + overflow-y: scroll; + overflow-x: hidden; + scroll-snap-type: y proximity; +} +div#videoOsdPage .osdTranscript .transcriptLog { + border-collapse: collapse; + border-spacing: 0; + line-height: 1.5em; +} +div#videoOsdPage .osdTranscript .line { + position: relative; + z-index: 1; + scroll-snap-align: end; + --bg: #000000; + --bg-opacity: 0.25; + --bar: rgb(var(--accent, 0x00, 0xad, 0xee)); + --bar-opacity: 0; +} +body:not([input-eaten=true]) div#videoOsdPage .osdTranscript .line { + cursor: pointer; +} +div#videoOsdPage .osdTranscript .line::before, div#videoOsdPage .osdTranscript .line::after { + content: ""; + position: absolute; + z-index: -1; + transition: background-color 0.1s ease-in-out, opacity 0.1s ease-in-out; +} +div#videoOsdPage .osdTranscript .line::before { + top: 0; + left: 0; + bottom: 0; + right: 0; + background-color: var(--bg); + opacity: var(--bg-opacity); +} +div#videoOsdPage .osdTranscript .line::after { + top: 0; + left: 0; + bottom: 0; + width: 0.25em; + background-color: var(--bar); + opacity: var(--bar-opacity); + transform: scaleY(var(--progress)); + transform-origin: 0 0; +} +div#videoOsdPage .osdTranscript .line:nth-child(2n) { + --bg: #888888; +} +div#videoOsdPage .osdTranscript .line.active { + --bg: var(--bar); + --bg-opacity: 0.5; + --bar-opacity: 1; +} +div#videoOsdPage .osdTranscript .line .time { + display: inline-block; + margin: 0.75em 1em; + margin-right: 0; +} +div#videoOsdPage .osdTranscript .line .text { + overflow-wrap: anywhere; + padding: 0.75em; +} +div#videoOsdPage .osdTranscript .line[data-positive=false] { + display: none; +}/*# sourceMappingURL=Transcript.css.map */ diff --git a/mods/Transcript.css.map b/mods/Transcript.css.map new file mode 100644 index 0000000..2aaf2af --- /dev/null +++ b/mods/Transcript.css.map @@ -0,0 +1 @@ +{"version":3,"sources":["Transcript.scss","Transcript.css"],"names":[],"mappings":"AAEQ;EACI,0CAAA;ACDZ;ADKI;EACI,kBAAA;EACA,UAAA;EACA,SAAA;EAEA,mCAAA;UAAA,2BAAA;EAEA,oCAAA;EACA,UAAA;EACA,oBAAA;EAEA,2KACI;EAWJ,gBAAA;ACjBR;ADmBQ;EACI,UAAA;EACA,mBAAA;ACjBZ;ADoBQ;EACI,0BAAA;EACA,2BAAA;EACA,kBAAA;EACA,kBAAA;EAEA,6BAAA;ACnBZ;ADsBQ;EACI,yBAAA;EACA,iBAAA;EACA,kBAAA;ACpBZ;ADuBQ;EACI,kBAAA;EACA,UAAA;EAEA,sBAAA;EAoCA,aAAA;EACA,kBAAA;EACA,2CAAA;EACA,gBAAA;ACzDZ;ADoBY;EACI,eAAA;AClBhB;ADqBY;EACI,WAAA;EACA,kBAAA;EACA,WAAA;EAEA,uEAAA;ACpBhB;ADuBY;EACI,MAAA;EACA,OAAA;EACA,SAAA;EACA,QAAA;EAEA,2BAAA;EACA,0BAAA;ACtBhB;ADyBY;EACI,MAAA;EACA,OAAA;EACA,SAAA;EACA,aAAA;EAEA,4BAAA;EACA,2BAAA;EACA,kCAAA;EACA,qBAAA;ACxBhB;ADgCY;EACI,aAAA;AC9BhB;ADiCY;EACI,gBAAA;EACA,iBAAA;EACA,gBAAA;AC/BhB;ADkCY;EACI,qBAAA;EACA,kBAAA;EACA,eAAA;AChChB;ADmCY;EACI,uBAAA;EACA,eAAA;ACjChB;ADoCY;EACI,aAAA;AClChB","file":"Transcript.css","sourcesContent":["div#videoOsdPage {\n > .videoOsdBottom > .osdControls > .buttons .btnTranscript {\n &.enabled {\n color: rgba(var(--accent), 0.8) !important;\n }\n }\n\n .osdTranscript {\n position: absolute;\n right: 3em;\n bottom: 0;\n\n backdrop-filter: blur(16px);\n\n transition: opacity 0.1s ease-in-out;\n opacity: 0;\n pointer-events: none;\n\n text-shadow:\n 0 0 1em black,\n 0 0 1em black,\n 0 0 0.5em black,\n 0 0 0.5em black,\n 0 0 0.25em black,\n 0 0 0.25em black,\n 0 0 3px black,\n 0 0 3px black,\n 0 0 2px black,\n 0 0 2px black;\n\n font-weight: 600;\n\n &.enabled {\n opacity: 1;\n pointer-events: all;\n }\n\n .transcriptLogWrap {\n max-width: min(30vw, 30em);\n max-height: min(30vh, 30em);\n overflow-y: scroll;\n overflow-x: hidden;\n\n scroll-snap-type: y proximity;\n }\n\n .transcriptLog {\n border-collapse: collapse;\n border-spacing: 0;\n line-height: 1.5em;\n }\n\n .line {\n position: relative;\n z-index: 1;\n\n scroll-snap-align: end;\n\n body:not([input-eaten=\"true\"]) & {\n cursor: pointer;\n }\n\n &::before, &::after {\n content: \"\";\n position: absolute;\n z-index: -1;\n\n transition: background-color 0.1s ease-in-out, opacity 0.1s ease-in-out;\n }\n\n &::before {\n top: 0;\n left: 0;\n bottom: 0;\n right: 0;\n\n background-color: var(--bg);\n opacity: var(--bg-opacity);\n }\n\n &::after {\n top: 0;\n left: 0;\n bottom: 0;\n width: 0.25em;\n\n background-color: var(--bar);\n opacity: var(--bar-opacity);\n transform: scaleY(var(--progress));\n transform-origin: 0 0;\n }\n\n --bg: #000000;\n --bg-opacity: 0.25;\n --bar: rgb(var(--accent, 0x00, 0xad, 0xee));\n --bar-opacity: 0;\n\n &:nth-child(2n) {\n --bg: #888888;\n }\n\n &.active {\n --bg: var(--bar);\n --bg-opacity: 0.5;\n --bar-opacity: 1;\n }\n\n .time {\n display: inline-block;\n margin: 0.75em 1em;\n margin-right: 0;\n }\n\n .text {\n overflow-wrap: anywhere;\n padding: 0.75em;\n }\n\n &[data-positive=\"false\"] {\n display: none;\n }\n }\n }\n}\n","div#videoOsdPage > .videoOsdBottom > .osdControls > .buttons .btnTranscript.enabled {\n color: rgba(var(--accent), 0.8) !important;\n}\ndiv#videoOsdPage .osdTranscript {\n position: absolute;\n right: 3em;\n bottom: 0;\n backdrop-filter: blur(16px);\n transition: opacity 0.1s ease-in-out;\n opacity: 0;\n pointer-events: none;\n text-shadow: 0 0 1em black, 0 0 1em black, 0 0 0.5em black, 0 0 0.5em black, 0 0 0.25em black, 0 0 0.25em black, 0 0 3px black, 0 0 3px black, 0 0 2px black, 0 0 2px black;\n font-weight: 600;\n}\ndiv#videoOsdPage .osdTranscript.enabled {\n opacity: 1;\n pointer-events: all;\n}\ndiv#videoOsdPage .osdTranscript .transcriptLogWrap {\n max-width: min(30vw, 30em);\n max-height: min(30vh, 30em);\n overflow-y: scroll;\n overflow-x: hidden;\n scroll-snap-type: y proximity;\n}\ndiv#videoOsdPage .osdTranscript .transcriptLog {\n border-collapse: collapse;\n border-spacing: 0;\n line-height: 1.5em;\n}\ndiv#videoOsdPage .osdTranscript .line {\n position: relative;\n z-index: 1;\n scroll-snap-align: end;\n --bg: #000000;\n --bg-opacity: 0.25;\n --bar: rgb(var(--accent, 0x00, 0xad, 0xee));\n --bar-opacity: 0;\n}\nbody:not([input-eaten=true]) div#videoOsdPage .osdTranscript .line {\n cursor: pointer;\n}\ndiv#videoOsdPage .osdTranscript .line::before, div#videoOsdPage .osdTranscript .line::after {\n content: \"\";\n position: absolute;\n z-index: -1;\n transition: background-color 0.1s ease-in-out, opacity 0.1s ease-in-out;\n}\ndiv#videoOsdPage .osdTranscript .line::before {\n top: 0;\n left: 0;\n bottom: 0;\n right: 0;\n background-color: var(--bg);\n opacity: var(--bg-opacity);\n}\ndiv#videoOsdPage .osdTranscript .line::after {\n top: 0;\n left: 0;\n bottom: 0;\n width: 0.25em;\n background-color: var(--bar);\n opacity: var(--bar-opacity);\n transform: scaleY(var(--progress));\n transform-origin: 0 0;\n}\ndiv#videoOsdPage .osdTranscript .line:nth-child(2n) {\n --bg: #888888;\n}\ndiv#videoOsdPage .osdTranscript .line.active {\n --bg: var(--bar);\n --bg-opacity: 0.5;\n --bar-opacity: 1;\n}\ndiv#videoOsdPage .osdTranscript .line .time {\n display: inline-block;\n margin: 0.75em 1em;\n margin-right: 0;\n}\ndiv#videoOsdPage .osdTranscript .line .text {\n overflow-wrap: anywhere;\n padding: 0.75em;\n}\ndiv#videoOsdPage .osdTranscript .line[data-positive=false] {\n display: none;\n}"]} \ No newline at end of file diff --git a/mods/Transcript.js b/mods/Transcript.js new file mode 100644 index 0000000..6ddf581 --- /dev/null +++ b/mods/Transcript.js @@ -0,0 +1,629 @@ +//@ts-check + +import JadefinIntegrity from '../JadefinIntegrity.js'; + +import Jadefin from "../Jadefin.js"; +import JadefinMod from "../JadefinMod.js"; +import JadefinModules from "../JadefinModules.js"; +import JadefinUtils from "../JadefinUtils.js"; + +import { rd, rdom, rd$, RDOMListHelper } from "../utils/rdom.js"; + +const TICKS_PER_MS = 10000; + +export default JadefinIntegrity("Transcript", import.meta.url, () => new (class Transcript extends JadefinMod { + _currentPlayer; + _currentTrack; + + /** @type {any[] | null} */ + _currentSubsRaw = null; + /** @type {any[] | null} */ + _currentSubs = null; + + _isEnabled = false; + _isAutoscroll = true; + _ignoreScroll = true; + + /** @type {Element | undefined} */ + _lastOsdPage; + _lastHtmlVideoPlayer; + _lastOffs = Number.NaN; + _lastInView = Number.NaN; + + _btnTranscript = rd$()` + + `; + + _osdTranscript = rd$()` +
+
+
+
+
+ `; + + _osdTranscriptLogWrap = JadefinUtils.notnull(this._osdTranscript.querySelector(".transcriptLogWrap")); + _osdTranscriptLog = JadefinUtils.notnull(this._osdTranscript.querySelector(".transcriptLog")); + _osdTranscriptLogList = new RDOMListHelper(this._osdTranscriptLog); + + _ssaAssFetching; + _ssaAssObserver = new MutationObserver(this.onSsaAssCanvasAdded.bind(this)); + _ssaAssObserverConfig = { + childList: true + }; + + _osdBottomObserver = new ResizeObserver(this.updatePosition.bind(this)); + + constructor() { + super(); + + this._btnTranscript.addEventListener("click", () => this.isEnabled = !this.isEnabled); + + this._osdTranscriptLogWrap.addEventListener("scroll", () => { + if (!this._ignoreScroll) { + this._isAutoscroll = false; + } else if (window.onscrollend === undefined) { + this._ignoreScroll = false; + } + }); + + this._osdTranscriptLogWrap.addEventListener("scrollend", () => { + if (!this._ignoreScroll) { + this._isAutoscroll = false; + } else { + this._ignoreScroll = false; + } + }); + + JadefinUtils.events.addEventListener(JadefinUtils.eventTypes.WORKER_CREATED, e => { + const detail = e["detail"]; + + // TODO: 10.9 replaces JSSO / libssa-js with JASSUB, revisit once that's stable. + if (!detail.args.scriptURL.endsWith("/libraries/subtitles-octopus-worker.js")) { + return; + } + + /** @type {Worker} */ + const worker = detail.worker; + worker.addEventListener("message", this.onSsaAssWorkerMessage.bind(this)); + }); + } + + get isEnabled() { + return this._isEnabled; + } + + set isEnabled(value) { + if (!this._isEnabled && value) { + this._isAutoscroll = true; + this._lastOffs = Number.NaN; + this._lastInView = Number.NaN; + } + + this._isEnabled = value; + + this.update(); + } + + get currentSubsRaw() { + return JadefinUtils.currentPlayer == this._currentPlayer ? this._currentSubsRaw : null; + } + + get currentSubs() { + return JadefinUtils.currentPlayer == this._currentPlayer ? this._currentSubs : null; + } + + async init(name, url) { + await super.init(name, url); + + this.initStyle(); + const initing = [ + this.initHookSetTrackForDisplay(), + this.initHookFetchSubtitles(), + this.initHookSetOffset(), + ]; + + document.addEventListener("viewshow", () => { + this.update(); + }); + + await Promise.all(initing); + + this.log.i("Ready"); + } + + async initHookSetTrackForDisplay() { + await JadefinUtils.waitUntil(() => JadefinModules.htmlVideoPlayer); + + const self = this; + const orig = this._setTrackForDisplay = JadefinModules.htmlVideoPlayer.prototype.setTrackForDisplay; + JadefinModules.htmlVideoPlayer.prototype.setTrackForDisplay = function(videoElement, track, targetTextTrackIndex) { + self.log.i("Setting subtitles"); + self.log.dir(track); + + if (self._currentPlayer == this && + self._currentTrack?.DeliveryUrl && track?.DeliveryUrl && + self._currentTrack.DeliveryUrl == track.DeliveryUrl) { + // Subtitle track got "changed" for another reason, f.e. audio track change. + return orig.call(this, videoElement, track, targetTextTrackIndex); + } + + self.setSubtitles(track, null, true); + + const codec = track?.Codec?.toLowerCase(); + let format = ""; + let incrementFetchQueue = false; + + if (codec == "ssa" || codec == "ass") { + format = ".ass"; + incrementFetchQueue = true; + } + + // Required because ass subs are fetched separately. + if (incrementFetchQueue) { + if (self._ssaAssFetching != this) { + self.log.v("Manually incrementing fetch queue"); + self._ssaAssFetching = this; + self._ssaAssObserver.disconnect(); + + // @ts-ignore - not entirely correct, but video is non-null here. + self._ssaAssObserver.observe(JadefinUtils.video?.parentNode, self._ssaAssObserverConfig); + + this.incrementFetchQueue(); + } + } else if (self._ssaAssFetching == this) { + self.log.v("Manually decrementing fetch queue (sub change)"); + this._ssaAssFetching?.decrementFetchQueue(); + this._ssaAssFetching = null; + this._ssaAssObserver.disconnect(); + } + + if (format != "") { + // We need to fetch ass subs ourselves. + self.log.i("Manually fetching subtitles (non-subrip format)"); + self.log.dir(track); + + let url = JadefinModules.playbackManager.getSubtitleUrl(track, JadefinModules.playbackManager.currentItem(JadefinUtils.currentPlayer)); + url = url.replace(format, ".js"); + + this.incrementFetchQueue(); + + fetch(url) + .then(r => r.json()) + .then(r => self.setSubtitles(track, r)) + .finally(() => this.decrementFetchQueue()); + } + + return orig.call(this, videoElement, track, targetTextTrackIndex); + }; + } + + async initHookFetchSubtitles() { + await JadefinUtils.waitUntil(() => JadefinModules.htmlVideoPlayer); + + const self = this; + const orig = this._fetchSubtitles = JadefinModules.htmlVideoPlayer.prototype.fetchSubtitles; + JadefinModules.htmlVideoPlayer.prototype.fetchSubtitles = function(track, item) { + self.log.i("Fetching subtitles"); + self.log.dir(track); + + const rv = orig.call(this, track, item); + + rv.then(r => self.setSubtitles(track, r), () => self.setSubtitles(track, null)); + + return rv; + }; + } + + async initHookSetOffset() { + await JadefinUtils.waitUntil(() => JadefinModules.htmlVideoPlayer); + + const self = this; + + const resetSubtitleOffset = this._resetSubtitleOffset = JadefinModules.htmlVideoPlayer.prototype.resetSubtitleOffset; + JadefinModules.htmlVideoPlayer.prototype.resetSubtitleOffset = function() { + var rv = resetSubtitleOffset.call(this); + + self.update(); + + return rv; + }; + + const updateCurrentTrackOffset = this._resetSubtitleOffset = JadefinModules.htmlVideoPlayer.prototype.updateCurrentTrackOffset; + JadefinModules.htmlVideoPlayer.prototype.updateCurrentTrackOffset = function(offsetValue, currentTrackIndex) { + var rv = updateCurrentTrackOffset.call(this, offsetValue, currentTrackIndex); + + self.update(); + + return rv; + }; + } + + onSsaAssCanvasAdded() { + const htmlVideoPlayer = this._ssaAssFetching; + + this._ssaAssObserver.disconnect(); + + this.log.v("Manual fetch queue progress - SsaAss canvas updated"); + + if (!htmlVideoPlayer) { + this.log.v("Manual fetch queue fail - no htmlVideoPlayer"); + + return; + } + + // The canvas gets added relatively early, therefore hook drawImage. + // TODO: 10.9 replaces JSSO / libssa-js with JASSUB, revisit once that's stable. + + const canvas = JadefinUtils.video.parentNode?.querySelector("canvas"); + const ctx = canvas?.getContext("2d"); + + if (!ctx) { + this.log.v("Manually decrementing fetch queue (no 2d ctx)"); + htmlVideoPlayer.decrementFetchQueue(); + + return; + } + + // TODO: drawImage doesn't fire for ass / ssa scenarios where subs start late. + const orig = ctx.drawImage.bind(ctx); + ctx.drawImage = (...args) => { + if (!this._ssaAssFetching) { + return orig(...args); + } + + this.log.v("Manually decrementing fetch queue (drawImage)"); + + this._ssaAssFetching.decrementFetchQueue(); + this._ssaAssFetching = null; + + return orig(...args); + }; + } + + onSsaAssWorkerMessage(e) { + if (!this._ssaAssFetching) { + return; + } + + if (e.data.target == "canvas" && e.data.op == "oneshot-result") { + this.log.v("Manually decrementing fetch queue (worker message)"); + + this._ssaAssFetching.decrementFetchQueue(); + this._ssaAssFetching = null; + } + } + + setSubtitles(track, subs, force) { + if (!force && this._currentTrack != track) { + this.log.w("Late sub fetch"); + return; + } + + if (subs) { + this.log.i("Subtitles updated"); + this.log.dir(subs); + } else { + this.log.i("Subtitles reset"); + } + + this._currentPlayer = JadefinUtils.currentPlayer; + this._currentTrack = track; + const raw = this._currentSubsRaw = /** @type {any[] | null} */ (subs?.TrackEvents); + + if (!raw) { + this._currentSubs = null; + } else { + const track = JadefinModules.playbackManager.subtitleTracks().find(t => t.Index == JadefinModules.playbackManager.getSubtitleStreamIndex()); + const codec = track?.Codec?.toLowerCase(); + let isAss = false; + + if (codec == "ssa" || codec == "ass") { + isAss = true; + } + + let subs = raw.map(l => ({ + id: l.Id, + timeStartRaw: l.StartPositionTicks, + timeEndRaw: l.EndPositionTicks, + timeStart: l.StartPositionTicks / TICKS_PER_MS, + timeEnd: l.EndPositionTicks / TICKS_PER_MS, + isPositive: true, + timeText: "", + text: l.Text, + textSpan: /** @type {HTMLElement | null} */ (null), + active: false, + el: /** @type {HTMLElement | null} */ (null) + })); + + for (let line of subs) { + let text = line.text; + + if (isAss) { + // TODO: Filter out SVG in ASS properly + text = text.replace(/^(?"); + + line.textSpan = span; + } + + const seen = []; + const filtered = []; + + let lastTime = -1; + + for (let line of subs) { + let time = line.timeStartRaw; + if (time < lastTime) { + continue; + } + + const text = line.textSpan?.innerText?.trim()?.toLowerCase() ?? ""; + + if (text.length == 0) { + continue; + } + + // TODO: Filter out karaoke in ASS properly + if (isAss && text.indexOf(" ") == -1 && seen.findIndex(t => t.indexOf(text) != -1) != -1) { + continue; + } + + let index = seen.indexOf(text); + + if (index == -1) { + filtered.push(line); + lastTime = time; + + if (seen.length >= 10) { + seen.splice(0, 1); + } + } else { + seen.splice(index, 1); + } + + seen.push(text); + } + + this._currentSubs = filtered; + } + + this._isAutoscroll = true; + this._lastOffs = Number.NaN; + this._lastInView = Number.NaN; + + this.update(); + } + + /** + * @param {ResizeObserverEntry[]} entries + */ + updatePosition(entries) { + const entry = entries[0]; + const height = entry.contentRect.height; + + if (height <= 0) { + return; + } + + this._osdTranscript.style.bottom = `calc(${height}px + 3em)`; + } + + updateActive() { + const video = JadefinUtils.video; + const currentPlayer = JadefinUtils.currentPlayer; + const subs = this.isEnabled ? this.currentSubs : null; + if (!video || !currentPlayer || !subs) { + return; + } + + const time = + video.currentTime * 1000 + + ((currentPlayer._currentPlayOptions?.transcodingOffsetTicks || 0) / TICKS_PER_MS) + + currentPlayer.getSubtitleOffset() * 1000; + + /** @type {any} */ + let nextInView = null; + + for (let i in subs) { + const line = subs[i]; + if (!line.isPositive) { + continue; + } + + const active = line.timeStart <= time && time <= line.timeEnd; + const progress = (time - line.timeStart) / (line.timeEnd - line.timeStart); + + if (progress < 0 && line.timeStart < (subs[nextInView]?.timeStart ?? Number.MAX_VALUE)) { + nextInView = i; + } + + if (!line.active && !active) { + continue; + } + + if (active) { + nextInView = i + 1; + } + + line.active = active; + line.el?.classList.toggle("active", active); + line.el?.style.setProperty("--progress", `${Math.max(0, Math.min(progress, 1))}`); + } + + nextInView = nextInView != null ? Math.max(0, nextInView - 1) : (subs.findIndex(l => l.isPositive) + 1); + + if (this._lastInView != nextInView) { + const lastInView = subs[this._lastInView]; + + if (lastInView && Math.abs(lastInView.el.getBoundingClientRect().bottom - this._osdTranscriptLogWrap.getBoundingClientRect().bottom) < 8) { + this._isAutoscroll = true; + } + + this._lastInView = nextInView; + + if (this._isAutoscroll) { + this._ignoreScroll = true; + + subs[nextInView]?.el?.scrollIntoView({ + behavior: this._osdTranscript.getAttribute("inert") != null ? "instant" : "smooth", + block: "end", + inline: "nearest" + }); + } + } + } + + update() { + if (!JadefinUtils.routePathIsVideo || !this.currentSubs) { + this._isEnabled = false; + } + + this.log.v(`Updating, enabled: ${this.isEnabled}`); + + this._updateOsdPage(); + this._updateHtmlVideoPlayer(); + this._updateSubtitleTimes(); + this._updateTranscriptLog(); + this.updateActive(); + + this._btnTranscript.classList.toggle("hide", !this.currentSubs); + this._btnTranscript.classList.toggle("enabled", this.isEnabled); + this._osdTranscript.classList.toggle("enabled", this.isEnabled); + this._osdTranscript.toggleAttribute("inert", !this.isEnabled); + } + + _updateOsdPage() { + const videoOsdPage = document.querySelector("div#videoOsdPage:not(.hide)"); + if (!videoOsdPage || this._lastOsdPage == videoOsdPage) { + return; + } + this._lastOsdPage = videoOsdPage; + + this.log.i("Adding event listener to videoOsdPage"); + this.log.dir(videoOsdPage); + + videoOsdPage.addEventListener(window.PointerEvent ? "pointerdown" : "click", e => { + const srcElement = /** @type {HTMLElement} */ (e.target); + + if (JadefinUtils.hasParent(srcElement, this._osdTranscript)) { + e.stopPropagation(); + } + }, true); + + const osdBottom = videoOsdPage.querySelector(".videoOsdBottom"); + if (osdBottom) { + this.log.i("Observing osd bottom size"); + this.log.dir(osdBottom); + + this._osdBottomObserver.disconnect(); + this._osdBottomObserver.observe(osdBottom); + } + + const buttons = videoOsdPage.querySelector(".osdControls > .buttons"); + if (this._btnTranscript.parentElement != buttons && buttons) { + this.log.i("Adding transcript button to osd buttons"); + this.log.dir(buttons); + + buttons.insertBefore(this._btnTranscript, buttons.querySelector(".btnSubtitles")); + } + + if (this._osdTranscript.parentElement != videoOsdPage) { + this.log.i("Adding transcript to osd page"); + + videoOsdPage.insertBefore(this._osdTranscript, videoOsdPage.querySelector(".videoOsdBottom")); + } + } + + _updateHtmlVideoPlayer() { + if (!this.isEnabled) { + return; + } + + const currentPlayer = JadefinUtils.currentPlayer; + if (!currentPlayer || this._lastHtmlVideoPlayer == currentPlayer) { + return; + } + this._lastHtmlVideoPlayer = currentPlayer; + + this.log.i("Adding event listener to htmlVideoPlayer"); + this.log.dir(currentPlayer); + + JadefinModules.Events.on(currentPlayer, "timeupdate", this.updateActive.bind(this)); + } + + _updateSubtitleTimes() { + const subs = this.isEnabled ? this.currentSubs : null; + if (!subs) { + return; + } + + const offs = (JadefinUtils.htmlVideoPlayer?.getSubtitleOffset() ?? 0) * 1000 * TICKS_PER_MS; + if (this._lastOffs == offs) { + return; + } + this._lastOffs = offs; + + for (let line of subs) { + const time = line.timeStartRaw - offs; + + line.timeText = JadefinModules.datetime.getDisplayRunningTime(time); + line.isPositive = time >= 0; + } + } + + _updateTranscriptLog() { + const elList = this._osdTranscriptLogList; + const subs = this.isEnabled ? this.currentSubs : null; + + if (!subs) { + // Don't end - keep the last subs for fadeouts. + + return; + } + + for (let i in subs) { + const line = subs[i]; + + line.el = elList.add(line.id, el => { + const old = el; + + el = rd$(el)` + + ${line.timeText} + ${line.textSpan} + + `; + + if (!old) { + el.addEventListener("click", () => { + if (document.body.getAttribute("input-eaten") != "true") { + JadefinModules.playbackManager.seek(el["_data_subLine"].timeStartRaw); + } + }); + } + + return el; + }); + + line.el["_data_subLine"] = line; + } + + elList.end(); + } + +})()); diff --git a/mods/Transcript.scss b/mods/Transcript.scss new file mode 100644 index 0000000..cc4a332 --- /dev/null +++ b/mods/Transcript.scss @@ -0,0 +1,124 @@ +div#videoOsdPage { + > .videoOsdBottom > .osdControls > .buttons .btnTranscript { + &.enabled { + color: rgba(var(--accent), 0.8) !important; + } + } + + .osdTranscript { + position: absolute; + right: 3em; + bottom: 0; + + backdrop-filter: blur(16px); + + transition: opacity 0.1s ease-in-out; + opacity: 0; + pointer-events: none; + + text-shadow: + 0 0 1em black, + 0 0 1em black, + 0 0 0.5em black, + 0 0 0.5em black, + 0 0 0.25em black, + 0 0 0.25em black, + 0 0 3px black, + 0 0 3px black, + 0 0 2px black, + 0 0 2px black; + + font-weight: 600; + + &.enabled { + opacity: 1; + pointer-events: all; + } + + .transcriptLogWrap { + max-width: min(40vw, 30em); + max-height: min(50vh, 30em); + overflow-y: scroll; + overflow-x: hidden; + + scroll-snap-type: y proximity; + } + + .transcriptLog { + border-collapse: collapse; + border-spacing: 0; + line-height: 1.5em; + } + + .line { + position: relative; + z-index: 1; + + scroll-snap-align: end; + + body:not([input-eaten="true"]) & { + cursor: pointer; + } + + &::before, &::after { + content: ""; + position: absolute; + z-index: -1; + + transition: background-color 0.1s ease-in-out, opacity 0.1s ease-in-out; + } + + &::before { + top: 0; + left: 0; + bottom: 0; + right: 0; + + background-color: var(--bg); + opacity: var(--bg-opacity); + } + + &::after { + top: 0; + left: 0; + bottom: 0; + width: 0.25em; + + background-color: var(--bar); + opacity: var(--bar-opacity); + transform: scaleY(var(--progress)); + transform-origin: 0 0; + } + + --bg: #000000; + --bg-opacity: 0.25; + --bar: rgb(var(--accent, 0x00, 0xad, 0xee)); + --bar-opacity: 0; + + &:nth-child(2n) { + --bg: #888888; + } + + &.active { + --bg: var(--bar); + --bg-opacity: 0.5; + --bar-opacity: 1; + } + + .time { + display: inline-block; + margin: 0.75em 1em; + margin-right: 0; + } + + .text { + overflow-wrap: anywhere; + padding: 0.75em; + } + + &[data-positive="false"] { + display: none; + } + } + } +} diff --git a/mods/VersionCheck.css b/mods/VersionCheck.css new file mode 100644 index 0000000..c4c8348 --- /dev/null +++ b/mods/VersionCheck.css @@ -0,0 +1,24 @@ +.headerExtrasMenu.headerButton > .versionCheck, +.actionsheetMenuItemIcon.hasVersionCheck > .versionCheck { + background: var(--versionCheckColor, deeppink); + position: absolute; + width: 0.5em; + height: 0.5em; + right: 0.5em; + bottom: 0.5em; + border-radius: 100%; +} +.osdHeader .headerExtrasMenu.headerButton > .versionCheck, +.osdHeader .actionsheetMenuItemIcon.hasVersionCheck > .versionCheck { + display: none; +} + +.actionsheetMenuItemIcon.hasVersionCheck { + position: relative; +} +.actionsheetMenuItemIcon.hasVersionCheck > .versionCheck { + width: 0.35em; + height: 0.35em; + right: -0.125em; + bottom: -0.125em; +}/*# sourceMappingURL=VersionCheck.css.map */ \ No newline at end of file diff --git a/mods/VersionCheck.css.map b/mods/VersionCheck.css.map new file mode 100644 index 0000000..215f477 --- /dev/null +++ b/mods/VersionCheck.css.map @@ -0,0 +1 @@ +{"version":3,"sources":["VersionCheck.scss","VersionCheck.css"],"names":[],"mappings":"AAAA;;EAEI,8CAAA;EACA,kBAAA;EACA,YAAA;EACA,aAAA;EACA,YAAA;EACA,aAAA;EACA,mBAAA;ACCJ;ADCI;;EACI,aAAA;ACER;;ADEA;EACI,kBAAA;ACCJ;ADCI;EACI,aAAA;EACA,cAAA;EACA,eAAA;EACA,gBAAA;ACCR","file":"VersionCheck.css","sourcesContent":[".headerExtrasMenu.headerButton > .versionCheck,\n.actionsheetMenuItemIcon.hasVersionCheck > .versionCheck {\n background: var(--versionCheckColor, deeppink);\n position: absolute;\n width: 0.5em;\n height: 0.5em;\n right: 0.5em;\n bottom: 0.5em;\n border-radius: 100%;\n\n .osdHeader & {\n display: none;\n }\n}\n\n.actionsheetMenuItemIcon.hasVersionCheck {\n position: relative;\n\n > .versionCheck {\n width: 0.35em;\n height: 0.35em;\n right: -0.125em;\n bottom: -0.125em;\n }\n}\n",".headerExtrasMenu.headerButton > .versionCheck,\n.actionsheetMenuItemIcon.hasVersionCheck > .versionCheck {\n background: var(--versionCheckColor, deeppink);\n position: absolute;\n width: 0.5em;\n height: 0.5em;\n right: 0.5em;\n bottom: 0.5em;\n border-radius: 100%;\n}\n.osdHeader .headerExtrasMenu.headerButton > .versionCheck,\n.osdHeader .actionsheetMenuItemIcon.hasVersionCheck > .versionCheck {\n display: none;\n}\n\n.actionsheetMenuItemIcon.hasVersionCheck {\n position: relative;\n}\n.actionsheetMenuItemIcon.hasVersionCheck > .versionCheck {\n width: 0.35em;\n height: 0.35em;\n right: -0.125em;\n bottom: -0.125em;\n}"]} \ No newline at end of file diff --git a/mods/VersionCheck.js b/mods/VersionCheck.js new file mode 100644 index 0000000..729ee97 --- /dev/null +++ b/mods/VersionCheck.js @@ -0,0 +1,78 @@ +//@ts-check + +import JadefinIntegrity from '../JadefinIntegrity.js'; + +import Jadefin from "../Jadefin.js"; +import JadefinMod from "../JadefinMod.js"; +import JadefinModules from "../JadefinModules.js"; +import JadefinUtils from "../JadefinUtils.js"; + +import { rd, rdom, rd$, RDOMListHelper } from "../utils/rdom.js"; + +export default JadefinIntegrity("VersionCheck", import.meta.url, () => new (class VersionCheck extends JadefinMod { + _promptedUpdate = false; + + constructor() { + super(); + } + + async init(name, modUrl) { + await super.init(name, modUrl); + + await this.initStyle(); + + const parser = new DOMParser(); + const url = `${window.location.origin}${window.location.pathname}?cachev=${Date.now()}`; + const data = await fetch(url).then(r => r.text()); + const doc = parser.parseFromString(data, "text/html"); + + const srcOld = this.srcOld = this.storage.get("src"); + const srcCurr = this.srcCurr = document.querySelector("[data-main-jellyfin-bundle]")?.getAttribute("src"); + const srcNew = this.srcNew = doc.querySelector("[data-main-jellyfin-bundle]")?.getAttribute("src"); + + if (srcOld != srcCurr) { + this.storage.set("src", srcCurr); + JadefinModules.toast("Jadefin updated."); + } + + const canUpdate = this.canUpdate = srcCurr != srcNew; + if (!canUpdate) { + return; + } + + this.promptUpdate(); + } + + promptUpdate() { + if (this._promptedUpdate) { + return; + } + + this._promptedUpdate = true; + + JadefinModules.toast("Jadefin update available."); + + const ExtrasMenu = /** @type {import("./ExtrasMenu.js").default} */ (Jadefin.getMod("ExtrasMenu")); + + ExtrasMenu.headerExtrasEl.appendChild(rd$()``); + + const item = ExtrasMenu.items.find(i => i.name == "Reload" && i.icon == "update"); + const orig = item.cbEl; + + item.cbEl = (/** @type {HTMLElement} */ el, current, alive) => { + orig?.(el, current, alive); + + if (!alive) { + return; + } + + const icon = el.querySelector(".actionsheetMenuItemIcon"); + if (!icon) { + return; + } + + icon.classList.add("hasVersionCheck"); + icon.appendChild(rd$()``); + }; + } +})()); diff --git a/mods/VersionCheck.scss b/mods/VersionCheck.scss new file mode 100644 index 0000000..008162b --- /dev/null +++ b/mods/VersionCheck.scss @@ -0,0 +1,25 @@ +.headerExtrasMenu.headerButton > .versionCheck, +.actionsheetMenuItemIcon.hasVersionCheck > .versionCheck { + background: var(--versionCheckColor, deeppink); + position: absolute; + width: 0.5em; + height: 0.5em; + right: 0.5em; + bottom: 0.5em; + border-radius: 100%; + + .osdHeader & { + display: none; + } +} + +.actionsheetMenuItemIcon.hasVersionCheck { + position: relative; + + > .versionCheck { + width: 0.35em; + height: 0.35em; + right: -0.125em; + bottom: -0.125em; + } +} diff --git a/mods/VolumeBoost.js b/mods/VolumeBoost.js new file mode 100644 index 0000000..24efe61 --- /dev/null +++ b/mods/VolumeBoost.js @@ -0,0 +1,198 @@ +//@ts-check + +import JadefinIntegrity from '../JadefinIntegrity.js'; + +import JadefinMod from "../JadefinMod.js"; +import JadefinModules from "../JadefinModules.js"; +import JadefinUtils from "../JadefinUtils.js"; + +export default JadefinIntegrity("VolumeBoost", import.meta.url, () => new (class VolumeBoost extends JadefinMod { + audioCtx = new AudioContext(); + audioBypass = this.audioCtx.createGain(); + audioGain = this.audioCtx.createGain(); + audioComp = this.audioCtx.createDynamicsCompressor(); + audioCompGain = this.audioCtx.createGain(); + + _currentGain = 1; + + constructor() { + super(); + + this.audioBypass.gain.value = 1; + + this.audioGain.gain.value = 0; + + this.audioComp.knee.value = 40; + this.audioComp.ratio.value = 12; + this.audioComp.attack.value = 0; + this.audioComp.release.value = 0.25; + + this.audioCompGain.gain.value = 0; + + this.audioBypass.connect(this.audioCtx.destination); + this.audioGain.connect(this.audioComp).connect(this.audioCompGain).connect(this.audioCtx.destination); + + document.addEventListener("click", e => this.audioCtxResume(), true); + } + + get currentGain() { + return this._currentGain; + } + + set currentGain(value) { + this.log.i(`Changing gain to ${value}`); + + const diff = Math.abs(this._currentGain - value); + const timeNow = this.audioCtx.currentTime; + const time = this.audioCtx.currentTime + Math.min(0.5 * diff, 2); + + if (diff != 0) { + this.audioBypass.gain.setValueAtTime(this.audioBypass.gain.value, timeNow); + this.audioGain.gain.setValueAtTime(this.audioGain.gain.value, timeNow); + this.audioCompGain.gain.setValueAtTime(this.audioCompGain.gain.value, timeNow); + this.audioBypass.gain.linearRampToValueAtTime((value <= 1) ? value : 0, time); + this.audioGain.gain.linearRampToValueAtTime((value <= 1) ? 0 : value, time); + this.audioCompGain.gain.linearRampToValueAtTime((value <= 1) ? 0 : 1, time); + } + + this._currentGain = value; + this.connect(); + } + + async init(name, url) { + await super.init(name, url); + + this.initHookActionSheetShow(); + + document.addEventListener("viewshow", () => { + if (JadefinUtils.routePathIsVideo) { + this.log.i("Navigating to video"); + this.audioCtxResume(); + + if (this._connectRepeat) { + clearInterval(this._connectRepeat); + } + + clearInterval(this._connectRepeat); + this._connectRepeat = setInterval(() => this.connect(), 100); + } + }); + + this.log.i("Ready"); + } + + initHookActionSheetShow() { + const orig = this._actionSheetShowOrig = JadefinModules.actionSheet.show.bind(JadefinModules.actionSheet); + JadefinModules.actionSheet.show = (options) => { + const optionsOrig = Object.assign({}, options, { + items: options.items.slice() + }); + + this.log.v("actionSheet.show(...)"); + this.log.dir(options); + + // Options menu during playback + if (options.items.length >= 6 && + options.items[0].id == "aspectratio" && + options.items[1].id == "playbackrate" && + options.items[2].id == "quality" && + options.items[3].id == "repeatmode" && + options.items[4].id == "suboffset" && + options.items[5].id == "stats" && + true + ) { + options.items.splice(4, 0, {id: "custom-volumeboost", name: "Volume Boost", asideText: `${this.currentGain}x`}); + + return new Promise((resolve, reject) => { + JadefinModules.actionSheet.show({ + positionTo: options.positionTo, + items: [ + {id: "all", name: "Advanced..."}, + {divider: true}, + ...options.items.slice(4), + ] + }).then(id => { + if (id == "all") { + orig(Object.assign({}, options, { + items: [ + ...options.items.slice(0, 4), + {divider: true}, + {id: "back", name: "Back..."}, + ] + })).then(id => { + if (id == "back") { + JadefinModules.actionSheet.show(optionsOrig).then(resolve).catch(reject); + } else { + resolve(id); + } + }).catch(reject); + + return; + } + + if (id == "custom-volumeboost") { + reject(); + + JadefinModules.actionSheet.show({ + positionTo: options.positionTo, + items: [ + {id: "1", name: "1x", selected: this.currentGain == 1}, + {id: "2", name: "2x", selected: this.currentGain == 2}, + {id: "3", name: "3x", selected: this.currentGain == 3}, + {id: "4", name: "4x", selected: this.currentGain == 4}, + ] + }).then(id => { + if (!id) { + return; + } + + this.currentGain = parseInt(id); + }); + + return; + } + + resolve(id); + }).catch(reject); + }); + } + + return orig(options); + }; + } + + connect() { + const video = JadefinUtils.video; + if (!video) { + return; + } + + const last = this.video; + const lastSrc = this.videoSrc; + + if (video != last) { + lastSrc?.disconnect(); + this.videoSrc = null; + } + + this.video = video; + if (!this.videoSrc) { + this.log.i("Connected to video element"); + this.log.dir(this.video); + + this.videoSrc = this.audioCtx.createMediaElementSource(this.video); + this.videoSrc.connect(this.audioBypass); + this.videoSrc.connect(this.audioGain); + } + + if (this._connectRepeat) { + clearInterval(this._connectRepeat); + } + } + + audioCtxResume() { + if (this.audioCtx.state === "suspended") { + this.audioCtx.resume(); + } + } +})()); diff --git a/mods/jade/PatchAndroidHLSJS.js b/mods/jade/PatchAndroidHLSJS.js new file mode 100644 index 0000000..3c05462 --- /dev/null +++ b/mods/jade/PatchAndroidHLSJS.js @@ -0,0 +1,34 @@ +//@ts-check + +import JadefinIntegrity from '../../JadefinIntegrity.js'; + +import Jadefin from "../../Jadefin.js"; +import JadefinMod from "../../JadefinMod.js"; +import JadefinModules from "../../JadefinModules.js"; + +// https://github.com/jellyfin/jellyfin-android/issues/1031 +export default JadefinIntegrity("PatchAndroidHLSJS", import.meta.url, () => new (class PatchAndroidHLSJS extends JadefinMod { + constructor() { + super(); + + this.enableHlsJsPlayer = this.enableHlsJsPlayer.bind(this); + } + + async init(name, url) { + await super.init(name, url); + + const htmlMediaHelper = this.htmlMediaPlayer = Jadefin.findWebpackRawLoad(e => (e.rR?.toString().indexOf("x-mpegURL") || -1) != -1)[0]; + this._enableHlsJsPlayer = htmlMediaHelper.rR.bind(htmlMediaHelper); + + Object.defineProperty(htmlMediaHelper, "rR", { get: () => this.enableHlsJsPlayer }); + } + + enableHlsJsPlayer(runTimeTicks, mediaType) { + // https://github.com/jellyfin/jellyfin-web/commit/baf1b55a0cb83d4cb63a18968fc93493ce785498 + if (JadefinModules.browser.android && mediaType === "Video") { + mediaType = "Audio"; + } + + return this._enableHlsJsPlayer?.(runTimeTicks, mediaType); + } +})()); diff --git a/mods/jade/Shortcuts.css b/mods/jade/Shortcuts.css new file mode 100644 index 0000000..5cb95c0 --- /dev/null +++ b/mods/jade/Shortcuts.css @@ -0,0 +1,10 @@ +.extrasMenuPopup .refreshButton .refreshProgress { + display: inline-block; + vertical-align: middle; + height: 4px; + margin-top: 0.75em; + width: 100%; +} +.extrasMenuPopup .refreshButton:not(:disabled) .refreshProgress { + display: none; +}/*# sourceMappingURL=Shortcuts.css.map */ \ No newline at end of file diff --git a/mods/jade/Shortcuts.css.map b/mods/jade/Shortcuts.css.map new file mode 100644 index 0000000..321d68d --- /dev/null +++ b/mods/jade/Shortcuts.css.map @@ -0,0 +1 @@ +{"version":3,"sources":["Shortcuts.scss","Shortcuts.css"],"names":[],"mappings":"AACI;EACI,qBAAA;EACA,sBAAA;EACA,WAAA;EACA,kBAAA;EACA,WAAA;ACAR;ADIQ;EACI,aAAA;ACFZ","file":"Shortcuts.css","sourcesContent":[".extrasMenuPopup .refreshButton {\n .refreshProgress {\n display: inline-block;\n vertical-align: middle;\n height: 4px;\n margin-top: 0.75em;\n width: 100%;\n }\n\n &:not(:disabled) {\n .refreshProgress {\n display: none;\n }\n }\n}\n",".extrasMenuPopup .refreshButton .refreshProgress {\n display: inline-block;\n vertical-align: middle;\n height: 4px;\n margin-top: 0.75em;\n width: 100%;\n}\n.extrasMenuPopup .refreshButton:not(:disabled) .refreshProgress {\n display: none;\n}"]} \ No newline at end of file diff --git a/mods/jade/Shortcuts.js b/mods/jade/Shortcuts.js new file mode 100644 index 0000000..e469d83 --- /dev/null +++ b/mods/jade/Shortcuts.js @@ -0,0 +1,63 @@ +//@ts-check + +import JadefinIntegrity from '../../JadefinIntegrity.js'; + +import Jadefin from "../../Jadefin.js"; +import JadefinMod from "../../JadefinMod.js"; +import JadefinModules from "../../JadefinModules.js"; + +import { rd, rdom, rd$, RDOMListHelper } from "../../utils/rdom.js"; + +export default JadefinIntegrity("Shortcuts", import.meta.url, () => new (class Shortcuts extends JadefinMod { + constructor() { + super(); + } + + async init(name, url) { + await super.init(name, url); + + this.initStyle(); + + const ExtrasMenu = /** @type {import("../ExtrasMenu.js").default} */ (Jadefin.getMod("ExtrasMenu")); + + ExtrasMenu.items.push({ + name: "Jellyseerr", + secondaryText: "Add new movies or series", + icon: "playlist_add_circle", + in: ExtrasMenu.IN_DRAWER, + cb: () => { + window.open("https://jellyseerr.0x0a.de/", "_blank"); + } + }); + + ExtrasMenu.items.push({ + name: "Scan All Libraries", + secondaryText: "Rescan all libraries", + icon: "sync", + in: ExtrasMenu.IN_CUSTOM, + inCustom: (current, item) => current == ExtrasMenu.IN_LIBRARY && (JadefinModules.ApiClient._currentUser?.Policy.IsAdministrator ?? false), + cbEl: (/** @type {HTMLElement} */ el, current, alive) => { + this.log.i(`Injecting library scan task button into extras popup: ${alive}`); + this.log.dir(el); + + if (alive) { + el.classList.add("refreshButton"); + + el.querySelector(".listItemBody")?.appendChild(rd$()` + + `); + } + + JadefinModules.taskButton({ + mode: alive ? "on" : "off", + taskKey: "RefreshLibrary", + button: el, + progressElem: el.querySelector(".refreshProgress") + }); + }, + cb: () => { + } + }); + } + +})()); diff --git a/mods/jade/Shortcuts.scss b/mods/jade/Shortcuts.scss new file mode 100644 index 0000000..7ab1af9 --- /dev/null +++ b/mods/jade/Shortcuts.scss @@ -0,0 +1,15 @@ +.extrasMenuPopup .refreshButton { + .refreshProgress { + display: inline-block; + vertical-align: middle; + height: 4px; + margin-top: 0.75em; + width: 100%; + } + + &:not(:disabled) { + .refreshProgress { + display: none; + } + } +} diff --git a/mods_example.json b/mods_example.json new file mode 100644 index 0000000..b0afdb9 --- /dev/null +++ b/mods_example.json @@ -0,0 +1,6 @@ +[ + "InsideMods.js", + "/jadefin/mods/SameDomain.js", + "//example.com/jadefin/mods/SameProtocol.js", + "list://example.com/jadefin/mods/mods_extra.json" +] \ No newline at end of file diff --git a/mods_jade.json b/mods_jade.json new file mode 100644 index 0000000..8ceb8e2 --- /dev/null +++ b/mods_jade.json @@ -0,0 +1,12 @@ +[ + "ExtrasMenu.js", + "VolumeBoost.js", + "FixStuck.js", + "VersionCheck.js", + "Screenshot.js", + "InputEater.js", + "Transcript.js", + + "jade/Shortcuts.js", + "jade/PatchAndroidHLSJS.js" +] \ No newline at end of file diff --git a/update/index.html b/update/index.html new file mode 100644 index 0000000..4be192d --- /dev/null +++ b/update/index.html @@ -0,0 +1,19 @@ + + + + jellyfin.0x0a.de cache refresh + + + +

This page should refresh automatically.
If you're stuck, click here.

+ + \ No newline at end of file diff --git a/utils/cyrb53.js b/utils/cyrb53.js new file mode 100644 index 0000000..e29cf4d --- /dev/null +++ b/utils/cyrb53.js @@ -0,0 +1,30 @@ +// https://github.com/bryc/code/blob/master/jshash/experimental/cyrb53.js + +/* + cyrb53 (c) 2018 bryc (github.com/bryc) + License: Public domain. Attribution appreciated. + A fast and simple 53-bit string hash function with decent collision resistance. + Largely inspired by MurmurHash2/3, but with a focus on speed/simplicity. +*/ + +/* + cyrb53a (c) 2023 bryc (github.com/bryc) + License: Public domain. Attribution appreciated. + The original cyrb53 has a slight mixing bias in the low bits of h1. + This shouldn't be a huge problem, but I want to try to improve it. + This new version should have improved avalanche behavior, but + it is not quite final, I may still find improvements. + So don't expect it to always produce the same output. +*/ +export default function cyrb53a(str, seed = 0) { + let h1 = 0xdeadbeef ^ seed, h2 = 0x41c6ce57 ^ seed; + for(let i = 0, ch; i < str.length; i++) { + ch = str.charCodeAt(i); + h1 = Math.imul(h1 ^ ch, 0x85ebca77); + h2 = Math.imul(h2 ^ ch, 0xc2b2ae3d); + } + h1 ^= Math.imul(h1 ^ (h2 >>> 15), 0x735a2d97); + h2 ^= Math.imul(h2 ^ (h1 >>> 15), 0xcaf649a9); + h1 ^= h2 >>> 16; h2 ^= h1 >>> 16; + return 2097152 * (h2 >>> 0) + (h1 >>> 11); +}; diff --git a/utils/rdom.js b/utils/rdom.js new file mode 100644 index 0000000..d892984 --- /dev/null +++ b/utils/rdom.js @@ -0,0 +1,673 @@ +// @ts-check + +/* RDOM (rotonde dom) + * 0x0ade's collection of DOM manipulation functions because updating innerHTML every time a single thing changes isn't cool. + * This started out as a mini framework for Rotonde. + * Mostly oriented towards quickly manipulating many things quickly. + */ + +/** + * @typedef {{ fields: any[], renderers: any[], texts: any[], html: string, init: null | ((el: null | Element) => (null | Element)) }} RDOMParseResult + */ + +export var rdom = { + /** @type {Map} */ + _cachedTemplates: new Map(), + /** @type {Map} */ + _cachedIDs: new Map(), + _lastID: -1, + + /** + * Find an element with a given key. + * @param {Element} el Element (context) to search inside. + * @param {string | number} key Key to look for. + * @returns {HTMLElement} Found element. + */ + find(el, key, value = "", type = "field") { + let attr = `rdom-${type}${key === 1 ? "" : key === undefined ? "s" : "-"+key}`; + value = value ? value.toString() : value; + + let check = el => { + let av = el.getAttribute(attr); + return av !== null && (!value || value === av) ? el : null; + } + + let checked = check(el); + if (checked) + return checked; + + let find = el => { + // Check children first. + for (let child = el.firstElementChild; child; child = child.nextElementSibling) { + let checked = check(child); + if (checked) + return checked; + } + + // If no child matches, check children's children. + for (let child = el.firstElementChild; child; child = child.nextElementSibling) { + if (child["rdomCtx"]) // Context change - ignore this branch. + continue; + let found = find(child); + if (found) + return found; + } + + return null; + } + + return find(el); + }, + + /** + * Find all elements with a given key. + * @param {Element} el Element (context) to search inside. + * @param {string | number} key Key to look for. + * @returns {HTMLElement[]} Found elements. + */ + findAll(el, key, value = "", type = "field") { + let attr = `rdom-${type}${key === 1 ? "" : key === undefined ? "s" : "-"+key}`; + value = value ? value.toString() : value; + + let all = []; + + let check = el => { + let av = el.getAttribute(attr); + return av !== null && (!value || value === av) ? el : null; + } + + let checked = check(el); + if (checked) + all.push(checked); + + let find = el => { + for (let child = el.firstElementChild; child; child = child.nextElementSibling) { + let checked = check(child); + if (checked) + all.push(checked); + + if (child["rdomCtx"]) // Context change - ignore this branch. + continue; + + find(child); + } + } + + find(el); + return all; + }, + + /** + * Get the context ID of the given element. + * @param {Element} el Element to get the context ID for. + * @returns {string | null} RDOM context ID. + */ + getCtxID(el, self = true) { + let ctx; + + if (self && (ctx = el["rdomCtx"])) + return ctx; + + for (let /** @type {Element | null} */ p = el; p; p = el.parentElement) { + if (ctx = p["rdomCtx"]) + return ctx; + } + + return null; + }, + + /** + * Get the context of the given element. + * @param {Element} el Element to get the context for. + * @returns {HTMLElement | null} RDOM context element. + */ + getCtx(el, self = true) { + if (self && el["rdomCtx"]) + return /** @type {HTMLElement} */ (el); + + for (let p = /** @type {HTMLElement | null} */ (el); p; p = el.parentElement) { + if (p["rdomCtx"]) + return p; + } + + return null; + }, + + /** Prepare an element to be used for RDOM's state-related functions. + * @param {Element} el The element to initialize. + * @returns {HTMLElement} The passed element. + */ + init(el) { + if (el["rdomFields"]) + return /** @type {HTMLElement} */ (el); + el["rdomFields"] = {}; + el["rdomStates"] = {}; + return /** @type {HTMLElement} */ (el); + }, + + /** + * Move an element to a given index non-destructively. + * @param {ChildNode} el The element to move. + * @param {number} index The target index. + */ + move(el, index) { + if (!el) + return; + + let tmp = el; + // @ts-ignore previousElementSibling is too new? + while (tmp = tmp.previousElementSibling) + index--; + + // offset == 0: We're fine. + if (!index) + return; + + let swap; + tmp = el; + if (index < 0) { + // offset < 0: Element needs to be pushed "left" / "up". + // -offset is the "# of elements we expected there not to be", + // thus how many places we need to shift to the left. + // @ts-ignore previousElementSibling is too new? + while ((swap = tmp) && (tmp = tmp.previousElementSibling) && index < 0) + index++; + swap.before(el); + + } else { + // offset > 0: Element needs to be pushed "right" / "down". + // offset is the "# of elements we expected before us but weren't there", + // thus how many places we need to shift to the right. + // @ts-ignore previousElementSibling is too new? + while ((swap = tmp) && (tmp = tmp.nextElementSibling) && index > 0) + index--; + swap.after(el); + } + }, + + /** Escape a string into a HTML - safe format. + * @param {string} m String to escape. + * @returns {string} Escaped string. + */ + escape(m) { + let n = ""; + for (let c of ""+m) { + if (c === "&") + n += "&"; + else if (c === "<") + n += "<"; + else if (c === ">") + n += ">"; + else if (c === "\"") + n += """; + else if (c === "'") + n += "'"; + else + n += c; + } + return n; + }, + + /** + * Get the holder of the rdom-get with the given value, or all holders into the given object. + * @param {Element} el + * @param {string | any} valueOrObj + * @returns {HTMLElement | any} + */ + get(el, valueOrObj = {}) { + if (typeof(valueOrObj) === "string") + return rdom.find(el, 1, valueOrObj, "get"); + + for (let field of rdom.findAll(el, 1, "", "get")) { + let key = /** @type {string} */ (field.getAttribute("rdom-get")); + valueOrObj[key] = field; + } + + return valueOrObj; + }, + + /** + * Parse a template string into a HTML string + extra data, escaping expressions unprefixed with $, inserting attribute arrays and preserving child nodes. + * @param {TemplateStringsArray} template + * @param {...any} values + * @returns {RDOMParseResult} + */ + rdparse$(template, ...values) { + try { + let fields = []; + let renderers = []; + let texts = []; + let init = null; + + let attrProxies = new Set(); + + let ignored = 0; + let ids = /** @type {number[]} */ (rdom._cachedIDs.get(template)); + if (!ids) { + ids = []; + rdom._cachedIDs.set(template, ids); + } + let idi = -1; + let getid = () => ids[++idi] || (ids[idi] = ++rdom._lastID); + + let tag = (tag, attr = "", val = "") => `${val}` + + let html = template.reduce(function rdparse$reduce(prev, next, i) { + let val = values[i - 1]; + let t = prev[prev.length - 1]; + + if (ignored) { + // Ignore val. + --ignored; + return prev + next; + } + + if (t === "$") { + // Keep value as-is. + return prev.slice(0, -1) + val + next; + } + + if (val && val.key && next.trim() === "=") { + // Settable / gettable field. + next = ""; + fields.push({ h: val, key: val.key, state: val.state, value: values[i] }); + ++ignored; + val = `rdom-field-${rdom.escape(val.key)}="${rdom.escape(val.key)}"`; + + } else if (t === "=") { + // Proxy attributes using a field. + if (val && val.join) + val = val.join(" "); + else if (!(val instanceof Function)) + val = ""+val; + + let split = prev.lastIndexOf(" ") + 1; + let attr = prev.slice(split, -1); + let key = attr; + if (attrProxies.has(key)) + key += "-" + getid(); + attrProxies.add(attr); + prev = prev.slice(0, split); + let h = rd.attr(key, attr); + h.value = val; + fields.push(h); + val = `rdom-field-${rdom.escape(key)}="${rdom.escape(key)}"`; + + } else if (val instanceof Function && i === values.length && !next) { + // Add an init processor, which is present at the end of the template. + init = val; + val = ""; + + } else if (val && (val instanceof Node || val instanceof Function)) { + // Add placeholders, which will be replaced later on. + let id = getid(); + let val_ = val; + renderers.push({ id: id, value: val instanceof Function ? val : () => val_ }); + val = tag("empty", "rdom-render="+id); + + } else { + // Proxy text using a text node. + let id = getid(); + texts.push({ id: id, value: val }); + val = tag("text", "rdom-text="+id); + prev = prev.trimEnd(); + next = next.trimStart(); + } + + return prev + val + next; + }); + + let data = { + fields, + renderers, + texts, + html, + init + }; + return data; + } catch (e) { + console.warn("[rdom]", "rd$ failed parsing:", String.raw(template, ...(values.map(v => "${"+v+"}"))), "\n", e); + throw e; + } + }, + + /** + * Build the result of rdparse$ into a HTML element. + * @param {Element | null} el + * @param {RDOMParseResult} data + * @returns {HTMLElement} + */ + rdbuild(el, data) { + let elEmpty = null; + if (el && el.tagName === "RDOM-EMPTY") { + elEmpty = el; + el = null; + } + + /** @type {Element | null} */ + let nodeBase = null; + if (data.html) { + let html = data.html.trim(); + nodeBase = /** @type {Element | null} */ (rdom._cachedTemplates.get(html)); + if (!nodeBase) { + nodeBase = document.createElement("template"); + nodeBase.innerHTML = html; + // @ts-ignore + nodeBase = nodeBase.content.firstElementChild; + rdom._cachedTemplates.set(html, /** @type {Element} */ (nodeBase)); + } + } + + if (!nodeBase && data.init) + return /** @type {HTMLElement} */ (el || data.init(null)); + + let init = !el && data.init; + let rel = rdom.init(el || /** @type {Element} */ (document.importNode(/** @type {Element} */ (nodeBase), true))); + + if (!rel["rdomCtx"]) + rel["rdomCtx"] = ""+(++rdom._lastID); + + for (let { id, value } of data.texts) { + let el = rdom.find(rel, 1, id, "text"); + if (el && value !== undefined) { + if (el.tagName === "RDOM-TEXT" && el.parentNode?.childNodes.length === 1) { + // Inline rdom-text. + el = /** @type {HTMLElement} */ (el.parentElement); + el.removeChild(el.children[0]); + el.setAttribute("rdom-text", id); + } + el.textContent = value; + } + } + + // "Collect" fields. + for (let wrap of data.fields) { + let { h, key, state, value } = wrap; + h = h || wrap; + let el = rdom.init(rdom.find(rel, key)); + let fields = el["rdomFields"]; + let states = el["rdomStates"]; + + if (!fields[key]) { + // Initialize the field. + el.setAttribute( + "rdom-fields", + `${el.getAttribute("rdom-fields") || ""} ${key}`.trim() + ); + fields[key] = h; + states[key] = state; + if (h.init) + h.init(state, el, key); + } + + // Set the value. + if (value !== undefined) + h.set(states[key], el, value); + } + + for (let { id, value } of data.renderers) { + let el = rdom.find(rel, 1, id, "render"); + if (el && value !== undefined) { + let p = el.parentNode; + if (value && value instanceof Function) + value = value(el.tagName === "RDOM-EMPTY" ? null : el); + value = value || rd$(null)``; + if (el !== value && !(el.tagName === "RDOM-EMPTY" && value.tagName === "RDOM-EMPTY")) { + // Replace (fill) the field. + p?.replaceChild(value, el); + value.setAttribute("rdom-render", id); + } + } + } + + if (elEmpty && elEmpty.parentNode) + elEmpty.parentNode.replaceChild(rel, elEmpty); + + if (init) { + let rv = init(rel); + if (rv instanceof HTMLElement) + rel = rv || rel; + } + return rel; + }, + + /** + * Parse a template string into an existing HTML element, escaping expressions unprefixed with $, inserting attribute arrays and preserving child nodes. + * Returns a function parsing a given template string into the given HTML element. + * @param {Element | null} [el] + */ + rd$(el) { + /** + * @param {TemplateStringsArray} template + * @param {any[]} values + */ + function rd$dyn(template, ...values) { return rdbuild(el || null, rdparse$(template, ...values)); } + return rd$dyn; + }, + + /** + * Parse a template string, escaping expressions unprefixed with $. + * @param {TemplateStringsArray} template + * @param {...any} values + * @returns {string} + */ + escape$(template, ...values) { + return template.reduce(function escape$reduce(prev, next, i) { + let val = values[i - 1]; + let t = prev[prev.length - 1]; + + if (t === "$") { + // Keep value as-is. + return prev.slice(0, -1) + val + next; + } + + // Escape HTML + if (val && val.join) + val = val.join(" "); + else + val = rdom.escape(val); + + if (t === "=") + // Escape attributes. + val = `"${val}"`; + + return prev + val + next; + }).trim(); + }, + +} + +export var rdparse$ = rdom.rdparse$; +export var rdbuild = rdom.rdbuild; +export var rd$ = rdom.rd$; +export var escape$ = rdom.escape$; + +/** + * Sample RDOM field handlers. + */ +export var rd = { + _: (h, key, state) => { + return { + key: key, + state: state, + init: h.init, + get: h.get, + set: h.set, + }; + }, + + _attr: { + get: (s, el) => s.v, + set: (s, el, v) => { + if (s.v === v) + return; + let prev = s.v; + s.v = v; + if (s.name.startsWith("on") && v instanceof Function) { + let ev = s.name.slice(2); + if (prev && prev instanceof Function) + el.removeEventListener(ev, prev); + el.addEventListener(ev, s.v = v.bind(el), false); + return; + } + + el.setAttribute(s.name, v); + } + }, + attr: (key, name) => rd._(rd._attr, key, { + name: name || key, + v: undefined, + }), + + _toggleClass: { + get: (s) => s.v, + set: (s, el, v) => { + if (s.v === v) + return; + s.v = v; + if (v) { + el.classList.add(s.nameTrue); + if (s.nameFalse) + el.classList.remove(s.nameFalse); + } else { + el.classList.remove(s.nameTrue); + if (s.nameFalse) + el.classList.add(s.nameFalse); + } + } + }, + toggleClass: (key, nameTrue, nameFalse) => rd._(rd._toggleClass, key, { + nameTrue: nameTrue || key, + nameFalse: nameFalse, + v: undefined, + }), + + _html: { + get: (s, el) => s.v, + set: (s, el, v) => { + if (s.v === v) + return; + s.v = v; + el.innerHTML = v; + } + }, + html: (key) => rd._(rd._html, key, { + v: undefined, + }), +} + +/** + * A list container context. + */ +export class RDOMListHelper { + /** + * @param {Element} container + */ + constructor(container, ordered = true) { + this.container = container; + this.ordered = ordered; + this._i = -1; + + if (container["rdomListHelper"]) { + let ctx = container["rdomListHelper"]; + ctx.ordered = ordered; + return ctx; + } + + this.container["rdomListHelper"] = this; + + /** + * Set of previously added elements. + * This set will be checked against [added] on cleanup, ensuring that any zombies will be removed properly. + * @type {Set} + */ + this.prev = new Set(); + /** + * Set of [rdom.add]ed elements. + * This set will be used and reset in [rdom.cleanup]. + * @type {Set} + */ + this.added = new Set(); + + /** + * All current element -> object mappings. + * @type {Map} + */ + this.refs = new Map(); + /** + * All current object -> element mappings. + * @type {Map} + */ + this.elems = new Map(); + + } + + /** + * Adds or updates an element. + * This function needs a reference object so that it can find and update existing elements for any given object. + * @param {any} ref The reference object belonging to the element. + * @param {any} render The element renderer. Either function(Element) : Element, or an object with a property "render" with such a function. + * @returns {HTMLElement} The created / updated wrapper element. + */ + add(ref, render) { + // Check if we already added an element for ref. + // If so, update it. Otherwise create and add a new element. + let el = /** @type {HTMLElement} */ (this.elems.get(ref)); + let elOld = el; + el = render.render ? render.render(el) : render(el); + + if (elOld) { + if (elOld !== el) + this.container.replaceChild(el, elOld); + } else { + this.container.appendChild(el); + } + + if (this.ordered) { + // Move the element to the given index. + rdom.move(el, ++this._i); + } + + // Register the element as "added:" - It's not a zombie and won't be removed on cleanup. + this.added.add(el); + // Register the element as the element of ref. + this.refs.set(el, ref); + this.elems.set(ref, el); + return el; + } + + /** + * Remove an element from this context, both the element in the DOM and all references in RDOM. + * @param {Element} el The element to remove. + */ + remove(el) { + if (!el) + return; + let ref = this.refs.get(el); + if (!ref) + return; // The element doesn't belong to this context - no ref object found. + // Remove the element and all related object references from the context. + this.refs.delete(el); + this.elems.delete(ref); + // Remove the element from the DOM. + el.remove(); + } + + /** + * Remove zombie elements and perform any other ending cleanup. + * Call this after the last [add]. + */ + end() { + for (let el of this.prev) { + if (this.added.has(el)) + continue; + this.remove(el); + } + let tmp = this.prev; + this.prev = this.added; + this.added = tmp; + this.added.clear(); + this._i = -1; + } + +}