Initial commit
This commit is contained in:
commit
e383aa4738
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
.htaccess
|
||||
/priv/
|
||||
/.vscode/
|
318
Jadefin.js
Normal file
318
Jadefin.js
Normal file
@ -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<JadefinMod>} */
|
||||
_loadingMods_dedupe = new Set();
|
||||
/** @type {{module: JadefinMod, name: String, url: String}[]} */
|
||||
_loadingMods_all = [];
|
||||
/** @type {Promise<void>} */
|
||||
_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);
|
||||
}
|
||||
}));
|
||||
|
||||
}
|
||||
|
||||
})());
|
54
JadefinIntegrity.js
Normal file
54
JadefinIntegrity.js
Normal file
@ -0,0 +1,54 @@
|
||||
//@ts-check
|
||||
|
||||
const ID = "JadefinIntegrity";
|
||||
|
||||
class JadefinIntegrity {
|
||||
/** @type {Map<string, {url: string, loaded: any}>} */
|
||||
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);
|
84
JadefinLog.js
Normal file
84
JadefinLog.js
Normal file
@ -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;
|
||||
}
|
||||
|
||||
});
|
61
JadefinMod.js
Normal file
61
JadefinMod.js
Normal file
@ -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$()`<link rel="stylesheet" type="text/css" href=${this.getUrl(".css")}>`);
|
||||
}
|
||||
|
||||
/**
|
||||
* @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;
|
238
JadefinModules.js
Normal file
238
JadefinModules.js
Normal file
@ -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<string>
|
||||
}}
|
||||
*/
|
||||
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;
|
||||
}
|
||||
|
||||
})());
|
50
JadefinStorage.js
Normal file
50
JadefinStorage.js
Normal file
@ -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;
|
93
JadefinUtils.js
Normal file
93
JadefinUtils.js
Normal file
@ -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;
|
||||
}
|
||||
|
||||
})());
|
6
README.md
Normal file
6
README.md
Normal file
@ -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.
|
105
init.js
Normal file
105
init.js
Normal file
@ -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);
|
||||
})();
|
9
mods.json
Normal file
9
mods.json
Normal file
@ -0,0 +1,9 @@
|
||||
[
|
||||
"ExtrasMenu.js",
|
||||
"VolumeBoost.js",
|
||||
"FixStuck.js",
|
||||
"VersionCheck.js",
|
||||
"Screenshot.js",
|
||||
"InputEater.js",
|
||||
"Transcript.js"
|
||||
]
|
14
mods/ExtrasMenu.css
Normal file
14
mods/ExtrasMenu.css
Normal file
@ -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;
|
||||
}
|
203
mods/ExtrasMenu.js
Normal file
203
mods/ExtrasMenu.js
Normal file
@ -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$()`
|
||||
<button is="paper-icon-button-light" class="headerExtrasMenu headerButton headerButtonRight" title="Extras" style="display: inline-flex;">
|
||||
<span class="material-icons tune"></span>
|
||||
</button>
|
||||
`;
|
||||
|
||||
drawerExtrasEl = rd$()`
|
||||
<div class="extrasMenuOptions">
|
||||
</div>
|
||||
`;
|
||||
|
||||
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 = `<h3 class="sidebarHeader">Extras</h3>`;
|
||||
|
||||
for (let item of this._items) {
|
||||
if (!this.checkVisibility(this.IN_DRAWER, item)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let itemEl = rd$()`
|
||||
<a is="emby-linkbutton" class="navMenuOption emby-button" href="#">
|
||||
<span class=${`material-icons navMenuOptionIcon ${item.icon}`} aria-hidden="true"></span>
|
||||
<div class="navMenuOptionTextBlock">
|
||||
<span class="navMenuOptionText">${item.name}</span><br>
|
||||
<span class="navMenuOptionTextSubtext">${item.secondaryText}</span>
|
||||
</div>
|
||||
</a>
|
||||
`;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
})());
|
39
mods/FixStuck.js
Normal file
39
mods/FixStuck.js
Normal file
@ -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")
|
||||
);
|
||||
}
|
||||
|
||||
})());
|
27
mods/InputEater.css
Normal file
27
mods/InputEater.css
Normal file
@ -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 */
|
1
mods/InputEater.css.map
Normal file
1
mods/InputEater.css.map
Normal file
@ -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}"]}
|
371
mods/InputEater.js
Normal file
371
mods/InputEater.js
Normal file
@ -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$()`
|
||||
<button is="paper-icon-button-light" class="btnRemote autoSize paper-icon-button-light" title="Grab remote">
|
||||
<span class="xlargePaperIconButton material-icons settings_remote" aria-hidden="true"></span>
|
||||
</button>
|
||||
`;
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
})());
|
34
mods/InputEater.scss
Normal file
34
mods/InputEater.scss
Normal file
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
72
mods/Screenshot.js
Normal file
72
mods/Screenshot.js
Normal file
@ -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
|
||||
})
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
})());
|
87
mods/Transcript.css
Normal file
87
mods/Transcript.css
Normal file
@ -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 */
|
1
mods/Transcript.css.map
Normal file
1
mods/Transcript.css.map
Normal file
File diff suppressed because one or more lines are too long
629
mods/Transcript.js
Normal file
629
mods/Transcript.js
Normal file
@ -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$()`
|
||||
<button is="paper-icon-button-light" class="btnTranscript autoSize paper-icon-button-light" title="Transcript">
|
||||
<span class="xlargePaperIconButton material-icons manage_search" aria-hidden="true"></span>
|
||||
</button>
|
||||
`;
|
||||
|
||||
_osdTranscript = rd$()`
|
||||
<div class="osdTranscript">
|
||||
<div class="transcriptLogWrap">
|
||||
<table class="transcriptLog"></table>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
_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(/^(?<!\\){[^}]*}m .+$/g, "");
|
||||
|
||||
// https://aegisub.org/docs/3.2/ASS_Tags/
|
||||
// Just a basic set of tags - anything more advanced should hopefully only be found in {}
|
||||
text = text.replace(/(?:\\(?:n|N|h|[ius][10]|b-?[\d.]+|[xy]?(?:bord|shad|be|blur|fs|fsc[xy]|fsp|fr[xyz]?|fa[xy]|fe|an?|[kpq])-?[\d.]+|r))+/g, " ");
|
||||
}
|
||||
|
||||
text = text.replace(/(?<!\\){[^}]*}/g, "");
|
||||
text = text.replace(/\n\n\n+/g, "\n\n");
|
||||
line.text = text;
|
||||
|
||||
const span = document.createElement("span");
|
||||
span.innerText = text;
|
||||
span.innerHTML = span.innerHTML.replace("\n", "<br>");
|
||||
|
||||
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)`
|
||||
<tr class="line" data-index=${i} data-id=${line.id} data-positive=${line.isPositive}>
|
||||
<td class="time">${line.timeText}</td>
|
||||
<td class="text">${line.textSpan}</td>
|
||||
</tr>
|
||||
`;
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
})());
|
124
mods/Transcript.scss
Normal file
124
mods/Transcript.scss
Normal file
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
24
mods/VersionCheck.css
Normal file
24
mods/VersionCheck.css
Normal file
@ -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 */
|
1
mods/VersionCheck.css.map
Normal file
1
mods/VersionCheck.css.map
Normal file
@ -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}"]}
|
78
mods/VersionCheck.js
Normal file
78
mods/VersionCheck.js
Normal file
@ -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$()`<span class="versionCheck"></span>`);
|
||||
|
||||
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$()`<span class="versionCheck"></span>`);
|
||||
};
|
||||
}
|
||||
})());
|
25
mods/VersionCheck.scss
Normal file
25
mods/VersionCheck.scss
Normal file
@ -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;
|
||||
}
|
||||
}
|
198
mods/VolumeBoost.js
Normal file
198
mods/VolumeBoost.js
Normal file
@ -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();
|
||||
}
|
||||
}
|
||||
})());
|
34
mods/jade/PatchAndroidHLSJS.js
Normal file
34
mods/jade/PatchAndroidHLSJS.js
Normal file
@ -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);
|
||||
}
|
||||
})());
|
10
mods/jade/Shortcuts.css
Normal file
10
mods/jade/Shortcuts.css
Normal file
@ -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 */
|
1
mods/jade/Shortcuts.css.map
Normal file
1
mods/jade/Shortcuts.css.map
Normal file
@ -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}"]}
|
63
mods/jade/Shortcuts.js
Normal file
63
mods/jade/Shortcuts.js
Normal file
@ -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$()`
|
||||
<progress min="0" max="100" class="refreshProgress"></progress>
|
||||
`);
|
||||
}
|
||||
|
||||
JadefinModules.taskButton({
|
||||
mode: alive ? "on" : "off",
|
||||
taskKey: "RefreshLibrary",
|
||||
button: el,
|
||||
progressElem: el.querySelector(".refreshProgress")
|
||||
});
|
||||
},
|
||||
cb: () => {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
})());
|
15
mods/jade/Shortcuts.scss
Normal file
15
mods/jade/Shortcuts.scss
Normal file
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
6
mods_example.json
Normal file
6
mods_example.json
Normal file
@ -0,0 +1,6 @@
|
||||
[
|
||||
"InsideMods.js",
|
||||
"/jadefin/mods/SameDomain.js",
|
||||
"//example.com/jadefin/mods/SameProtocol.js",
|
||||
"list://example.com/jadefin/mods/mods_extra.json"
|
||||
]
|
12
mods_jade.json
Normal file
12
mods_jade.json
Normal file
@ -0,0 +1,12 @@
|
||||
[
|
||||
"ExtrasMenu.js",
|
||||
"VolumeBoost.js",
|
||||
"FixStuck.js",
|
||||
"VersionCheck.js",
|
||||
"Screenshot.js",
|
||||
"InputEater.js",
|
||||
"Transcript.js",
|
||||
|
||||
"jade/Shortcuts.js",
|
||||
"jade/PatchAndroidHLSJS.js"
|
||||
]
|
19
update/index.html
Normal file
19
update/index.html
Normal file
@ -0,0 +1,19 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title>jellyfin.0x0a.de cache refresh</title>
|
||||
<script>
|
||||
(async () => {
|
||||
for (let key of await caches.keys()) {
|
||||
await caches.delete(key);
|
||||
}
|
||||
|
||||
localStorage.setItem("jadefin-decache", "1");
|
||||
window.location = "/";
|
||||
})();
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<p>This page should refresh automatically.<br><a href="/">If you're stuck, click here.</a></p>
|
||||
</body>
|
||||
</html>
|
30
utils/cyrb53.js
Normal file
30
utils/cyrb53.js
Normal file
@ -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);
|
||||
};
|
673
utils/rdom.js
Normal file
673
utils/rdom.js
Normal file
@ -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<string, Element>} */
|
||||
_cachedTemplates: new Map(),
|
||||
/** @type {Map<TemplateStringsArray, number[]>} */
|
||||
_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 = "") => `<rdom-${tag} ${attr}>${val}</rdom-${tag}>`
|
||||
|
||||
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)`<rdom-empty/>`;
|
||||
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<Element>}
|
||||
*/
|
||||
this.prev = new Set();
|
||||
/**
|
||||
* Set of [rdom.add]ed elements.
|
||||
* This set will be used and reset in [rdom.cleanup].
|
||||
* @type {Set<Element>}
|
||||
*/
|
||||
this.added = new Set();
|
||||
|
||||
/**
|
||||
* All current element -> object mappings.
|
||||
* @type {Map<Element, any>}
|
||||
*/
|
||||
this.refs = new Map();
|
||||
/**
|
||||
* All current object -> element mappings.
|
||||
* @type {Map<any, Element>}
|
||||
*/
|
||||
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;
|
||||
}
|
||||
|
||||
}
|
Loading…
Reference in New Issue
Block a user