mirror of
https://github.com/captbaritone/webamp.git
synced 2026-08-02 06:52:19 +00:00
Correctly handle keyframe transitions (#1037)
* Correctly handle keyframe transitions CSS properties with a animation type of "discrete" like `cursor`actually switch half-way _between_ each keyframe. To avoid ambiguityabout how long we want each frame to show, we explicitly give both thestart _and_ end timing of each frame. https://drafts.csswg.org/web-animations-1/#discrete Thanks to https://github.com/matthewwithanm/ for catching this bug. We also add a `:hover` pseudo selector to avoid running the animation when the cursor is not visible. * Refactor to dedupe URIs in CSS * Make ani parsing more robust by handling artist/title Also, ignore icons that crash the parser * Clean up comments
This commit is contained in:
parent
680b863b90
commit
0bb694d9d2
8 changed files with 194 additions and 74 deletions
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
exports[`Green Dimension v2.wsz eqslid.cur 1`] = `
|
||||
Object {
|
||||
"artist": null,
|
||||
"images": "00201032320000001681600220004000032000640001032000000160000000000000000,00201032320000001681600220004000032000640001032000000160000000000000000...",
|
||||
"metadata": Object {
|
||||
"bfAttributes": 1,
|
||||
|
|
@ -19,11 +20,13 @@ Object {
|
|||
8,
|
||||
],
|
||||
"seq": null,
|
||||
"title": null,
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Super_Mario_Amp_2.wsz close.cur 1`] = `
|
||||
Object {
|
||||
"artist": null,
|
||||
"images": "0020103232000000168800220004000032000640001080000012840000000000000000,0020103232000000168800220004000032000640001080000012840000000000000000,0020103232000000168800220004000032000640001080000012840000000000000000,0020103232000000168800220004000032000640001080000012840000000000000000,0020103232000000168800220004000032000640001080000012840000000000000000,0020103232000000168800220004000032000640001080000012840000000000000000,0020103232000000168800220004000032000640001080000012840000000000000000,0020103232000000168800220004000032000640001080000012840000000000000000,0020103232000000168800220004000032000640001080000012840000000000000000,0020103232000000168800220004000032000640001080000012840000000000000000...",
|
||||
"metadata": Object {
|
||||
"bfAttributes": 3,
|
||||
|
|
@ -74,11 +77,33 @@ Object {
|
|||
9,
|
||||
8,
|
||||
],
|
||||
"title": null,
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Super_Mario_Amp_2.wsz close.cur 2`] = `
|
||||
"
|
||||
@keyframes webamp-ani-cursor-0 {
|
||||
0% { cursor: url(data:image/x-win-bitmap;base64,AAACAAEAICAAAAAAAACoCAAAFgAAA), auto; }
|
||||
7.246376811594203% { cursor: url(data:image/x-win-bitmap;base64,AAACAAEAICAAAAAAAACoCAAAFgAAA), auto; }
|
||||
14.492753623188406% { cursor: url(data:image/x-win-bitmap;base64,AAACAAEAICAAAAAAAACoCAAAFgAAA), auto; }
|
||||
21.73913043478261% { cursor: url(data:image/x-win-bitmap;base64,AAACAAEAICAAAAAAAACoCAAAFgAAA), auto; }
|
||||
28.985507246376812%, 38.405797101449274%, 44.927536231884055% { cursor: url(data:image/x-win-bitmap;base64,AAACAAEAICAAAAAAAACoCAAAFgAAA), auto; }
|
||||
34.05797101449276%, 42.028985507246375%, 47.10144927536232% { cursor: url(data:image/x-win-bitmap;base64,AAACAAEAICAAAAAAAACoCAAAFgAAA), auto; }
|
||||
48.55072463768116% { cursor: url(data:image/x-win-bitmap;base64,AAACAAEAICAAAAAAAACoCAAAFgAAA), auto; }
|
||||
49.275362318840585% { cursor: url(data:image/x-win-bitmap;base64,AAACAAEAICAAAAAAAACoCAAAFgAAA), auto; }
|
||||
50.72463768115942%, 53.62318840579711%, 56.52173913043478% { cursor: url(data:image/x-win-bitmap;base64,AAACAAEAICAAAAAAAACoCAAAFgAAA), auto; }
|
||||
52.17391304347826%, 55.072463768115945% { cursor: url(data:image/x-win-bitmap;base64,AAACAAEAICAAAAAAAACoCAAAFgAAA), auto; }
|
||||
}
|
||||
#example:hover {
|
||||
animation: webamp-ani-cursor-0 2300ms step-end infinite;
|
||||
}
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`Super_Mario_Amp_2.wsz eqslid.cur 1`] = `
|
||||
Object {
|
||||
"artist": null,
|
||||
"images": "0020103232000000168120022000400003200064000102400000128120000000000000000,0020103232000000168120022000400003200064000102400000128120000000000000000,0020103232000000168120022000400003200064000102400000128120000000000000000,0020103232000000168120022000400003200064000102400000128120000000000000000,0020103232000000168120022000400003200064000102400000128120000000000000000,0020103232000000168120022000400003200064000102400000128120000000000000000,0020103232000000168120022000400003200064000102400000128120000000000000000,0020103232000000168120022000400003200064000102400000128120000000000000000...",
|
||||
"metadata": Object {
|
||||
"bfAttributes": 1,
|
||||
|
|
@ -93,5 +118,6 @@ Object {
|
|||
},
|
||||
"rate": null,
|
||||
"seq": null,
|
||||
"title": null,
|
||||
}
|
||||
`;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { parseAni } from "./aniParser";
|
||||
import { readAni, aniCss } from "./aniUtils";
|
||||
|
||||
// Parse a `.ani` in our fixture directory and trim down the image data for use
|
||||
// in snapshot tests.
|
||||
|
|
@ -16,6 +17,19 @@ function parsePath(filePath: string) {
|
|||
return ani;
|
||||
}
|
||||
|
||||
function readPathCss(filePath: string) {
|
||||
const buffer = fs.readFileSync(
|
||||
path.join(__dirname, "./__tests__/fixtures/ani/", filePath)
|
||||
);
|
||||
|
||||
const ani = readAni(buffer);
|
||||
ani.frames = ani.frames.map(({ url, percents }) => ({
|
||||
url: url.slice(0, 60),
|
||||
percents,
|
||||
}));
|
||||
return aniCss("#example", ani);
|
||||
}
|
||||
|
||||
// https://skins.webamp.org/skin/6e30f9e9b8f5719469809785ae5e4a1f/Super_Mario_Amp_2.wsz/
|
||||
describe("Super_Mario_Amp_2.wsz", () => {
|
||||
test("eqslid.cur", async () => {
|
||||
|
|
@ -23,6 +37,7 @@ describe("Super_Mario_Amp_2.wsz", () => {
|
|||
});
|
||||
test("close.cur", async () => {
|
||||
expect(parsePath("Super_Mario_Amp_2/close.cur")).toMatchSnapshot();
|
||||
expect(readPathCss("Super_Mario_Amp_2/close.cur")).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { RIFFFile } from "riff-file";
|
||||
import { unpackArray } from "byte-data";
|
||||
import { unpackArray, unpackString } from "byte-data";
|
||||
|
||||
type Chunk = {
|
||||
format: string;
|
||||
|
|
@ -24,11 +24,15 @@ export type AniMetadata = {
|
|||
bfAttributes: number; // ANI attribute bit flags
|
||||
};
|
||||
|
||||
type AniInfo = { title: string | null; artist: string | null };
|
||||
|
||||
export type ParsedAni = {
|
||||
rate: number[] | null;
|
||||
seq: number[] | null;
|
||||
images: Uint8Array[];
|
||||
metadata: AniMetadata;
|
||||
artist: string | null;
|
||||
title: string | null;
|
||||
};
|
||||
|
||||
const DWORD = { bits: 32, be: false, signed: false, fp: false };
|
||||
|
|
@ -47,24 +51,32 @@ export function parseAni(arr: Uint8Array): ParsedAni {
|
|||
let rate: number[] | null = null;
|
||||
let seq: number[] | null = null;
|
||||
let images: Uint8Array[] | null = null;
|
||||
let info: AniInfo = { artist: null, title: null };
|
||||
|
||||
signature.subChunks.forEach(({ chunkId, chunkData, subChunks }) => {
|
||||
// TODO: Why do we need to trim here?
|
||||
switch (chunkId.trim()) {
|
||||
case "anih": // TODO: assert(i === 0)
|
||||
signature.subChunks.forEach(({ chunkId, chunkData, subChunks, format }) => {
|
||||
switch (trimNullTerminated(chunkId)) {
|
||||
case "anih":
|
||||
metadata = parseMetadata(arr, chunkData.start, chunkData.end);
|
||||
break;
|
||||
case "rate": // TODO: assert(i === 1)
|
||||
case "rate":
|
||||
rate = unpackArray(arr, DWORD, chunkData.start, chunkData.end);
|
||||
break;
|
||||
case "seq": // TODO: assert(i === 2) (or 1??)
|
||||
case "seq":
|
||||
seq = unpackArray(arr, DWORD, chunkData.start, chunkData.end);
|
||||
break;
|
||||
case "LIST": // TODO: assert(i === subChunks.length)
|
||||
images = subChunks.map((c) =>
|
||||
// TODO: We could assert that each have a chunkId of "icon"
|
||||
arr.slice(c.chunkData.start, c.chunkData.end)
|
||||
);
|
||||
switch (format) {
|
||||
case "INFO":
|
||||
info = parseInfo(arr, subChunks);
|
||||
break;
|
||||
case "fram":
|
||||
images = subChunks.map((c) => {
|
||||
if (c.chunkId !== "icon") {
|
||||
throw new Error(`Unexpected chunk type in fram: ${chunkId}`);
|
||||
}
|
||||
return arr.slice(c.chunkData.start, c.chunkData.end);
|
||||
});
|
||||
}
|
||||
break;
|
||||
default:
|
||||
// TODO: We could assert that this never happens
|
||||
|
|
@ -79,7 +91,28 @@ export function parseAni(arr: Uint8Array): ParsedAni {
|
|||
throw new Error("Did not find LIST");
|
||||
}
|
||||
|
||||
return { images, rate, seq, metadata };
|
||||
return { ...info, images, rate, seq, metadata };
|
||||
}
|
||||
|
||||
function parseInfo(arr: Uint8Array, chunks: Chunk[]): AniInfo {
|
||||
const info: AniInfo = { title: null, artist: null };
|
||||
chunks.forEach((chunk) => {
|
||||
switch (chunk.chunkId) {
|
||||
case "INAM":
|
||||
info.title = trimNullTerminated(
|
||||
unpackString(arr, chunk.chunkData.start, chunk.chunkData.end)
|
||||
);
|
||||
break;
|
||||
case "IART":
|
||||
info.artist = trimNullTerminated(
|
||||
unpackString(arr, chunk.chunkData.start, chunk.chunkData.end)
|
||||
);
|
||||
break;
|
||||
default:
|
||||
// Unexpected subchunk
|
||||
}
|
||||
});
|
||||
return info;
|
||||
}
|
||||
|
||||
function parseMetadata(
|
||||
|
|
@ -101,3 +134,9 @@ function parseMetadata(
|
|||
bfAttributes: words[8],
|
||||
};
|
||||
}
|
||||
|
||||
// I suspect that RIFF points to byte ranges, but that includes the byte(s?)
|
||||
// used for null termination.
|
||||
function trimNullTerminated(str: string): string {
|
||||
return str.trim();
|
||||
}
|
||||
|
|
|
|||
75
packages/webamp/js/aniUtils.ts
Normal file
75
packages/webamp/js/aniUtils.ts
Normal 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;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
|
@ -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,37 +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.
|
||||
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;
|
||||
// Since our animation loops, we need to tell CSS that 0% === 100%
|
||||
const percentStr = percent === 0 ? `0%, 100%` : `${percent}%`;
|
||||
|
||||
elapsed += rate;
|
||||
return `${percentStr} { cursor: url(${url}), auto; }`;
|
||||
});
|
||||
const framesCss = keyframes.join("\n");
|
||||
const keyframesCss = `@keyframes ${animationName} { ${framesCss} }`;
|
||||
|
||||
const durationMs = totalDuration * JIFFIES_PER_MS;
|
||||
const rule = `${selector} { animation: ${animationName} ${durationMs}ms infinite; }`;
|
||||
return [keyframesCss, rule].join("\n");
|
||||
}
|
||||
|
||||
// Cursors might appear in context menus which are not nested inside the window layout div.
|
||||
function normalizeCursorSelector(selector: string): string {
|
||||
return `${
|
||||
|
|
@ -124,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);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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)) {
|
||||
|
|
|
|||
|
|
@ -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,15 @@ 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) };
|
||||
try {
|
||||
return { type: "ani", ani: AniUtils.readAni(contents) };
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return { type: "cur", url: curUrlFromByteArray(contents) };
|
||||
return { type: "cur", url: FileUtils.curUrlFromByteArray(contents) };
|
||||
}
|
||||
|
||||
export async function getPlaylistStyle(zip: JSZip): Promise<PlaylistStyle> {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue