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 7b90485e..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,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); } } }); 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..87745946 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,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 { 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 {