webamp/js/skinParser.js
2018-01-31 20:43:39 -08:00

295 lines
7.6 KiB
JavaScript

import JSZip from "../node_modules/jszip/dist/jszip"; // Hack
import SKIN_SPRITES from "./skinSprites";
import regionParser from "./regionParser";
import { LETTERS } from "./constants";
import { parseViscolors, parseIni } from "./utils";
const shallowMerge = objs =>
objs.reduce((prev, img) => Object.assign(prev, img), {});
const CURSORS = [
"CLOSE",
"EQCLOSE",
"EQNORMAL",
"EQSLID",
"EQTITLE",
"MAINMENU",
"MMENU",
"MIN",
"NORMAL",
"PCLOSE",
"PNORMAL",
"POSBAR",
"PSIZE",
"PTBAR",
"PVSCROLL",
"PWINBUT",
"PWSNORM",
"PWSSIZE",
"SONGNAME",
"TITLEBAR",
"VOLBAL",
"WINBUT",
"WSNORMAL",
"WSPOSBAR"
/*
* > There are usually 4 more cursors in the skins: volbar.cur, wsclose.cur,
* > wswinbut.cur, wsmin.cur, but they are never used, at least in the last
* > versions of winamp, so there's no need of including them. The cursors
* > shown when the mouse is over the app-buttons are the same in normal and
* > winshade mode, except for the main menu button. You can make animated
* > cursors, but you have to name them with the extension .cur (animated
* > cursors are usually .ani files).
*
* -- Skinners Atlas
*
* "VOLBAR",
* "WSCLOSE",
* "WSWINBUT",
* "WSMIN",
*
*/
];
const _genImgFromBlob = async blob =>
new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => {
// Schedule cleanup of object url?
// Maybe on next tick, or with requestidlecallback
resolve(img);
};
img.onerror = () => reject("Failed to decode image");
img.src = URL.createObjectURL(blob);
});
const genImgFromBlob = async blob => {
if (window.createImageBitmap) {
try {
// Use this faster native browser API if avaliable.
return await window.createImageBitmap(blob);
} catch (e) {
console.warn(
"Encountered an error with createImageBitmap. Falling back to Image approach."
);
// There are some bugs in the new API. In case something goes wrong, we call fall back.
return _genImgFromBlob(blob);
}
}
return _genImgFromBlob(blob);
};
async function genFileFromZip(zip, fileName, ext, mode) {
const regex = new RegExp(`^(.*/)?${fileName}(\.${ext})?$`, "i");
const files = zip.file(regex);
if (!files.length) {
return null;
}
// Return a promise (awaitable).
return files[0].async(mode);
}
function getSpriteUrisFromImg(img, sprites) {
const canvas = document.createElement("canvas");
const context = canvas.getContext("2d");
return sprites.reduce((images, sprite) => {
canvas.height = sprite.height;
canvas.width = sprite.width;
context.drawImage(img, -sprite.x, -sprite.y);
const image = canvas.toDataURL();
images[sprite.name] = image;
return images;
}, {});
}
async function genImgFromFilename(zip, fileName) {
const blob = await genFileFromZip(zip, fileName, "bmp", "blob");
if (!blob) {
return null;
}
// The spec for createImageBitmap() says the browser should try to sniff the
// mime type, but it looks like Firefox does not. So we specify it here
// explicitly.
const typedBlob = new Blob([blob], { type: "image/bmp" });
return genImgFromBlob(typedBlob);
}
async function genSpriteUrisFromFilename(zip, fileName) {
const img = await genImgFromFilename(zip, fileName);
if (img == null) {
return {};
}
return getSpriteUrisFromImg(img, SKIN_SPRITES[fileName]);
}
async function getCursorFromFilename(zip, fileName) {
const base64 = await genFileFromZip(zip, fileName, "CUR", "base64");
return base64 && `data:image/x-win-bitmap;base64,${base64}`;
}
const defaultVisColors = [
"rgb(0,0,0)",
"rgb(24,33,41)",
"rgb(239,49,16)",
"rgb(206,41,16)",
"rgb(214,90,0)",
"rgb(214,102,0)",
"rgb(214,115,0)",
"rgb(198,123,8)",
"rgb(222,165,24)",
"rgb(214,181,33)",
"rgb(189,222,41)",
"rgb(148,222,33)",
"rgb(41,206,16)",
"rgb(50,190,16)",
"rgb(57,181,16)",
"rgb(49,156,8)",
"rgb(41,148,0)",
"rgb(24,132,8)",
"rgb(255,255,255)",
"rgb(214,214,222)",
"rgb(181,189,189)",
"rgb(160,170,175)",
"rgb(148,156,165)",
"rgb(150,150,150)"
];
const defaultPlaylistStyle = {
normal: "#00FF00",
current: "#FFFFFF",
normalbg: "#000000",
selectedbg: "#0000FF",
font: "Arial"
};
async function genPlaylistStyle(zip) {
const pleditContent = await genFileFromZip(zip, "PLEDIT", "txt", "text");
const data = pleditContent && parseIni(pleditContent).text;
if (!data) {
// Corrupt or missing PLEDIT.txt file.
return defaultPlaylistStyle;
}
// Winamp seems to permit colors that contain too many characters.
// For compatibility with existing skins, we normalize them here.
["normal", "current", "normalbg", "selectedbg"].forEach(colorKey => {
let color = data[colorKey];
if (!color) {
return;
}
if (color[0] !== "#") {
color = `#${color}`;
}
data[colorKey] = color.slice(0, 7);
});
return data;
}
async function genColors(zip) {
const viscolorContent = await genFileFromZip(zip, "VISCOLOR", "txt", "text");
return viscolorContent ? parseViscolors(viscolorContent) : defaultVisColors;
}
async function genImages(zip) {
const imageObjs = await Promise.all(
Object.keys(SKIN_SPRITES).map(async fileName =>
genSpriteUrisFromFilename(zip, fileName)
)
);
// Merge all the objects into a single object. Tests assert that sprite keys are unique.
return shallowMerge(imageObjs);
}
async function genCursors(zip) {
const cursorObjs = await Promise.all(
CURSORS.map(async cursorName => ({
[cursorName]: await getCursorFromFilename(zip, cursorName)
}))
);
return shallowMerge(cursorObjs);
}
async function genRegion(zip) {
const regionContent = await genFileFromZip(zip, "REGION", "txt", "text");
return regionContent ? regionParser(regionContent) : {};
}
async function genGenTextSprites(zip) {
const img = await genImgFromFilename(zip, "GEN");
if (img == null) {
return null;
}
const canvas = document.createElement("canvas");
const context = canvas.getContext("2d");
canvas.width = img.width;
canvas.height = img.height;
context.drawImage(img, 0, 0);
const getLetters = (y, prefix) => {
const getColorAt = x => context.getImageData(x, y, 1, 1).data.join(",");
let x = 1;
const backgroundColor = getColorAt(0);
const height = 7;
return LETTERS.map(letter => {
let nextBackground = x;
while (
getColorAt(nextBackground) !== backgroundColor &&
nextBackground < canvas.width
) {
nextBackground++;
}
const width = nextBackground - x;
const name = `${prefix}_${letter}`;
const sprite = { x, y, height, width, name };
x = nextBackground + 1;
return sprite;
});
};
const letterWidths = {};
const sprites = [
...getLetters(88, "GEN_TEXT_SELECTED"),
...getLetters(96, "GEN_TEXT")
];
sprites.forEach(sprite => {
letterWidths[sprite.name] = sprite.width;
});
return [letterWidths, getSpriteUrisFromImg(img, sprites)];
}
// A promise that, given an array buffer returns a skin style object
async function skinParser(zipFileBuffer) {
const zip = await JSZip.loadAsync(zipFileBuffer);
const [
colors,
playlistStyle,
images,
cursors,
region,
genTextSprites
] = await Promise.all([
genColors(zip),
genPlaylistStyle(zip),
genImages(zip),
genCursors(zip),
genRegion(zip),
genGenTextSprites(zip)
]);
const [genLetterWidths, genTextImages] = genTextSprites || [null, {}];
return {
colors,
playlistStyle,
images: { ...images, ...genTextImages },
genLetterWidths,
cursors,
region
};
}
export default skinParser;