diff --git a/packages/webamp-modern-2/README.md b/packages/webamp-modern-2/README.md new file mode 100644 index 00000000..c4c96ad3 --- /dev/null +++ b/packages/webamp-modern-2/README.md @@ -0,0 +1,9 @@ +# TODO Next + +- [ ] When parsing skins, where is the root state accumulated? +- [ ] How do includes work when parsing skins? Do they create new context? + +# TODO Some day + +- [ ] Handle case (in)sensitivity of includes. +- [ ] Handle forward/backward slashes issues (if they exist) \ No newline at end of file diff --git a/packages/webamp-modern-2/assets/CornerAmp_Redux.wal b/packages/webamp-modern-2/assets/CornerAmp_Redux.wal new file mode 100644 index 00000000..7b7f2166 Binary files /dev/null and b/packages/webamp-modern-2/assets/CornerAmp_Redux.wal differ diff --git a/packages/webamp-modern-2/package.json b/packages/webamp-modern-2/package.json new file mode 100644 index 00000000..a89a57d7 --- /dev/null +++ b/packages/webamp-modern-2/package.json @@ -0,0 +1,18 @@ +{ + "name": "webamp-modern-2", + "version": "1.0.0", + "main": "index.js", + "license": "MIT", + "scripts": { + "start": "snowpack dev", + "build": "snowpack build" + }, + "devDependencies": { + "snowpack": "^3.5.5" + }, + "prettier": {}, + "dependencies": { + "@rgrove/parse-xml": "^3.0.0", + "jszip": "^3.6.0" + } +} diff --git a/packages/webamp-modern-2/snowpack.config.js b/packages/webamp-modern-2/snowpack.config.js new file mode 100644 index 00000000..a1cc060c --- /dev/null +++ b/packages/webamp-modern-2/snowpack.config.js @@ -0,0 +1,23 @@ +// Snowpack Configuration File +// See all supported options: https://www.snowpack.dev/reference/configuration + +/** @type {import("snowpack").SnowpackUserConfig } */ +module.exports = { + root: "src/", + mount: { + src: "/", + assets: "/assets", + }, + plugins: [ + /* ... */ + ], + packageOptions: { + /* ... */ + }, + devOptions: { + /* ... */ + }, + buildOptions: { + /* ... */ + }, +}; diff --git a/packages/webamp-modern-2/src/UIRoot.ts b/packages/webamp-modern-2/src/UIRoot.ts new file mode 100644 index 00000000..1c515214 --- /dev/null +++ b/packages/webamp-modern-2/src/UIRoot.ts @@ -0,0 +1 @@ +export default class UIRoot {} diff --git a/packages/webamp-modern-2/src/index.html b/packages/webamp-modern-2/src/index.html new file mode 100644 index 00000000..ada6103d --- /dev/null +++ b/packages/webamp-modern-2/src/index.html @@ -0,0 +1,13 @@ + + + + + + + Webamp Modern + + +
+ + + \ No newline at end of file diff --git a/packages/webamp-modern-2/src/index.ts b/packages/webamp-modern-2/src/index.ts new file mode 100644 index 00000000..f6a49498 --- /dev/null +++ b/packages/webamp-modern-2/src/index.ts @@ -0,0 +1,14 @@ +import JSZip from "jszip"; +import SkinParser from "./skin/parse"; + +async function main() { + const response = await fetch("assets/CornerAmp_Redux.wal"); + const data = await response.blob(); + const zip = await JSZip.loadAsync(data); + + const parser = new SkinParser(zip); + + await parser.parse(); +} + +main(); diff --git a/packages/webamp-modern-2/src/skin/Bitmap.ts b/packages/webamp-modern-2/src/skin/Bitmap.ts new file mode 100644 index 00000000..e2a4eefc --- /dev/null +++ b/packages/webamp-modern-2/src/skin/Bitmap.ts @@ -0,0 +1,31 @@ +export default class Bitmap { + _id: string; + _url: string; + _x: number; + _y: number; + _width: number; + _height: number; + + constructor({ + id, + url, + x, + y, + width, + height, + }: { + id: string; + url: string; + x: number; + y: number; + width: number; + height: number; + }) { + this._id = id; + this._url = url; + this._x = x; + this._y = y; + this._width = width; + this._height = height; + } +} diff --git a/packages/webamp-modern-2/src/skin/ImageManager.ts b/packages/webamp-modern-2/src/skin/ImageManager.ts new file mode 100644 index 00000000..17454bde --- /dev/null +++ b/packages/webamp-modern-2/src/skin/ImageManager.ts @@ -0,0 +1,68 @@ +import JSZip from "jszip"; +import { getCaseInsensitiveFile } from "../utils"; + +export default class ImageManager { + _urlCache: Map; + _sizeCache: Map; + constructor(private _zip: JSZip) { + this._urlCache = new Map(); + this._sizeCache = new Map(); + } + + async getUrl(filePath: string): Promise { + if (!this._urlCache.has(filePath)) { + const zipFile = getCaseInsensitiveFile(this._zip, filePath); + const imgBlob = await zipFile.async("blob"); + const imgUrl = await getUrlFromBlob(imgBlob); + this._urlCache.set(filePath, imgUrl); + } + return this._urlCache.get(filePath); + } + + async getSize(url: string): Promise<{ width: number; height: number }> { + if (!this._sizeCache.has(url)) { + const size = await getImageSize(url); + this._sizeCache.set(url, size); + } + return this._sizeCache.get(url); + } +} + +// This is intentionally async since we may want to sub it out for an async +// function in a node environment +async function getUrlFromBlob(blob: Blob): Promise { + // We initiallay used `URL.createObjectURL(blob)` here, but it had an issue + // where, when used as a background imaged, they would take more than one + // frame to load resulting in a white flash when switching background iamges. + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = function (e) { + // @ts-ignore This API is not very type-friendly. + resolve(e.target.result); + }; + reader.onerror = reject; + reader.readAsDataURL(blob); + }); +} + +async function loadImage( + imgUrl: string +): Promise<{ width: number; height: number }> { + return new Promise((resolve, reject) => { + const img = new Image(); + img.addEventListener("load", () => { + resolve(img); + }); + img.addEventListener("error", (e) => { + reject(e); + }); + img.src = imgUrl; + }); +} + +async function getImageSize( + imgUrl: string +): Promise<{ width: number; height: number }> { + const { width, height } = await loadImage(imgUrl); + return { width, height }; +} diff --git a/packages/webamp-modern-2/src/skin/parse.ts b/packages/webamp-modern-2/src/skin/parse.ts new file mode 100644 index 00000000..a85a3288 --- /dev/null +++ b/packages/webamp-modern-2/src/skin/parse.ts @@ -0,0 +1,371 @@ +import parseXml, { XmlElement } from "@rgrove/parse-xml"; +import { assert, num, getCaseInsensitiveFile, px } from "../utils"; +import UIRoot from "../UIRoot"; +import JSZip, { JSZipObject } from "jszip"; +import Bitmap from "./Bitmap"; +import ImageManager from "./ImageManager"; + +export default class SkinParser { + _zip: JSZip; + _imageManager: ImageManager; + _path: string[]; + _root: UIRoot; + + constructor(zip: JSZip) { + this._zip = zip; + this._path = []; + this._root = new UIRoot(); + this._imageManager = new ImageManager(zip); + } + async parse() { + const includedXml = await this.getCaseInsensitiveFile("skin.xml").async( + "string" + ); + + // Note: Included files don't have a single root node, so we add a synthetic one. + // A different XML parser library might make this unnessesary. + const parsed = parseXml(includedXml); + + this.traverseChildren(parsed); + } + async traverseChildren(parent) { + for (const child of parent.children) { + if (child instanceof XmlElement) { + await this.traverseChild(child); + } + } + } + async traverseChild(node) { + switch (node.name.toLowerCase()) { + case "wasabixml": + return this.wasabiXml(node); + case "include": + return this.include(node); + case "skininfo": + return this.skininfo(node); + case "elements": + return this.elements(node); + case "bitmap": + return this.bitmap(node); + case "color": + return this.color(node); + case "groupdef": + return this.groupdef(node); + case "layer": + return this.layer(node); + case "container": + return this.container(node); + case "layoutstatus": + return this.layoutStatus(node); + case "hideobject": + return this.hideobject(node); + case "button": + return this.button(node); + case "togglebutton": + return this.toggleButton(node); + case "group": + return this.group(node); + case "layout": + return this.layout(node); + case "component": + return this.component(node); + case "gammaset": + return this.gammaset(node); + case "gammagroup": + return this.gammagroup(node); + case "slider": + return this.slider(node); + case "script": + return this.script(node); + case "scripts": + return this.scripts(node); + case "text": + return this.text(node); + case "sendparams": + return this.sendparams(node); + case "wasabi:titlebar": + return this.wasabiTitleBar(node); + case "wasabi:button": + return this.wasabiButton(node); + case "truetypefont": + return this.trueTypeFont(node); + case "wasabi:standardframe:status": + return this.wasabiStandardframeStatus(node); + case "wasabi:standardframe:nostatus": + return this.wasabiStandardframeNoStatus(node); + case "eqvis": + return this.eqvis(node); + case "colorthemes:list": + return this.colorThemesList(node); + case "status": + return this.status(node); + // Note: Included files don't have a single root node, so we add a synthetic one. + // A different XML parser library might make this unnessesary. + case "wrapper": + return this.traverseChildren(node); + default: + throw new Error(`Unhandled XML node type: ${node.name}`); + } + } + + /* Individual Element Parsers */ + + async wasabiXml(node: XmlElement) { + await this.traverseChildren(node); + } + + async elements(node: XmlElement) { + await this.traverseChildren(node); + } + + async group(node: XmlElement) { + await this.traverseChildren(node); + } + + async bitmap(node: XmlElement) { + assert( + node.children.length === 0, + "Unexpected children in XML node." + ); + const { file } = node.attributes; + assert(file != null, "Expected bitmap node to have a `file` attribute"); + + const imgUrl = await this._imageManager.getUrl(file); + + const id = node.attributes.id; + const x = num(node.attributes.x) ?? 0; + const y = num(node.attributes.y) ?? 0; + let width = num(node.attributes.w); + let height = num(node.attributes.h); + + if (width == null || height == null) { + assert( + x != null && y != null, + "Expected images with unknown size to not have offsets." + ); + assert( + width == null && height == null, + "Expected both dimensions to be missing." + ); + const size = await this._imageManager.getSize(imgUrl); + width = size.width; + height = size.height; + } + + const bitmap = new Bitmap({ + url: imgUrl, + id, + x, + y, + width, + height, + }); + + // TODO: Store this somewhere. For now, we can just show it. + const div = document.createElement("div"); + div.style.height = px(bitmap._height); + div.style.width = px(bitmap._width); + div.style.backgroundImage = `url(${bitmap._url})`; + div.style.backgroundPositionX = px(-bitmap._x); + div.style.backgroundPositionY = px(-bitmap._y); + div.style.display = "inline-block"; + div.style.imageRendering = "pixelated"; + + document.body.appendChild(div); + } + + async text(node: XmlElement) { + assert( + node.children.length === 0, + "Unexpected children in XML node." + ); + + // TODO: Parse text + } + + async script(node: XmlElement) { + assert( + node.children.length === 0, + "Unexpected children in