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