import SKIN_SPRITES from "./skinSprites"; import regionParser from "./regionParser"; import { LETTERS, DEFAULT_SKIN } from "./constants"; import { parseViscolors, parseIni, getFileExtension, objectMap } 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 available. 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 { contents: await files[0].async(mode), name: files[0].name }; } 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) { // Winamp only supports .bmp images, but WACUP set a precidence of supporting // .png as well to reduce size. Since we care about size as well, we follow // suit. Our default skin uses .png to save 14kb. const file = await genFileFromZip(zip, fileName, "(png|bmp)", "blob"); if (!file) { return null; } const mimeType = `image/${getFileExtension(file.name) || "*"}`; // 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([file.contents], { type: mimeType }); 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 file = await genFileFromZip(zip, fileName, "CUR", "base64"); return file && `data:image/x-win-bitmap;base64,${file.contents}`; } async function genPlaylistStyle(zip) { const pledit = await genFileFromZip(zip, "PLEDIT", "txt", "text"); const data = pledit && parseIni(pledit.contents).text; if (!data) { // Corrupt or missing PLEDIT.txt file. return DEFAULT_SKIN.playlistStyle; } // Winamp seems to permit colors that contain too many characters. // For compatibility with existing skins, we normalize them here. ["normal", "current", "normalbg", "selectedbg", "mbFG", "mbBG"].forEach( colorKey => { let color = data[colorKey]; if (!color) { return; } if (color[0] !== "#") { color = `#${color}`; } data[colorKey] = color.slice(0, 7); } ); return { ...DEFAULT_SKIN.playlistStyle, ...data }; } async function genVizColors(zip) { const viscolor = await genFileFromZip(zip, "VISCOLOR", "txt", "text"); return viscolor ? parseViscolors(viscolor.contents) : DEFAULT_SKIN.colors; } 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 region = await genFileFromZip(zip, "REGION", "txt", "text"); return region ? regionParser(region.contents) : {}; } 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)]; } async function genGenExColors(zip) { const img = await genImgFromFilename(zip, "GENEX"); 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 getColorAt = x => `rgb(${context .getImageData(x, 0, 1, 1) // Discard the alpha channel .data.slice(0, 3) .join(",")})`; const colors = { // (1) x=48: item background (background to edits, listviews, etc.) itemBackground: 48, // (2) x=50: item foreground (text colour of edits, listviews, etc.) itemForeground: 50, // (3) x=52: window background (used to set the bg color for the dialog) windowBackground: 52, // (4) x=54: button text colour buttonText: 54, // (5) x=56: window text colour windowText: 56, // (6) x=58: colour of dividers and sunken borders divider: 58, // (7) x=60: selection colour for entries inside playlists (nothing else yet) playlistSelection: 60, // (8) x=62: listview header background colour listHeaderBackground: 62, // (9) x=64: listview header text colour listHeaderText: 64, // (10) x=66: listview header frame top and left colour listHeaderFrameTopAndLeft: 66, // (11) x=68: listview header frame bottom and right colour listHeaderFrameBottomAndRight: 68, // (12) x=70: listview header frame colour, when pressed listHeaderFramePressed: 70, // (13) x=72: listview header dead area colour listHeaderDeadArea: 72, // (14) x=74: scrollbar colour #1 scrollbarOne: 74, // (15) x=76: scrollbar colour #2 scrollbarTwo: 76, // (16) x=78: pressed scrollbar colour #1 pressedScrollbarOne: 78, // (17) x=80: pressed scrollbar colour #2 pressedScrollbarTwo: 80, // (18) x=82: scrollbar dead area colour scrollbarDeadArea: 82, // (19) x=84 List view text colour highlighted listTextHighlighted: 84, // (20) x=86 List view background colour highlighted listTextHighlightedBackground: 86, // (21) x=88 List view text colour selected listTextSelected: 88, // (22) x=90 List view background colour selected listTextSelectedBackground: 90 }; return objectMap(colors, getColorAt); } // A promise that, given an array buffer returns a skin style object async function skinParser(zipFileBuffer, JSZip) { const zip = await JSZip.loadAsync(zipFileBuffer); const [ colors, playlistStyle, images, cursors, region, genTextSprites, genExColors ] = await Promise.all([ genVizColors(zip), genPlaylistStyle(zip), genImages(zip), genCursors(zip), genRegion(zip), genGenTextSprites(zip), genGenExColors(zip) ]); const [genLetterWidths, genTextImages] = genTextSprites || [null, {}]; return { colors, playlistStyle, images: { ...images, ...genTextImages }, genLetterWidths, cursors, region, genExColors }; } export default skinParser;