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-09-21 21:27:57 +03:00

557 lines
16 KiB
TypeScript

import "phoenix_html";
import { Socket } from "phoenix";
import { LiveSocket, type ViewHook } from "phoenix_live_view";
import topbar from "../vendor/topbar";
import { VidstackPlayer, VidstackPlayerLayout } from "vidstack/global/player";
import { isHLSProvider } from "vidstack";
import HLS from "@algora/hls.js";
// TODO: add eslint & biome
// TODO: enable strict mode
// TODO: eliminate anys
interface PhxEvent extends Event {
target: Element;
detail: Record<string, any>;
}
type PhxEventKey = `js:${string}` | `phx:${string}`;
declare global {
interface Window {
liveSocket: LiveSocket;
addEventListener<K extends keyof WindowEventMap | PhxEventKey>(
type: K,
listener: (
this: Window,
ev: K extends keyof WindowEventMap ? WindowEventMap[K] : PhxEvent
) => any,
options?: boolean | AddEventListenerOptions | undefined
): void;
}
}
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: {
async mounted() {
const backdrop = document.querySelector("#video-backdrop");
this.playerId = this.el.id;
this.attemptedAutoplay = false;
this.player = await VidstackPlayer.create({
target: this.el,
viewType: "video",
streamType: "on-demand",
liveEdgeTolerance: 2,
load: "eager",
logLevel: "warn",
crossOrigin: true,
playsInline: true,
layout: new VidstackPlayerLayout(),
});
this.player.subscribe(({ autoPlayError }) => {
if (autoPlayError) {
this.player.muted = true;
this.player.play();
this.attemptedAutoplay = true;
}
});
const playVideo = (opts: {
player_id: string;
id: string;
url: string;
title: string;
poster: string;
is_live: boolean;
player_type: string;
current_time: number;
channel_name: string;
}) => {
if (this.playerId !== opts.player_id) {
return;
}
const setMediaSession = () => {
if (!("mediaSession" in navigator)) {
return;
}
navigator.mediaSession.metadata = new MediaMetadata({
title: opts.title,
artist: opts.channel_name,
album: "Algora TV",
artwork: [96, 128, 192, 256, 384, 512, 1024].map((px) => ({
src: `https://console.algora.io/asset/storage/v1/object/public/images/algora-gradient-${px}px.png`,
sizes: `${px}x${px}`,
type: "image/png",
})),
});
};
const autoplay = (() => {
// TODO: remove this once we have a better way to handle autoplay
if (this.el.id.startsWith("analytics-")) {
return false;
}
if (opts.player_type === "video/youtube") {
return navigator.userActivation.isActive;
}
return true;
})();
const startTime = (() => {
// TODO: remove this once vidstack youtube thumbnails at t=0 are fixed
if (opts.player_type === "video/youtube") {
return opts.current_time || 1;
}
return opts.current_time;
})();
this.player.autoplay = autoplay;
this.player.poster = opts.poster;
this.player.title = opts.title;
this.player.currentTime = startTime;
this.player.streamType = opts.is_live ? "ll-live:dvr" : "on-demand";
this.player.src = opts.url;
this.player.addEventListener("provider-change", (event) => {
const provider = event.detail;
if (isHLSProvider(provider)) {
provider.library = HLS;
provider.config = {
targetlatency: 6, // one segment
};
}
});
setMediaSession();
if (backdrop) {
backdrop.classList.remove("opacity-10");
backdrop.classList.add("opacity-20");
}
if (this.playerId === "video-player") {
this.pushEventTo("#clipper", "video_loaded", { id: opts.id });
}
};
this.handleEvent("play_video", playVideo);
},
},
Chat: {
mounted() {
this.el.scrollTo(0, this.el.scrollHeight);
},
updated() {
const pixelsBelowBottom =
this.el.scrollHeight - this.el.clientHeight - this.el.scrollTop;
if (pixelsBelowBottom < 200) {
this.el.scrollTo(0, this.el.scrollHeight);
}
},
},
PWAInstallPrompt: {
mounted() {
let deferredPrompt: any;
const installPrompt = document.getElementById("pwa-install-prompt");
const installButton = document.getElementById("pwa-install-button");
const closeButton = document.getElementById("pwa-close-button");
const instructionsMobile = document.getElementById(
"pwa-instructions-mobile"
);
if (
!installPrompt ||
!installButton ||
!closeButton ||
!instructionsMobile ||
localStorage.getItem("pwaPromptShown")
) {
return;
}
const scrollHeight =
(document.documentElement.scrollHeight || document.body.scrollHeight) -
document.documentElement.clientHeight;
const isMobile =
/Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(
navigator.userAgent
);
let promptShown = false;
const showPrompt = () => {
if (!promptShown) {
installPrompt.classList.remove("hidden");
if (isMobile) {
instructionsMobile.classList.remove("hidden");
installButton.classList.add("hidden");
} else {
installButton.classList.remove("hidden");
instructionsMobile.classList.add("hidden");
}
promptShown = true;
}
};
window.addEventListener(
"scroll",
() => {
const scrollPos =
document.documentElement.scrollTop || document.body.scrollTop;
if (scrollPos > Math.min(500, scrollHeight / 2) && deferredPrompt) {
showPrompt();
}
},
{ passive: true }
);
window.addEventListener("beforeinstallprompt", (e) => {
e.preventDefault();
deferredPrompt = e;
});
installButton.addEventListener("click", async () => {
if (deferredPrompt) {
deferredPrompt.prompt();
deferredPrompt = null;
}
installPrompt.classList.add("hidden");
localStorage.setItem("pwaPromptShown", "true");
});
closeButton.addEventListener("click", () => {
installPrompt.classList.add("hidden");
localStorage.setItem("pwaPromptShown", "true");
});
window.addEventListener("appinstalled", () => {
installPrompt.classList.add("hidden");
deferredPrompt = null;
localStorage.setItem("pwaPromptShown", "true");
});
},
},
TimezoneDetector: {
mounted() {
this.pushEvent("get_timezone", {
tz: Intl.DateTimeFormat().resolvedOptions().timeZone,
});
},
},
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 });
},
},
CopyToClipboard: {
value() {
return this.el.dataset.value;
},
notice() {
return this.el.dataset.notice;
},
mounted() {
this.el.addEventListener("click", () => {
navigator.clipboard.writeText(this.value()).then(() => {
this.pushEvent("copied_to_clipboard", { notice: this.notice() });
});
});
},
},
} 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();
};
// 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("phx:js-exec", ({ detail }) => {
document.querySelectorAll(detail.to).forEach((el) => {
liveSocket.execJS(el, el.getAttribute(detail.attr));
});
});
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;