Refactor to dedupe URIs in CSS

This commit is contained in:
Jordan Eldredge 2020-12-06 00:10:32 -08:00
parent c0c78903dc
commit afa358e488
5 changed files with 97 additions and 84 deletions

View file

@ -0,0 +1,75 @@
import { parseAni } from "./aniParser";
import { AniCursorImage } from "./types";
import * as Utils from "./utils";
import * as FileUtils from "./fileUtils";
const JIFFIES_PER_MS = 1000 / 60;
export function readAni(contents: Uint8Array): AniCursorImage {
const ani = parseAni(contents);
const rate = ani.rate ?? ani.images.map(() => ani.metadata.iDispRate);
const duration = Utils.sum(rate);
const frames = ani.images.map((image) => ({
url: FileUtils.curUrlFromByteArray(image),
percents: [] as number[],
}));
let elapsed = 0;
rate.forEach((r, i) => {
const frameIdx = ani.seq ? ani.seq[i] : i;
frames[frameIdx].percents.push((elapsed / duration) * 100);
elapsed += r;
});
return { duration: duration * JIFFIES_PER_MS, frames };
}
// Generate CSS for an animated cursor.
//
// Based on https://css-tricks.com/forums/topic/animated-cursor/
//
// Browsers won't render animated cursor images specified via CSS. For `.ani`
// images, we already have the frames as indiviual images, so we create a CSS
// animation.
//
// This function returns CSS containing a set of keyframes with embedded Data
// URIs as well as a CSS rule to the given selector.
//
// **Note:** This does not seem to work on Safari. I've filed an issue here:
// https://bugs.webkit.org/show_bug.cgi?id=219564
export function aniCss(selector: string, ani: AniCursorImage): string {
const animationName = `webamp-ani-cursor-${Utils.uniqueId()}`;
const keyframes = ani.frames.map(({ url, percents }) => {
const percent = percents.map((num) => `${num}%`).join(", ");
return `${percent} { cursor: url(${url}), auto; }`;
});
// CSS properties with a animation type of "discrete", like `cursor`, actually
// switch half-way _between_ each keyframe percentage. Luckily this half-way
// measurement is applied _after_ the easing function is applied. So, we can
// force the frames to appear at exactly the % that we specify by using
// `timing-function` of `step-end`.
//
// https://drafts.csswg.org/web-animations-1/#discrete
const timingFunction = "step-end";
// Winamp (re)starts the animation cycle when your mouse enters an element. By
// default this approach would cause the animation to run continuously, even
// when the cursor is not visible. To match Winamp's behavior we add a
// `:hover` pseudo selector so that the animation only runs when the cursor is
// visible.
const pseudoSelector = ":hover";
return `
@keyframes ${animationName} {
${keyframes.join("\n")}
}
${selector}${pseudoSelector} {
animation: ${animationName} ${
ani.duration
}ms ${timingFunction} infinite;
}
`;
}

View file

