Initial commit

This commit is contained in:
Jade Macho 2024-03-01 21:01:22 +01:00
commit e383aa4738
Signed by: 0x0ade
GPG Key ID: E1960710FE4FBEEF
38 changed files with 3812 additions and 0 deletions

3
.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
.htaccess
/priv/
/.vscode/

318
Jadefin.js Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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

File diff suppressed because one or more lines are too long

629
mods/Transcript.js Normal file
View 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
View 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
View 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 */

View 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
View 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
View 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
View 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();
}
}
})());

View 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
View 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 */

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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 += "&amp;";
else if (c === "<")
n += "&lt;";
else if (c === ">")
n += "&gt;";
else if (c === "\"")
n += "&quot;";
else if (c === "'")
n += "&#039;";
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;
}
}