diff --git a/packages/webamp/js/__snapshots__/aniParser.test.ts.snap b/packages/webamp/js/__snapshots__/aniParser.test.ts.snap index 8e63319e..860e0d13 100644 --- a/packages/webamp/js/__snapshots__/aniParser.test.ts.snap +++ b/packages/webamp/js/__snapshots__/aniParser.test.ts.snap @@ -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, } `; diff --git a/packages/webamp/js/aniParser.test.ts b/packages/webamp/js/aniParser.test.ts index 329873bd..12ac3a65 100644 --- a/packages/webamp/js/aniParser.test.ts +++ b/packages/webamp/js/aniParser.test.ts @@ -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(); }); }); diff --git a/packages/webamp/js/aniParser.ts b/packages/webamp/js/aniParser.ts index f213f526..0a5bda2e 100644 --- a/packages/webamp/js/aniParser.ts +++ b/packages/webamp/js/aniParser.ts @@ -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(); +} diff --git a/packages/webamp/js/aniUtils.ts b/packages/webamp/js/aniUtils.ts new file mode 100644 index 00000000..27ab2ddc --- /dev/null +++ b/packages/webamp/js/aniUtils.ts @@ -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; + } + `; +} diff --git a/packages/webamp/js/components/Skin.tsx b/packages/webamp/js/components/Skin.tsx index 178c7910..53f92efc 100644 --- a/packages/webamp/js/components/Skin.tsx +++ b/packages/webamp/js/components/Skin.tsx @@ -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); } } }); diff --git a/packages/webamp/js/fileUtils.ts b/packages/webamp/js/fileUtils.ts index ddcd86e0..cba6afee 100644 --- a/packages/webamp/js/fileUtils.ts +++ b/packages/webamp/js/fileUtils.ts @@ -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)) { diff --git a/packages/webamp/js/skinParserUtils.ts b/packages/webamp/js/skinParserUtils.ts index 52251e74..b5d92210 100644 --- a/packages/webamp/js/skinParserUtils.ts +++ b/packages/webamp/js/skinParserUtils.ts @@ -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 { diff --git a/packages/webamp/js/types.ts b/packages/webamp/js/types.ts index 03c30fbf..86325c3e 100644 --- a/packages/webamp/js/types.ts +++ b/packages/webamp/js/types.ts @@ -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 {