jank-client-fork/src/webpage/media.ts
2025-04-04 16:31:15 -05:00

555 lines
15 KiB
TypeScript

import {Contextmenu} from "./contextmenu.js";
import {I18n} from "./i18n.js";
import {Dialog} from "./settings.js";
const menu = new Contextmenu<media, undefined>("media");
menu.addButton(
() => I18n.media.download(),
function () {
const a = document.createElement("a");
a.href = this.src;
a.download = this.filename;
a.click();
},
);
menu.addButton(
() => I18n.media.moreInfo(),
async function () {
const di = new Dialog(this.title);
const options = di.float.options;
if (this.img) {
const img = document.createElement("img");
img.classList.add("media-medium");
img.src = this.img.url;
if (this.img.description) img.alt = this.img.description;
options.addHTMLArea(img);
}
if (this.artist) {
options.addText(I18n.media.artist(this.artist));
}
if (this.composer) {
options.addText(I18n.media.composer(this.composer));
}
{
const mins = Math.floor((await this.length) / 60000);
const seconds = Math.round(((await this.length) - mins * 60000) / 1000);
options.addText(I18n.media.length(mins + "", seconds + ""));
}
di.show();
if (this.copyright) {
const text = options.addText(this.copyright);
const txt = text.elm.deref();
if (txt) {
txt.classList.add("timestamp");
}
}
},
);
type mediaEvents =
| {
type: "play";
}
| {
type: "pause";
}
| {
type: "playing";
time: number;
}
| {
type: "done";
}
| {
type: "audio";
t: "start" | "stop";
}
| {
type: "audio";
t: "skip";
time: number;
}
| {
type: "end";
};
function makePlayBox(mor: string | media, player: MediaPlayer, ctime = 0) {
const div = document.createElement("div");
div.classList.add("flexltr", "Mplayer");
const button = document.createElement("img");
button.classList.add("svg-mediaButton", "svg-play");
const vDiv = document.createElement("div");
vDiv.classList.add("flexttb");
const title = document.createElement("span");
title.textContent = I18n.media.loading();
const barDiv = document.createElement("div");
barDiv.classList.add("flexltr");
const bar = document.createElement("input");
bar.type = "range";
bar.disabled = true;
bar.value = "" + ctime;
bar.min = "0";
const time = document.createElement("span");
time.textContent = "0:00/..:..";
const more = document.createElement("span");
more.classList.add("svg-soundMore", "svg-mediaSettings");
barDiv.append(bar, time);
vDiv.append(title, barDiv);
div.append(button, vDiv, more);
MediaPlayer.IdentifyFile(mor).then((thing) => {
let audio: HTMLAudioElement | undefined = undefined;
if (!thing) {
const span = document.createElement("span");
span.textContent = I18n.media.notFound();
return;
}
menu.bindContextmenu(
more,
thing,
undefined,
() => {},
() => {},
"left",
);
player.addListener(thing.src, followUpdates, div);
let int = setInterval((_) => {}, 1000);
if (mor instanceof Object) {
const audioo = new Audio(mor.src);
audioo.load();
audioo.autoplay = true;
audioo.currentTime = ctime / 1000;
int = setInterval(() => {
if (button.classList.contains("svg-pause")) {
player.addUpdate(mor.src, {type: "playing", time: audioo.currentTime * 1000});
}
}, 100) as unknown as number;
audioo.onplay = () => {
player.addUpdate(mor.src, {type: "play"});
};
audioo.onpause = () => {
player.addUpdate(thing.src, {type: "pause"});
};
audioo.onloadeddata = () => {
audio = audioo;
};
audioo.onended = () => {
player.addUpdate(mor.src, {type: "end"});
};
}
button.onclick = () => {
if (!player.isPlaying(thing.src)) {
player.setToTopList(thing, +bar.value * 1000);
} else {
player.addUpdate(thing.src, {
type: "audio",
t: button.classList.contains("svg-play") ? "start" : "stop",
});
}
};
function followUpdates(cur: mediaEvents) {
if (audio && cur.type !== "playing") {
}
if (cur.type == "audio" && audio) {
if (cur.t == "start") {
audio.play();
} else if (cur.t == "stop") {
audio.pause();
} else if (cur.t == "skip" && audio) {
audio.currentTime = cur.time / 1000;
}
}
if (cur.type == "audio" && cur.t == "skip") {
bar.value = "" + cur.time / 1000;
}
if (cur.type == "playing") {
regenTime(cur.time);
bar.value = "" + cur.time / 1000;
button.classList.add("svg-pause");
button.classList.remove("svg-play");
} else if (cur.type === "play") {
button.classList.add("svg-pause");
button.classList.remove("svg-play");
} else if (cur.type === "pause") {
button.classList.add("svg-play");
button.classList.remove("svg-pause");
} else if (cur.type === "end") {
clearInterval(int);
if (audio) {
audio.pause();
audio.src = "";
player.end();
}
button.classList.add("svg-play");
button.classList.remove("svg-pause");
regenTime();
}
}
const med = thing;
if (med.img) {
let img: HTMLImageElement;
if (mor instanceof Object) {
img = button;
} else {
img = document.createElement("img");
div.append(img);
}
img.classList.add("media-small");
img.src = med.img.url;
}
function timeToString(time: number) {
const minutes = Math.floor(time / 1000 / 60);
const seconds = Math.round(time / 1000 - minutes * 60) + "";
return `${minutes}:${seconds.padStart(2, "0")}`;
}
bar.oninput = () => {
player.addUpdate(thing.src, {
type: "audio",
t: "skip",
time: +bar.value * 1000,
});
regenTime(+bar.value * 1000);
};
async function regenTime(curTime: number = 0) {
const len = await med.length;
bar.disabled = false;
bar.max = "" + len / 1000;
time.textContent = `${timeToString(curTime)}/${timeToString(len)}`;
}
regenTime();
title.textContent = thing.title;
});
return div;
}
interface media {
src: string;
filename: string;
img?: {
url: string;
description?: string;
};
title: string;
artist?: string;
composer?: string;
sourcy?: string;
year?: number;
copyright?: string;
length: number | Promise<number>;
}
class MediaPlayer {
lists: media[] = [];
curAudio?: HTMLElement;
private listeners: [(e: mediaEvents) => void, string][] = [];
elm: HTMLDivElement;
cur = 0;
constructor() {
this.elm = document.getElementById("player") as HTMLDivElement;
}
addElmToList(audio: media) {
this.lists.unshift(audio);
}
addUpdate(e: string, update: mediaEvents) {
for (const thing of this.listeners) {
if (thing[1] === e) {
thing[0](update);
}
}
}
addListener(audio: string, updates: (e: mediaEvents) => void, elm: HTMLElement) {
this.listeners.push([updates, audio]);
const int = setInterval(() => {
if (!document.contains(elm)) {
clearInterval(int);
this.listeners = this.listeners.filter((_) => _[0] !== updates);
}
}, 1000);
}
isPlaying(str: string) {
const med = this.lists[this.cur];
if (!med) return false;
return med.src === str;
}
setToTopList(audio: media, time: number) {
const med = this.lists[this.cur];
if (med) {
this.addUpdate(med.src, {type: "end"});
}
this.lists.splice(this.cur, 0, audio);
this.regenPlayer(time);
}
end() {
if (this.curAudio) {
this.curAudio.remove();
this.cur++;
this.regenPlayer(0);
}
}
regenPlayer(time: number) {
this.elm.innerHTML = "";
if (this.lists.length > this.cur) {
const audio = this.lists[this.cur];
this.elm.append((this.curAudio = makePlayBox(audio, this, time)));
}
}
static cache = new Map<string, media | Promise<media>>();
static async IdentifyFile(url: string | media): Promise<media | null> {
if (url instanceof Object) {
return url;
}
const cValue = this.cache.get(url);
if (cValue) {
return cValue;
}
let resMedio = (_: media) => {};
this.cache.set(url, new Promise<media>((res) => (resMedio = res)));
const controller = new AbortController();
const f = await fetch(url, {
method: "get",
signal: controller.signal,
});
if (!f.ok || !f.body) {
return null;
}
let index = 0;
const read = f.body.getReader();
let cbuff = (await read.read()).value;
const output: Partial<media> = {
src: url,
};
try {
let sizeLeft = 0;
async function next() {
return (await get8BitArray(1))[0];
}
async function get8BitArray(size: number) {
sizeLeft -= size;
const arr = new Uint8Array(size);
let arri = 0;
while (size > 0) {
if (!cbuff) throw Error("ran out of file to read");
let itter = Math.min(size, cbuff.length - index);
size -= itter;
for (let i = 0; i < itter; i++, arri++, index++) {
arr[arri] = cbuff[index];
}
if (size !== 0) {
cbuff = (await read.read()).value;
index = 0;
}
}
return arr;
}
const head = String.fromCharCode(await next(), await next(), await next());
if (head === "ID3") {
const version = (await next()) + (await next()) * 256;
if (version === 2) {
//TODO I'm like 90% I can ignore *all* of the flags, but I need to check more sometime
await next();
//debugger;
const sizes = await get8BitArray(4);
sizeLeft = (sizes[0] << 21) + (sizes[1] << 14) + (sizes[2] << 7) + sizes[3];
const mappy = new Map<string, Uint8Array>();
while (sizeLeft > 0) {
const Identify = String.fromCharCode(await next(), await next(), await next());
const sizeArr = await get8BitArray(3);
const size = (sizeArr[0] << 16) + (sizeArr[1] << 8) + sizeArr[2];
if (Identify === String.fromCharCode(0, 0, 0)) {
break;
}
if (!size) {
throw Error("weirdness");
}
if (!Identify.match(/[A-Z0-9]/gm)) {
console.error(`tried to get ${Identify} which doesn't exist`);
break;
}
if (mappy.has(Identify)) {
await get8BitArray(size);
//console.warn("Got dupe", Identify);
} else {
mappy.set(Identify, await get8BitArray(size));
}
}
const pic = mappy.get("PIC");
if (pic) {
let i = 5; //skipping info I don't need right now
const desc: number[] = [];
for (; pic[i]; i++) {
desc.push(pic[i]);
}
const description = new TextDecoder().decode(new Uint8Array(desc));
i++;
const blob = new Blob([pic.slice(i, pic.length).buffer], {type: "image/jpeg"});
const urlmaker = window.URL || window.webkitURL;
const url = urlmaker.createObjectURL(blob);
output.img = {url, description};
}
function decodeText(buf: ArrayBuffer) {
let str = new TextDecoder().decode(buf);
if (str.startsWith("\u0000")) {
str = str.slice(1, str.length);
}
if (str.endsWith("\u0000")) {
str = str.slice(0, str.length - 1);
}
return str;
}
const mapmap = {
TT2: "title",
TP1: "artist",
TCM: "composer",
TAL: "sourcy",
TCO: "type",
TEN: "copyright",
} as const;
for (const [key, ind] of Object.entries(mapmap)) {
const temp = mappy.get(key);
if (temp) {
//@ts-ignore TS is being weird about this idk why
output[ind] = decodeText(temp);
}
}
const tye = mappy.get("TYE");
if (tye) {
output.year = +decodeText(tye);
}
//TODO more thoroughly check if these two are the same format
} else if (version === 3 || version === 4) {
const flags = await next();
if (flags & 0b01000000) {
//TODO deal with the extended header
}
//debugger;
const sizes = await get8BitArray(4);
sizeLeft = (sizes[0] << 21) + (sizes[1] << 14) + (sizes[2] << 7) + sizes[3];
const mappy = new Map<string, Uint8Array>();
while (sizeLeft > 0) {
const Identify = String.fromCharCode(
await next(),
await next(),
await next(),
await next(),
);
const sizeArr = await get8BitArray(4);
const size = (sizeArr[0] << 24) + (sizeArr[1] << 16) + (sizeArr[2] << 8) + sizeArr[3];
const flags = await get8BitArray(2);
const compression = !!(flags[1] & 0b10000000);
if (compression) {
//TODO Honestly, I don't know if I can do this with normal JS
continue;
}
const encyption = !!(flags[1] & 0b01000000);
if (encyption) {
//TODO not sure what this would even do
continue;
}
if (Identify === String.fromCharCode(0, 0, 0, 0)) {
break;
}
if (!size) {
//throw Error("weirdness");
}
if (!Identify.match(/[A-Z0-9]/gm)) {
console.error(`tried to get ${Identify} which doesn't exist`);
break;
}
if (mappy.has(Identify)) {
await get8BitArray(size);
//console.warn("Got dupe", Identify);
} else {
mappy.set(Identify, await get8BitArray(size));
}
}
const pic = mappy.get("APIC");
if (pic) {
const encoding = pic[0];
let i = 1; //skipping info I don't need right now
for (; pic[i]; i++) {}
i += 2;
let desc: number[] = [];
for (; pic[i]; i++) {
desc.push(pic[i]);
}
const description = new TextDecoder().decode(new Uint8Array(desc));
i++;
const blob = new Blob([pic.slice(i, pic.length).buffer], {type: "image/jpeg"});
const urlmaker = window.URL || window.webkitURL;
const url = urlmaker.createObjectURL(blob);
output.img = {url, description};
}
function decodeText(buf: ArrayBuffer) {
let str = new TextDecoder().decode(buf);
if (str.startsWith("\u0000")) {
str = str.slice(1, str.length);
}
if (str.endsWith("\u0000")) {
str = str.slice(0, str.length - 1);
}
return str;
}
const mapmap = {
TIT2: "title",
TPE1: "artist",
TCOM: "composer",
TALB: "sourcy",
TMED: "type",
TENC: "copyright",
} as const;
for (const [key, ind] of Object.entries(mapmap)) {
const temp = mappy.get(key);
if (temp) {
//@ts-ignore TS is being weird about this idk why
output[ind] = decodeText(temp);
}
}
const TYER = mappy.get("TYER");
if (TYER) {
output.year = +decodeText(TYER);
}
const TLEN = mappy.get("TLEN");
if (TLEN) {
output.length = +decodeText(TLEN);
}
}
} //TODO implement more metadata types
} catch (e) {
console.error(e);
} finally {
output.filename = url.split("/").at(-1);
controller.abort();
if (!output.length) {
output.length = new Promise<number>(async (res) => {
const audio = document.createElement("audio");
audio.src = url;
audio.onloadeddata = (_) => {
output.length = audio.duration * 1000;
res(audio.duration * 1000);
};
audio.load();
});
}
if (!output.title) {
output.title = output.filename;
}
}
resMedio(output as media);
return output as media;
}
}
export {MediaPlayer, media, makePlayBox};