@ -3,14 +3,13 @@ import { LETTERS } from "../constants";
import { imageSelectors, cursorSelectors } from "../skinSelectors";
import { useTypedSelector } from "../hooks";
import * as Selectors from "../selectors";
import { AniFrame, SkinImages } from "../types";
import { SkinImages } from "../types";
import { createSelector } from "reselect";
import * as Utils from "../utils";
import Css from "./Css";
import ClipPaths from "./ClipPaths";
import { aniCss } from "../aniUtils";
const CSS_PREFIX = "#webamp";
const JIFFIES_PER_MS = 1000 / 60;
const mapRegionNamesToIds: { [key: string]: string } = {
normal: "mainWindowClipPath",
@ -34,59 +33,6 @@ const FALLBACKS: { [key: string]: string } = {
MAIN_BALANCE_THUMB_ACTIVE: "MAIN_VOLUME_THUMB_SELECTED",
};
// Generate CSS for an animated cursor.
//
// Based on https://css-tricks.com/forums/topic/animated-cursor/
//
// Browsers won't render animated cursor images specified via CSS. For `.ani`
// images, we already have the frames as indiviual images, so we create a CSS
// animation.
//
// This function returns CSS containing a set of keyframes with embedded Data
// URIs as well as a CSS rule to the given selector.
//
// **Note:** This does not seem to work on Safari. I've filed an issue here:
// https://bugs.webkit.org/show_bug.cgi?id=219564
function aniCss(selector: string, frames: AniFrame[]): string {
const animationName = `webamp-ani-cursor-${Utils.uniqueId()}`;
const totalDuration = Utils.sum(frames.map(({ rate }) => rate));
let elapsed = 0;
const keyframes = frames.map(({ url, rate }) => {
const percent = (elapsed / totalDuration) * 100;
elapsed += rate;
return `${percent}% { cursor: url(${url}), auto; }`;
});
const durationMs = totalDuration * JIFFIES_PER_MS;
// CSS properties with a animation type of "discrete", like `cursor`, actually
// switch half-way _between_ each keyframe percentage. Luckily this half-way
// measurement is applied _after_ the easing function is applied. So, we can
// force the frames to appear at exactly the % that we specify by using
// `timing-function` of `step-end`.
//
// https://drafts.csswg.org/web-animations-1/#discrete
const timingFunction = "step-end";
// Winamp (re)starts the animation cycle when your mouse enters an element. By
// default this approach would cause the animation to run continuously, even
// when the cursor is not visible. To match Winamp's behavior we add a
// `:hover` pseudo selector so that the animation only runs when the cursor is
// visible.
const pseudoSelector = ":hover";
return `
@keyframes ${animationName} {
${keyframes.join("\n")}
}
${selector}${pseudoSelector} {
animation: ${animationName} ${durationMs}ms ${timingFunction} infinite;
}
`;
}
// Cursors might appear in context menus which are not nested inside the window layout div.
function normalizeCursorSelector(selector: string): string {
return `${
@ -146,7 +92,7 @@ const getCssRules = createSelector(
case "cur":
return `${selector} {cursor: url(${cursor.url}), auto}`;
case "ani": {
return aniCss(selector, cursor.frames);
return aniCss(selector, cursor.ani);
}
}
});

View file

@ -1,6 +1,7 @@
import invariant from "invariant";
import { IMusicMetadataBrowserApi } from "./types";
import { IAudioMetadata } from "music-metadata-browser"; // Import music-metadata type definitions
import * as Utils from "./utils";
type MediaDataType = string | ArrayBuffer | Blob;
@ -123,6 +124,11 @@ function urlIsBlobUrl(url: string): boolean {
return /^blob:/.test(url);
}
export function curUrlFromByteArray(arr: Uint8Array) {
const base64 = Utils.base64FromDataArray(arr);
return `data:image/x-win-bitmap;base64,${base64}`;
}
// This is not perfect, but... meh: https://stackoverflow.com/a/36756650/1263117
export function filenameFromUrl(url: string): string | null {
if (urlIsBlobUrl(url)) {

View file

@ -3,7 +3,8 @@ import { PlaylistStyle, SkinGenExColors, CursorImage } from "./types";
import SKIN_SPRITES, { Sprite } from "./skinSprites";
import { DEFAULT_SKIN } from "./constants";
import * as Utils from "./utils";
import { parseAni, ParsedAni } from "./aniParser";
import * as FileUtils from "./fileUtils";
import * as AniUtils from "./aniUtils";
export const getFileExtension = (fileName: string): string | null => {
const matches = /\.([a-z]{3,4})$/i.exec(fileName);
@ -131,20 +132,6 @@ function arrayStartsWith(arr: Uint8Array, matcher: number[]): boolean {
return matcher.every((item, i) => arr[i] === item);
}
function curUrlFromByteArray(arr: Uint8Array) {
const base64 = Utils.base64FromDataArray(arr);
return `data:image/x-win-bitmap;base64,${base64}`;
}
function framesFromAni(ani: ParsedAni) {
const rawUrls = ani.images.map(curUrlFromByteArray);
const urls = ani.seq == null ? rawUrls : ani.seq.map((i) => rawUrls[i]);
return urls.map((url, i) => {
const rate = ani.rate == null ? ani.metadata.iDispRate : ani.rate[i];
return { url, rate };
});
}
export async function getCursorFromFilename(
zip: JSZip,
fileName: string
@ -155,11 +142,10 @@ export async function getCursorFromFilename(
}
const contents = file.contents as Uint8Array;
if (arrayStartsWith(contents, RIFF_MAGIC)) {
const ani = parseAni(contents);
return { type: "ani", frames: framesFromAni(ani) };
return { type: "ani", ani: AniUtils.readAni(contents) };
}
return { type: "cur", url: curUrlFromByteArray(contents) };
return { type: "cur", url: FileUtils.curUrlFromByteArray(contents) };
}
export async function getPlaylistStyle(zip: JSZip): Promise<PlaylistStyle> {

View file

@ -75,6 +75,14 @@ export type Band =
export type Slider = Band | "preamp";
export type AniCursorImage = {
frames: {
url: string;
percents: number[];
}[];
duration: number;
};
export type CursorImage =
| {
type: "cur";
@ -82,21 +90,13 @@ export type CursorImage =
}
| {
type: "ani";
frames: {
url: string;
rate: number;
}[];
ani: AniCursorImage;
};
// TODO: Use a type to ensure these keys mirror the CURSORS constant in
// skinParser.js
export type Cursors = { [cursor: string]: CursorImage };
export type AniFrame = {
url: string;
rate: number;
};
export type GenLetterWidths = { [letter: string]: number };
export interface PlaylistStyle {