1
0
mirror of https://github.com/algora-io/tv.git synced 2024-11-26 01:00:20 +02:00
algora-tv/assets/js/app.ts
2024-05-05 02:27:04 +03:00

370 lines
10 KiB
TypeScript

import "phoenix_html";
import { Socket } from "phoenix";
import { LiveSocket, type ViewHook } from "phoenix_live_view";
import Chat from "./user_socket";
import topbar from "../vendor/topbar";
import videojs from "../vendor/video";
import "../vendor/videojs-youtube";
// TODO: add eslint & biome
// TODO: enable strict mode
// TODO: eliminate anys
let isVisible = (el) =>
!!(el.offsetWidth || el.offsetHeight || el.getClientRects().length > 0);
let execJS = (selector, attr) => {
document
.querySelectorAll(selector)
.forEach((el) => liveSocket.execJS(el, el.getAttribute(attr)));
};
const Hooks = {
Flash: {
mounted() {
let hide = () =>
liveSocket.execJS(this.el, this.el.getAttribute("phx-click"));
this.timer = setTimeout(() => hide(), 8000);
this.el.addEventListener("phx:hide-start", () =>
clearTimeout(this.timer)
);
this.el.addEventListener("mouseover", () => {
clearTimeout(this.timer);
this.timer = setTimeout(() => hide(), 8000);
});
},
destroyed() {
clearTimeout(this.timer);
},
},
Menu: {
getAttr(name) {
let val = this.el.getAttribute(name);
if (val === null) {
throw new Error(`no ${name} attribute configured for menu`);
}
return val;
},
reset() {
this.enabled = false;
this.activeClass = this.getAttr("data-active-class");
this.deactivate(this.menuItems());
this.activeItem = null;
window.removeEventListener("keydown", this.handleKeyDown);
},
destroyed() {
this.reset();
},
mounted() {
this.menuItemsContainer = document.querySelector(
`[aria-labelledby="${this.el.id}"]`
);
this.reset();
this.handleKeyDown = (e) => this.onKeyDown(e);
this.el.addEventListener("keydown", (e) => {
if (
(e.key === "Enter" || e.key === " ") &&
e.currentTarget.isSameNode(this.el)
) {
this.enabled = true;
}
});
this.el.addEventListener("click", (e) => {
if (!e.currentTarget.isSameNode(this.el)) {
return;
}
window.addEventListener("keydown", this.handleKeyDown);
// disable if button clicked and click was not a keyboard event
if (this.enabled) {
window.requestAnimationFrame(() => this.activate(0));
}
});
this.menuItemsContainer.addEventListener("phx:hide-start", () =>
this.reset()
);
},
activate(index, fallbackIndex) {
let menuItems = this.menuItems();
this.activeItem = menuItems[index] || menuItems[fallbackIndex];
this.activeItem.classList.add(this.activeClass);
this.activeItem.focus();
},
deactivate(items) {
items.forEach((item) => item.classList.remove(this.activeClass));
},
menuItems() {
return Array.from(
this.menuItemsContainer.querySelectorAll("[role=menuitem]")
);
},
onKeyDown(e) {
if (e.key === "Escape") {
document.body.click();
this.el.focus();
this.reset();
} else if (e.key === "Enter" && !this.activeItem) {
this.activate(0);
} else if (e.key === "Enter") {
this.activeItem.click();
}
if (e.key === "ArrowDown") {
e.preventDefault();
let menuItems = this.menuItems();
this.deactivate(menuItems);
this.activate(menuItems.indexOf(this.activeItem) + 1, 0);
} else if (e.key === "ArrowUp") {
e.preventDefault();
let menuItems = this.menuItems();
this.deactivate(menuItems);
this.activate(
menuItems.indexOf(this.activeItem) - 1,
menuItems.length - 1
);
} else if (e.key === "Tab") {
e.preventDefault();
}
},
},
VideoPlayer: {
mounted() {
const backdrop = document.querySelector("#video-backdrop");
this.player = videojs("video-player", {
autoplay: true,
liveui: true,
html5: {
vhs: {
llhls: true,
},
},
});
const playVideo = ({ detail }) => {
const { player } = detail;
this.player.options({
techOrder: [player.type === "video/youtube" ? "youtube" : "html5"],
...(player.currentTime && player.type === "video/youtube"
? { youtube: { customVars: { start: player.currentTime } } }
: {}),
});
this.player.src({ src: player.src, type: player.type });
this.player.play();
if (player.currentTime && player.type !== "video/youtube") {
this.player.currentTime(player.currentTime);
}
this.player.el().parentElement.classList.remove("hidden");
this.player.el().parentElement.classList.add("flex");
if (backdrop) {
backdrop.classList.remove("opacity-10");
backdrop.classList.add("opacity-20");
}
};
this.handleEvent("play_video", playVideo);
this.handleEvent("join_chat", Chat.join);
this.handleEvent("message_deleted", ({ id }) => {
document.querySelector(`#message-${id}`)?.remove();
});
},
},
NavBar: {
mounted() {
const offset = 16;
this.isOpaque = false;
const onScroll = () => {
if (!this.isOpaque && window.scrollY > offset) {
this.isOpaque = true;
this.el.classList.add("bg-gray-950");
this.el.classList.remove("bg-transparent");
} else if (this.isOpaque && window.scrollY <= offset) {
this.isOpaque = false;
this.el.classList.add("bg-transparent");
this.el.classList.remove("bg-gray-950");
}
};
window.addEventListener("scroll", onScroll, { passive: true });
},
},
} satisfies Record<string, Partial<ViewHook> & Record<string, unknown>>;
// Accessible focus handling
let Focus = {
focusMain() {
let target =
document.querySelector<HTMLElement>("main h1") ||
document.querySelector<HTMLElement>("main");
if (target) {
let origTabIndex = target.tabIndex;
target.tabIndex = -1;
target.focus();
target.tabIndex = origTabIndex;
}
},
// Subject to the W3C Software License at https://www.w3.org/Consortium/Legal/2015/copyright-software-and-document
isFocusable(el) {
if (
el.tabIndex > 0 ||
(el.tabIndex === 0 && el.getAttribute("tabIndex") !== null)
) {
return true;
}
if (el.disabled) {
return false;
}
switch (el.nodeName) {
case "A":
return !!el.href && el.rel !== "ignore";
case "INPUT":
return el.type != "hidden" && el.type !== "file";
case "BUTTON":
case "SELECT":
case "TEXTAREA":
return true;
default:
return false;
}
},
// Subject to the W3C Software License at https://www.w3.org/Consortium/Legal/2015/copyright-software-and-document
attemptFocus(el) {
if (!el) {
return;
}
if (!this.isFocusable(el)) {
return false;
}
try {
el.focus();
} catch (e) {}
return document.activeElement === el;
},
// Subject to the W3C Software License at https://www.w3.org/Consortium/Legal/2015/copyright-software-and-document
focusFirstDescendant(el) {
for (let i = 0; i < el.childNodes.length; i++) {
let child = el.childNodes[i];
if (this.attemptFocus(child) || this.focusFirstDescendant(child)) {
return true;
}
}
return false;
},
// Subject to the W3C Software License at https://www.w3.org/Consortium/Legal/2015/copyright-software-and-document
focusLastDescendant(element) {
for (let i = element.childNodes.length - 1; i >= 0; i--) {
let child = element.childNodes[i];
if (this.attemptFocus(child) || this.focusLastDescendant(child)) {
return true;
}
}
return false;
},
};
let csrfToken = document
.querySelector("meta[name='csrf-token']")!
.getAttribute("content");
let liveSocket = new LiveSocket("/live", Socket, {
hooks: Hooks,
params: { _csrf_token: csrfToken },
dom: {
onNodeAdded(node) {
if (node instanceof HTMLElement && node.autofocus) {
node.focus();
}
return node;
},
},
});
let routeUpdated = () => {
// TODO: uncomment
// Focus.focusMain();
const player = document.querySelector("#video-player")?.parentElement;
if (!player) {
return;
}
const { pathname } = new URL(window.location.href);
if (pathname.endsWith("/embed")) {
return;
}
const pipClasses = [
"fixed",
"bottom-0",
"right-0",
"z-[1000]",
"w-[100vw]",
"sm:w-[30vw]",
];
if (/^\/[^\/]+\/\d+$/.test(pathname)) {
player.classList.add("lg:pr-[24rem]");
player.classList.remove(...pipClasses);
} else {
player.classList.remove("lg:pr-[24rem]");
player.classList.add(...pipClasses);
}
};
// Show progress bar on live navigation and form submits
topbar.config({
barColors: { 0: "rgba(79, 70, 229, 1)" },
shadowColor: "rgba(0, 0, 0, .3)",
});
window.addEventListener("phx:page-loading-start", (info) =>
topbar.delayedShow(200)
);
window.addEventListener("phx:page-loading-stop", (info) => topbar.hide());
// Accessible routing
window.addEventListener("phx:page-loading-stop", routeUpdated);
window.addEventListener("js:exec", (e) =>
e.target[e.detail.call](...e.detail.args)
);
window.addEventListener("js:focus", (e) => {
let parent = document.querySelector(e.detail.parent);
if (parent && isVisible(parent)) {
(e.target as any).focus();
}
});
window.addEventListener("js:focus-closest", (e) => {
let el = e.target;
let sibling = el.nextElementSibling;
while (sibling) {
if (isVisible(sibling) && Focus.attemptFocus(sibling)) {
return;
}
sibling = sibling.nextElementSibling;
}
sibling = el.previousElementSibling;
while (sibling) {
if (isVisible(sibling) && Focus.attemptFocus(sibling)) {
return;
}
sibling = sibling.previousElementSibling;
}
Focus.attemptFocus((el as any).parent) || Focus.focusMain();
});
window.addEventListener("phx:remove-el", (e) =>
document.getElementById(e.detail.id)?.remove()
);
// connect if there are any LiveViews on the page
liveSocket.getSocket().onOpen(() => execJS("#connection-status", "js-hide"));
liveSocket.getSocket().onError(() => execJS("#connection-status", "js-show"));
liveSocket.connect();
// expose liveSocket on window for web console debug logs and latency simulation:
// >> liveSocket.enableDebug()
// >> liveSocket.enableLatencySim(1000) // enabled for duration of browser session
// >> liveSocket.disableLatencySim()
window.liveSocket = liveSocket;