From e383aa4738bbd7b4fbb5d4da31081004ef123bd8 Mon Sep 17 00:00:00 2001 From: Jade Macho Date: Fri, 1 Mar 2024 21:01:22 +0100 Subject: [PATCH] Initial commit --- .gitignore | 3 + Jadefin.js | 318 ++++++++++++++++ JadefinIntegrity.js | 54 +++ JadefinLog.js | 84 ++++ JadefinMod.js | 61 +++ JadefinModules.js | 238 ++++++++++++ JadefinStorage.js | 50 +++ JadefinUtils.js | 93 +++++ README.md | 6 + init.js | 105 +++++ mods.json | 9 + mods/ExtrasMenu.css | 14 + mods/ExtrasMenu.js | 203 ++++++++++ mods/FixStuck.js | 39 ++ mods/InputEater.css | 27 ++ mods/InputEater.css.map | 1 + mods/InputEater.js | 371 ++++++++++++++++++ mods/InputEater.scss | 34 ++ mods/Screenshot.js | 72 ++++ mods/Transcript.css | 87 +++++ mods/Transcript.css.map | 1 + mods/Transcript.js | 629 ++++++++++++++++++++++++++++++ mods/Transcript.scss | 124 ++++++ mods/VersionCheck.css | 24 ++ mods/VersionCheck.css.map | 1 + mods/VersionCheck.js | 78 ++++ mods/VersionCheck.scss | 25 ++ mods/VolumeBoost.js | 198 ++++++++++ mods/jade/PatchAndroidHLSJS.js | 34 ++ mods/jade/Shortcuts.css | 10 + mods/jade/Shortcuts.css.map | 1 + mods/jade/Shortcuts.js | 63 +++ mods/jade/Shortcuts.scss | 15 + mods_example.json | 6 + mods_jade.json | 12 + update/index.html | 19 + utils/cyrb53.js | 30 ++ utils/rdom.js | 673 +++++++++++++++++++++++++++++++++ 38 files changed, 3812 insertions(+) create mode 100644 .gitignore create mode 100644 Jadefin.js create mode 100644 JadefinIntegrity.js create mode 100644 JadefinLog.js create mode 100644 JadefinMod.js create mode 100644 JadefinModules.js create mode 100644 JadefinStorage.js create mode 100644 JadefinUtils.js create mode 100644 README.md create mode 100644 init.js create mode 100644 mods.json create mode 100644 mods/ExtrasMenu.css create mode 100644 mods/ExtrasMenu.js create mode 100644 mods/FixStuck.js create mode 100644 mods/InputEater.css create mode 100644 mods/InputEater.css.map create mode 100644 mods/InputEater.js create mode 100644 mods/InputEater.scss create mode 100644 mods/Screenshot.js create mode 100644 mods/Transcript.css create mode 100644 mods/Transcript.css.map create mode 100644 mods/Transcript.js create mode 100644 mods/Transcript.scss create mode 100644 mods/VersionCheck.css create mode 100644 mods/VersionCheck.css.map create mode 100644 mods/VersionCheck.js create mode 100644 mods/VersionCheck.scss create mode 100644 mods/VolumeBoost.js create mode 100644 mods/jade/PatchAndroidHLSJS.js create mode 100644 mods/jade/Shortcuts.css create mode 100644 mods/jade/Shortcuts.css.map create mode 100644 mods/jade/Shortcuts.js create mode 100644 mods/jade/Shortcuts.scss create mode 100644 mods_example.json create mode 100644 mods_jade.json create mode 100644 update/index.html create mode 100644 utils/cyrb53.js create mode 100644 utils/rdom.js 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; + } + +}