Something went wrong. Try again.
a simple but full-featured web player for OpenSubsonic music servers (Navidrome, Gonic, Ampache, etc) devins.page/tinysub
svelte typescript javascript subsonic gonic navidrome
Something went wrong. Try again.
TypeScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222import { tick } from "svelte";import { MediaQuery } from "svelte/reactivity";import { settings } from "./settings.svelte.js";
const activationTimers = new WeakMap<HTMLElement, number>();const entryAnimations = new WeakMap<HTMLElement, Animation>();const imageLoadingClass = "image-loading";
export const prefersReducedMotion = new MediaQuery( "(prefers-reduced-motion: reduce)",);
export const entryMotion = { duration: 150, stagger: 20, maxDelay: 100,} as const;
export const subtleMoveMotion = { duration: 80, easing: "cubic-bezier(0.25, 1, 0.5, 1)",} as const;
export function animateEntryRows(rows: Iterable<HTMLElement>) { let index = 0; for (const row of rows) { if (entryAnimations.has(row)) { index++; continue; } const animation = row.animate( [ { opacity: 0, translate: "0 -0.25rem" }, { opacity: 1, translate: "0" }, ], { duration: entryMotion.duration, delay: entryMotion.maxDelay * (1 - Math.exp((-index * entryMotion.stagger) / entryMotion.maxDelay)), easing: "ease-out", fill: "backwards", }, ); entryAnimations.set(row, animation); animation.onfinish = animation.oncancel = () => { if (entryAnimations.get(row) === animation) entryAnimations.delete(row); }; index++; }}
export const motionEnabled = () => settings.motion && !prefersReducedMotion.current;
export function fadeImage(image: HTMLImageElement, fadeIfComplete = false) { if (!motionEnabled() || (image.complete && !fadeIfComplete)) return;
image.classList.add(imageLoadingClass); let fadeAnimation: Animation | undefined; const reveal = (fade = false) => { image.classList.remove(imageLoadingClass); if (!fade || !motionEnabled() || !image.naturalWidth) return;
fadeAnimation?.cancel(); const nextAnimation = image.animate( [{ opacity: 0 }, { opacity: getComputedStyle(image).opacity }], { duration: entryMotion.duration, easing: "ease-out", }, ); fadeAnimation = nextAnimation; nextAnimation.onfinish = nextAnimation.oncancel = () => { if (fadeAnimation === nextAnimation) fadeAnimation = undefined; }; };
const handleLoad = () => reveal(true); const handleError = () => reveal(); if (image.complete) { handleLoad(); } else { image.addEventListener("load", handleLoad); image.addEventListener("error", handleError); } return () => { fadeAnimation?.cancel(); image.classList.remove(imageLoadingClass); image.removeEventListener("load", handleLoad); image.removeEventListener("error", handleError); };}
export function animateActivation(target: string | HTMLElement | null) { const element = typeof target === "string" ? document.querySelector<HTMLElement>(target) : target; if (!element || !motionEnabled()) return;
const previousTimer = activationTimers.get(element); if (previousTimer !== undefined) clearTimeout(previousTimer); element.classList.remove("is-activated"); void element.offsetWidth;
element.classList.add("is-activated"); const duration = getComputedStyle(element).animationDuration; const timer = window.setTimeout( () => { if (activationTimers.get(element) !== timer) return; activationTimers.delete(element); element.classList.remove("is-activated"); }, parseFloat(duration) * (duration.endsWith("ms") ? 1 : 1000), ); activationTimers.set(element, timer);}
const rejectionAnimations = new WeakMap<HTMLButtonElement, Animation>();
export function animateButtonRejection(button: HTMLButtonElement | null) { if (!button || !motionEnabled()) return;
rejectionAnimations.get(button)?.cancel(); const animation = button.animate( [ { translate: "0" }, { translate: "0.25rem 0" }, { translate: "-0.25rem 0" }, { translate: "0.125rem 0" }, { translate: "-0.125rem 0" }, { translate: "0" }, ], { duration: 250, easing: "ease-out" }, ); rejectionAnimations.set(button, animation); animation.onfinish = animation.oncancel = () => { if (rejectionAnimations.get(button) === animation) rejectionAnimations.delete(button); };}
const layoutAnimations = new WeakMap<HTMLElement, Animation>();
export const animateTreeEntries = ( container: HTMLElement | undefined, keys?: readonly string[], includeLabels = false,) => { if (!container || !motionEnabled()) return;
void tick().then(() => requestAnimationFrame(() => { if (!motionEnabled()) return; const rows = [ ...container.querySelectorAll<HTMLElement>("[data-tree-key]"), ].filter( (row) => !keys || keys.some( (key) => row.dataset.treeKey?.startsWith(`${key}/`) || (includeLabels && row.dataset.treeKey === key), ), ); animateEntryRows(rows); }), );};
export const captureTreeItemPositions = ( container: HTMLElement | undefined,) => { if (!container || !motionEnabled()) return; return new Map( [...container.querySelectorAll<HTMLElement>("[data-tree-key]")].map( (row) => [row, row.getBoundingClientRect().top], ), );};
const animateTreeItemPositions = ( positions: Map<HTMLElement, number> | undefined,) => { if (!positions) return;
void tick().then(() => requestAnimationFrame(() => { if (!motionEnabled()) return; for (const [row, previousTop] of positions) { if (!row.isConnected) continue; const offset = Math.max( -20, Math.min(20, previousTop - row.getBoundingClientRect().top), ); if (!offset) continue;
layoutAnimations.get(row)?.cancel(); const animation = row.animate( [{ translate: `0 ${offset}px` }, { translate: "0" }], subtleMoveMotion, ); layoutAnimations.set(row, animation); animation.onfinish = animation.oncancel = () => { if (layoutAnimations.get(row) === animation) layoutAnimations.delete(row); }; } }), );};
export const animateTreeExpansion = ( container: HTMLElement | undefined, key: string, positions: Map<HTMLElement, number> | undefined,) => { animateTreeEntries(container, [key]); animateTreeItemPositions(positions);};