diff --git a/xtrn/ansiview/ansiview.js b/xtrn/ansiview/ansiview.js
index 6fc0dae59455b6e1d82a371a8ebdb1e5ec80f181..a9eb67aff5118f2ea7cef569376a4ec23bdfc3bb 100644
--- a/xtrn/ansiview/ansiview.js
+++ b/xtrn/ansiview/ansiview.js
@@ -1,210 +1,396 @@
-// ansiview.js by echicken -at- bbs.electronicchicken.com
-
 load("sbbsdefs.js");
-load("tree.js");
 load("frame.js");
+load("tree.js");
+load("scrollbar.js");
+load("funclib.js");
+load("filebrowser.js");
 
-// Configuration variables:
-var ansiRoot = js.exec_dir + "library/"; // The location of your ANSI library, eg. "/dev/magicalansigenerator/"
-var disallowedFiles = ["PASSWD", "USER.DAT", "*.COM", "*.EXE", "*.GIF", "*.JPG", "*.MP3", "*.PNG", "*.RAR"]; // Files matching these patterns will not be shown in directory listings
-var slow = .033;	// Seconds between printed characters, slow speed
-var medium = .0033;	// Seconds between printed characters, medium speed
-var fast = .00033;	// Seconds between printed characters, fast speed
-var ansiDelay = medium; // Default output speed as one of slow, medium, or fast
-// End of configuration variables
-
-var lastPrint = system.timer;
-var curDir = ansiRoot;
-var parentDir = ansiRoot;
-var choice;
-var userInput;
-var treeCmd;
-var disp;
-
-function getInput(slideshow, ansiName) {
-	var retval = 1;
-	userInput = ascii(console.inkey(K_NOECHO).toUpperCase());
-	if(userInput == 0) return retval;
-	if(userInput == 32) {
-		console.saveline();
-		console.clearline();
-		if(slideshow) {
-			console.putmsg(ascii(27) + "[1;37;40m[\1h\1c" + ansiName.substr(0, 18) + "\1w] Q\1cuit\1w, <\1cSpace\1w> \1cfor more\1w, S\1clow\1w, M\1cedium\1w, F\1cast\1w, P\1crevious\1w, N\1cext");
-		} else {
-			console.putmsg(ascii(27) + "[1;37;40m[\1h\1c" + ansiName.substr(0, 34) + "\1w] Q\1cuit\1w, <\1cSpace\1w> \1cfor more\1w, S\1clow\1w, M\1cedium\1w, F\1cast");
+Frame.prototype.drawBorder = function(color) {
+	var theColor = color;
+	if(Array.isArray(color));
+		var sectionLength = Math.round(this.width / color.length);
+	this.pushxy();
+	for(var y = 1; y <= this.height; y++) {
+		for(var x = 1; x <= this.width; x++) {
+			if(x > 1 && x < this.width && y > 1 && y < this.height)
+				continue;
+			var msg;
+			this.gotoxy(x, y);
+			if(y == 1 && x == 1)
+				msg = ascii(218);
+			else if(y == 1 && x == this.width)
+				msg = ascii(191);
+			else if(y == this.height && x == 1)
+				msg = ascii(192);
+			else if(y == this.height && x == this.width)
+				msg = ascii(217);
+			else if(x == 1 || x == this.width)
+				msg = ascii(179);
+			else
+				msg = ascii(196);
+			if(Array.isArray(color)) {
+				if(x == 1)
+					theColor = color[0];
+				else if(x % sectionLength == 0 && x < this.width)
+					theColor = color[x / sectionLength];
+				else if(x == this.width)
+					theColor = color[color.length - 1];
+			}
+			this.putmsg(msg, theColor);
 		}
-		var userInput = ascii(console.getkey(K_NOECHO).toUpperCase());
-		console.clearline();
-		console.restoreline();
-	}
-	switch(userInput) {
-		case 70:
-			ansiDelay = fast;
-			break;
-		case 77:
-			ansiDelay = medium;
-			break;
-		case 83:
-			ansiDelay = slow;
-			break;
-		case 81:
-			retval = 0;
-			break;
-		case 78:
-			if(slideshow) retval = 2;
-			break;
-		case 80:
-			if(slideshow) retval = 3;
-			break;
 	}
-	return retval;
+	this.popxy();
 }
 
-function printAnsi(ansi, slideshow, ansiName) {
-	var retval = 1;
-	console.clear(LIGHTGRAY);
-	forLoop:
-	for(var a = 0; a < ansi.length; a++) {
-		console.putmsg(ansi[a]);
-		console.line_counter = 0;
-		while(system.timer - lastPrint < ansiDelay) {
-			retval = getInput(slideshow, ansiName);
-			if(retval != 1)
-				break forLoop;
+var root = js.exec_dir,
+	settings,
+	frame,
+	browserFrame,
+	statusBarFrame,
+	areaFrame,
+	speedFrame,
+	pauseFrame;
+
+var state = {
+	'status' : bbs.sys_status,
+	'attr' : console.attributes,
+	'syncTerm' : false,
+	'fileList' : [],
+	'pausing' : false,
+	'speed' : 0,
+	'browser' : null
+};
+
+var speedMap = [
+	0,		// 0 (unlimited)
+	300,	// 1
+	600,	// 2
+	1200,	// 3
+	2400,	// 4
+	4800,	// 5
+	9600,	// 6
+	19200,	// 7
+	38400,	// 8
+	57600,	// 9
+	76800,	// 10
+	115200	// 11
+];
+
+var printFile = function(file) {
+
+	console.clear(BG_BLACK|LIGHTGRAY);
+	frame.invalidate();
+
+	if(state.syncTerm) {
+
+		console.putmsg("\x1B[0;" + state.speed + "*r");
+		mswait(500);
+		console.printfile(file, (state.pausing ? P_NONE : P_NOPAUSE));
+		console.pause();
+		console.putmsg("\x1B[0;0*r");
+
+	} else if(state.speed == 0) {
+
+		console.printfile(file, (state.pausing ? P_NONE : P_NOPAUSE));
+		console.pause();
+
+	} else {
+
+		var f = new File(file);
+		f.open("r");
+		var contents = f.read().split("");
+		f.close();
+		
+		var buf = Math.ceil((speedMap[state.speed] / 8) / 1000);
+
+		if(!state.pausing) {
+			bbs.sys_status&=(~SS_PAUSEON);
+			bbs.sys_status|=SS_PAUSEOFF;
 		}
-		lastPrint = system.timer;
-	}
-	if(retval == 1) {
-		console.putmsg(ascii(27) + "[1;37;40m[\1c" + ansiName + " \1w - \1c Press any key to continue\1w]");
-		console.getkey(K_NOECHO|K_NOCRLF);
+		
+		while(contents.length > 0) {
+			console.write(contents.splice(0, buf).join(""));
+			if(console.inkey(K_NONE, 1) != "")
+				break;
+		}
+		console.pause();
+
+		bbs.sys_status|=SS_PAUSEON;
+		bbs.sys_status&=(~SS_PAUSEOFF);
+
 	}
-	return retval;
+
+	console.clear(BG_BLACK|LIGHTGRAY);
+
 }
 
-function fileChooser() {
-	console.clear(LIGHTGRAY);
-	var f = new File(js.exec_dir + "ansiview.ans");
-	f.open("r");
-	var g = f.read();
-	f.close();
-	console.putmsg(g);
-	console.gotoxy(3, 4);
-	console.putmsg(ascii(27) + "[1;37;40mpath: " + curDir.replace(ansiRoot, "/"));
-	var dirList = directory(curDir + "*");
-	var frame = new Frame(2,6,76,15)
-	var tree = new Tree(frame);
-	tree.colors.lfg = WHITE; // The lightbar foreground colour, see sbbsdefs.js for valid values
-	tree.colors.lbg = BG_CYAN; // The lightbar background colour, see sbbsdefs.js for valid values
-	if(curDir != ansiRoot) 
-		tree.addItem("[..]", parentDir);
-	for(var d = 0; d < dirList.length; d++) {
-		if(checkFile(dirList[d])) 
-			continue;
-		disp = dirList[d].split("/");
-		if(file_isdir(dirList[d])) {
-			disp = disp[disp.length - 2];
-			disp = "[" + disp + "]";
-		} else {
-			disp = disp[disp.length - 1];
-		}
-		tree.addItem(disp, dirList[d]);
-	}
-	frame.open();
-	tree.open();
-	while(!js.terminated) {
-		frame.cycle();
-		userInput = console.inkey(K_NOECHO, 5).toUpperCase();
-		if(userInput == "") 
+// Basic check for SyncTERM; we don't really care which version it is
+var isSyncTerm = function() {
+
+	console.clear(BG_BLACK|LIGHTGRAY);
+	console.write("Checking for SyncTERM ...");
+
+	var ret = true,
+		cTerm = "\x1B[=67;84;101;114;109;".split(""),
+		ckpt = console.ctrlkey_passthru;
+
+	console.ctrlkey_passthru = -1;
+	console.write("\x1B[0c");
+
+	while(cTerm.length > 0) {
+		if(console.inkey(K_NONE, 5000) == cTerm.shift())
 			continue;
-		if(userInput == "Q" || userInput == "S" || userInput == "H") 
-			break;
-		userInput = tree.getcmd(userInput);
-		console.line_counter = 0;
-		if(userInput !== true && userInput !== false) 
-			break;
+		ret = false;
+		break;
 	}
-	return userInput;
+	do {} while(console.inkey(K_NONE) != "") // Flush the input toilet
+
+	console.ctrlkey_passthru = ckpt;
+	return ret;
+
 }
 
-function loadAnsiFile(ansi) {
-	var f = new File(ansi);
-	if(f.exists) f.open("r");
-	if(!f.is_open) return;
-	var ansiFileContents = f.read();
-	f.close();
-	return ansiFileContents;
+var showSpeed = function() {
+	speedFrame.clear();
+	speedFrame.putmsg(
+		(state.speed == 0) ? "Full" : speedMap[state.speed]
+	);
 }
 
-function checkFile(str) {
-	if(file_isdir(str)) {
-		var matchMe = str.split("/");
-		matchMe = matchMe[matchMe.length - 2];
-	} else {
-		matchMe = file_getname(str);
-	}
-	for(var f = 0; f < disallowedFiles.length; f++) 
-		if(wildmatch(false, matchMe, disallowedFiles[f])) 
-			return true;
-	return false;
+var showPause = function() {
+	pauseFrame.clear();
+	pauseFrame.putmsg((state.pausing) ? "On" : "Off");
 }
 
-function slideshow() {
-	var dirList = directory(curDir + "*.*");
-	var retval;
-	ssForLoop:
-	for(var d = 0; d< dirList.length; d++) {
-		if(!file_isdir(dirList[d]) && !checkFile(dirList[d]) && !wildmatch(false, file_getname(dirList[d]), "*.ZIP")) retval = printAnsi(loadAnsiFile(dirList[d]), true, file_getname(dirList[d]));
-		switch(retval) {
-			case 0:
-				break ssForLoop;
-			case 3:
-				d = d - 2;
-				if(d < -1) d = -1;
-				break;
-			default:
-				break;
-		}
+var GalleryChooser = function() {
+
+	var frames = {
+		'frame' : null,
+		'tree' : null,
+		'scrollBar' : null
+	};
+
+	var getList = function() {
+		var f = new File(root + "settings.ini");
+		f.open("r");
+		var galleries = f.iniGetAllObjects();
+		f.close();
+		return galleries;
 	}
+
+	this.open = function() {
+
+		areaFrame.clear();
+		areaFrame.putmsg("Gallery Menu");
+
+		frames.frame = new Frame(
+			browserFrame.x,
+			browserFrame.y,
+			browserFrame.width,
+			browserFrame.height,
+			browserFrame.attr,
+			browserFrame
+		);
+		frames.tree = new Tree(frames.frame);
+		frames.tree.colors.fg = getColor(settings.fg);
+		frames.tree.colors.bg = getColor(settings.bg);
+		frames.tree.colors.lfg = getColor(settings.lfg);
+		frames.tree.colors.lbg = getColor(settings.lbg);
+
+		var list = getList();
+		list.forEach(
+			function(item) {
+				item.colors = settings;
+				frames.tree.addItem(
+					format("%-35s  %s", item.name, item.description),
+					function() {
+						state.browser.close();
+						state.browser = load(root + item.module, JSON.stringify(item));
+						state.browser.open();
+						areaFrame.clear();
+						areaFrame.putmsg(item.name);
+					}
+				);
+			}
+		);
+
+		frames.scrollBar = new ScrollBar(frames.tree);
+
+		frames.frame.open();
+		frames.tree.open();
+
+	}
+
+	this.close = function() {
+		frames.tree.close();
+		frames.scrollBar.close();
+		frames.frame.close();
+	}
+
+	this.cycle = function() {
+		frames.scrollBar.cycle();
+	}
+
+	this.getcmd = function(cmd) {
+		frames.tree.getcmd(cmd);
+	}
+
+}
+
+var initDisplay = function() {
+
+	console.clear(BG_BLACK|LIGHTGRAY);
+
+	frame = new Frame(
+		1,
+		1,
+		console.screen_columns,
+		console.screen_rows,
+		BG_BLACK|LIGHTGRAY
+	);
+
+	browserFrame = new Frame(
+		frame.x + 1,
+		frame.y + 1,
+		frame.width - 2,
+		frame.height - 3,
+		frame.attr,
+		frame
+	);
+
+	statusBarFrame = new Frame(
+		frame.x + 1,
+		frame.x + frame.height - 2,
+		frame.width - 2,
+		1,
+		settings.sbg|settings.sfg,
+		frame
+	);
+	statusBarFrame.putmsg(
+		format(
+			"%-41s%-16s%-13s",
+			"Area:","S)peed:","P)ause:"
+		)
+	);
+	statusBarFrame.gotoxy(statusBarFrame.width - 4, 1);
+	statusBarFrame.putmsg("Q)uit");
+	
+	areaFrame = new Frame(
+		statusBarFrame.x + 6,
+		statusBarFrame.y,
+		35,
+		1,
+		statusBarFrame.attr,
+		statusBarFrame
+	);
+
+	speedFrame = new Frame(
+		statusBarFrame.x + 49,
+		statusBarFrame.y,
+		6,
+		1,
+		statusBarFrame.attr,
+		statusBarFrame
+	);
+	showSpeed();
+
+	pauseFrame = new Frame(
+		statusBarFrame.x + 65,
+		statusBarFrame.y,
+		3,
+		1,
+		statusBarFrame.attr,
+		statusBarFrame
+	);
+	showPause();
+
+	frame.drawBorder(settings.border);
+	frame.gotoxy(frame.width - 24, 1);
+	frame.putmsg(ascii(180) + "\1h\1kansiview by echicken\1h\1w" + ascii(195));
+
+	frame.open();
+
 }
 
-function helpScreen() {
-	console.clear(LIGHTGRAY);
-	var f = new File(js.exec_dir + "help.ans");
+var initSettings = function() {
+	var f = new File(js.exec_dir + "settings.ini");
 	f.open("r");
-	var g = f.read();
+	settings = f.iniGetObject();
 	f.close();
-	console.putmsg(g);
-	while(console.getkey(K_NOECHO).toUpperCase() != "Q") { }
+	settings.fg = getColor(settings.fg);
+	settings.bg = getColor(settings.bg);
+	settings.lfg = getColor(settings.lfg);
+	settings.lbg = getColor(settings.lbg);
+	settings.sfg = getColor(settings.sfg);
+	settings.sbg = getColor(settings.sbg);
+	settings.border = settings.border.split(",");
+	settings.border.forEach(
+		function(e, i, a) {
+			a[i] = getColor(e);
+		}
+	);
 }
 
-while(!js.terminated) {
-	var choice = fileChooser();
-	console.line_counter = 0;
-	if(file_isdir(choice)) {
-		if(choice.length > curDir.length) {
-			parentDir = curDir;
-		} else if(parentDir != ansiRoot && parentDir.length > ansiRoot.length) {
-			parentDir = curDir.split("/");
-			parentDir = parentDir.slice(0, parentDir.length - 3).join("/") + "/";
-		} else {
-			parentDir = ansiRoot;
-		}
-		curDir = choice;
-	} else if(choice == "Q") {
-		exit();
-	} else if(choice == "S") {
-		slideshow();
-	} else if(choice == "H") {
-		helpScreen();
-	} else if(file_getext(choice).toUpperCase() == ".ZIP") {
-		destDir = curDir + file_getname(choice).toUpperCase().replace(".ZIP", "") + "/";
-		parentDir = curDir;
-		curDir = destDir;
-		if(file_isdir(destDir)) 
-			continue;
-		bbs.exec(system.exec_dir + "unzip -s -o -qq -d" + destDir + " " + choice);
-	} else {
-		var ansi = loadAnsiFile(choice);
-		printAnsi(ansi, false, file_getname(choice));
+var init = function() {
+
+	bbs.sys_status|=(SS_MOFF|SS_PAUSEON);
+	bbs.sys_status&=(~SS_PAUSEOFF);
+
+	state.syncTerm = isSyncTerm();
+
+	initSettings();
+	initDisplay();
+
+	state.browser = new GalleryChooser();
+	state.browser.open();
+
+}
+
+var cleanUp = function() {
+	frame.close();
+	bbs.sys_status = state.status;
+	console.attributes = state.attr;
+	console.clear();
+	exit(0);
+}
+
+var handleInput = function(userInput) {
+	switch(userInput) {
+		case "P":
+			state.pausing = (state.pausing) ? false : true;
+			showPause();
+			break;
+		case "S":
+			state.speed = (state.speed + 1) % 12;
+			showSpeed();
+			break;
+		case "Q":
+			state.browser.close();
+			frame.invalidate();
+			if(state.browser instanceof GalleryChooser) {
+				cleanUp();
+			} else {
+				state.browser = new GalleryChooser();
+				state.browser.open();
+			}
+			break;
+		default:
+			state.browser.getcmd(userInput);
+			break;
 	}
 }
+
+var main = function() {
+
+	while(!js.terminated) {
+		handleInput(console.inkey(K_NONE, 5).toUpperCase());
+		state.browser.cycle();
+		if(frame.cycle())
+			console.gotoxy(console.screen_columns, console.screen_rows);
+	}
+
+}
+
+init();
+main();
+cleanUp();
diff --git a/xtrn/ansiview/local.js b/xtrn/ansiview/local.js
new file mode 100644
index 0000000000000000000000000000000000000000..aac743585b060f5b99c1e3107f95bc7f283eca0b
--- /dev/null
+++ b/xtrn/ansiview/local.js
@@ -0,0 +1,60 @@
+(function() {
+
+	var onLoad = function(files) {
+
+		if(files.length < 1)
+			return;
+
+		var path = files[0].replace(file_getname(files[0]), "");
+
+		if(!file_exists(path + "ansiview.ini"))
+			return;
+		var f = new File(path + "ansiview.ini");
+		f.open("r");
+		this.descriptions = f.iniGetObject("descriptions");
+		f.close();
+
+	}
+
+	var onFile = function(file) {
+
+		var fn = file_getname(file);
+
+		if(typeof this.descriptions == "undefined")
+			this.descriptions = {};
+
+		var ret = format(
+			"%-25s%s",
+			file_isdir(file) ? ("[" + fn + "]") : fn,
+			typeof this.descriptions[fn.toLowerCase()] == "undefined" ? "" : (" " + this.descriptions[fn.toLowerCase()])
+		);
+		return ret;
+
+	}
+
+	var args = JSON.parse(argv[0]);
+
+	var hide = [".", "ansiview.ini", "ANSIVIEW.INI"];
+
+	var fileBrowser = new FileBrowser(
+		{	'path' : args.path,
+			'top' : args.path,
+			'frame' : browserFrame,
+			'colors' : {
+				'lfg' : args.colors.lfg,
+				'lbg' : args.colors.lbg,
+				'fg' : args.colors.fg,
+				'bg' : args.colors.bg,
+				'sfg' : args.colors.sfg,
+				'sbg' : args.colors.sbg
+			},
+			'hide' : (typeof args.hide == "undefined") ? hide : hide.concat(args.hide.split(","))
+		}
+	);
+	fileBrowser.on("load", onLoad);
+	fileBrowser.on("file", onFile);
+	fileBrowser.on("fileSelect", printFile);
+
+	return fileBrowser;
+
+})();
\ No newline at end of file
diff --git a/xtrn/ansiview/settings.ini b/xtrn/ansiview/settings.ini
new file mode 100644
index 0000000000000000000000000000000000000000..81e41423dfaff4ab9150c65e6abfbdacb14bfc49
--- /dev/null
+++ b/xtrn/ansiview/settings.ini
@@ -0,0 +1,27 @@
+border = LIGHTBLUE,CYAN,LIGHTCYAN,WHITE
+fg = WHITE
+bg = BG_BLACK
+lfg = WHITE
+lbg = BG_CYAN
+sfg = WHITE
+sbg = BG_BLUE
+
+[ANSI Gallery]
+description = A local archive of ANSI and ASCII artwork
+module = local.js
+path = /path/to/my/ansi/art/collection
+hide = *.exe,*.com
+
+; Uncomment this section to enable access to sixteencolors.net
+;[sixteencolors.net]
+;description = An online ANSI and ASCII artwork archive
+;module = sixteencolors.js
+;cache = true
+;cachettl = 86400
+
+; Uncomment this section to enable access to thescene.electronicchicken.com
+;[thescene.electronicchicken.com]
+;description = An online ANSI and ASCII artwork archive
+;module = thescene.js
+;cache = true
+;cachettl = 86400
diff --git a/xtrn/ansiview/sixteencolors-api.js b/xtrn/ansiview/sixteencolors-api.js
new file mode 100644
index 0000000000000000000000000000000000000000..6ca94945e9de9954897d6421924a985995029af5
--- /dev/null
+++ b/xtrn/ansiview/sixteencolors-api.js
@@ -0,0 +1,179 @@
+/*	SixteenColors object
+	A simple interface between Synchronet 3.15+ and the sixteencolors.net API.
+	echicken -at- bbs.electronicchicken.com
+
+	With the exception of .getFile(), which returns a file as a string, all
+	other methods return arrays of objects.
+
+	Useful methods:
+
+	- .years()
+		- Returns a list of years, and how many packs each year has.
+	- .year(year)
+		- Returns a list of all packs in a year
+		- Each object in the array has a 'name' property, among others
+	- .pack(pack)
+		- Finds a pack by name (see above - not 'filename')
+		- Returns an object with various properties, most useful being
+		  'files', which is an array
+		- Each object in the 'files' array has a 'file_location' property
+	- .getFile(file_location)
+		- Retrieves a file by file_location (see above)
+		- Returns the file as a string
+
+	Not-as-useful methods:
+
+	- .packs(page, rows)
+		- Returns 'page' of entries from the full list of packs in the archive
+		  with 'rows' number of elements in the array.
+		- I haven't tried this, but I assume its return value will be similar
+		  to .year(), possibly alphabetized
+	- .file(pack, name)
+		- Returns info about a given file in a given pack
+		- I didn't see much detail that you don't already get from .pack()
+	- .randomFiles(page, rows)
+		- Get a list of random files, I guess
+	- .randomPacks(page, rows)
+		- Get a list of random packs, I guess
+
+	Currently useless methods:
+
+	The API currently returns empty arrays for the following:
+
+	- .groups()
+	- .group(group)
+	- .artists()
+	- .artist()
+	- .month(year, month)
+
+	Errors:
+
+	On error, all methods log an error message and then return null.  This is
+	lame, but it's all I need for now.
+
+*/
+
+load("http.js");
+
+var SixteenColors = function() {
+
+	var apiUrl = "http://api.sixteencolors.net/v0";
+	var downloadUrl = "http://sixteencolors.net/";
+
+	var getJSON = function(path) {
+		try {
+			return JSON.parse(
+				new HTTPRequest().Get(apiUrl + (typeof path == "undefined" ? "" : path))
+			);
+		} catch(err) {
+			log("SixteenColors._getJSON: " + err);
+			return null;
+		}
+	}
+
+	this.years = function() {
+		return getJSON("/year");
+	}
+
+	this.year = function(year) {
+		if(typeof year == "undefined") {
+			throw "SixteenColors.year: Invalid or no year provided.";
+		} else {
+			var y = this.years();
+			if(y === null)
+				return y;
+			var count = 0;
+			for(var yy = 0; yy < y.length; yy++) {
+				if(y[yy].year != "year")
+					continue;
+				count = y[yy].packs;
+				break;
+			}
+			return getJSON("/year/" + year + "?page=1&rows=" + count);
+		}
+	}
+
+	this.packs = function(page, rows) {
+		var path = "/pack";
+		if(typeof page == "number" && typeof rows == "number")
+			path += "?page=" + page + "&rows=" + rows;
+		return getJSON(path);
+	}
+
+	this.pack = function(name) {
+		if(typeof name != "string")
+			throw "SixteenColors.pack: Invalid or no pack name provided.";
+		else
+			return getJSON("/pack/" + name);
+	}
+
+	this.file = function(pack, name) {
+		if(typeof pack != "string")
+			throw "SixteenColors.file: Invalid or no pack name provided.";
+		else if(typeof name != "string")
+			throw "SixteenColors.file: Invalid or no file name provided.";
+		else
+			return getJSON("/pack/" + pack + "/" + name);
+	}
+
+	this.randomFiles = function(page, rows) {
+		if(typeof page == "undefined" || typeof rows == "undefined")
+			throw "SixteenColors.randomFiles: Invalid 'page' or 'rows' argument (must be numbers)";
+		else
+			return getJSON("/file/random?page=" + page + "&rows=" + rows);
+	}
+
+	this.randomPacks = function(page, rows) {
+		if(typeof page == "undefined" || typeof rows == "undefined")
+			throw "SixteenColors.randomPacks: Invalid or missing 'page' or 'rows' arguments";
+		else
+			return getJSON("/pack/random?page=" + page + "&rows=" + rows);
+	}
+
+	this.getFile = function(file_location) {
+		try {
+			return (new HTTPRequest().Get(downloadUrl + file_location));
+		} catch(err) {
+			log("SixteenColors.getFile: " + err);
+			return null;
+		}
+	}
+
+	/*	The API seems to always return empty arrays for these last five calls.
+		I don't think that anything's broken - just that 16c doesn't have
+		group / artist / month metadata entered in for most (or any) packs yet.
+		(When you list packs in a year, for example, each pack's 'month' value
+		always seems to be null.) */
+
+	this.groups = function() {
+		return getJSON("/group");
+	}
+
+	this.group = function(name) {
+		if(typeof name != "string")
+			throw "SixteenColors.group: Invalid or no group name provided.";
+		else
+			return getJSON("/group/" + name);
+	}
+
+	this.artists = function() {
+		return getJSON("/artist");
+	}
+
+	this.artist = function(artist) {
+		if(typeof artist != "string")
+			throw "SixteenColors.artist: Invalid or no artist name provided.";
+		else
+			return getJSON("/artist/" + artist);
+	}
+
+	this.month = function(year, month) {
+		if(typeof year != "string" || year.length != 4)
+			throw "SixteenColors.month: Invalid or no year provided.";
+		else if(typeof month != "string" || month.length != 2)
+			throw "SixteenColors.month: Invalid or no month provided.";
+		else
+			return getJSON("/year/" + year + "/" + month);
+	}
+
+}
\ No newline at end of file
diff --git a/xtrn/ansiview/sixteencolors.js b/xtrn/ansiview/sixteencolors.js
new file mode 100644
index 0000000000000000000000000000000000000000..1113b5152731042b68a7228238d15df22764227c
--- /dev/null
+++ b/xtrn/ansiview/sixteencolors.js
@@ -0,0 +1,378 @@
+(function() {
+
+	load(root + "sixteencolors-api.js");
+
+	var Browser = function(options) {
+
+		var self = this;
+
+		var badExtensions = [
+			"exe",
+			"com",
+			"zip",
+			"gif",
+			"jpg",
+			"png"
+		];
+
+		var properties = {
+			'path' : "",
+			'parentFrame' : null,
+			'frame' : null,
+			'pathFrame' : null,
+			'tree' : null,
+			'treeFrame' : null,
+			'scrollbar' : null,
+			'colors' : {
+				'fg' : WHITE,
+				'bg' : BG_BLACK,
+				'lfg' : WHITE,
+				'lbg' : BG_CYAN,
+				'sfg' : WHITE,
+				'sbg' : BG_BLUE
+			},
+			'index' : 0,
+			'cache' : false,
+			'cachettl' : 0,
+			'cachePath' : root + ".cache/sixteencolors/",
+			'selectHook' : function(item) {}
+		}
+
+		var api = new SixteenColors();
+
+		var errorItem = function() {
+			properties.tree.addItem(
+				"An error was encountered.  Press 'Q' to quit this module.",
+				function() {}
+			);
+		}
+
+		var initSettings = function() {
+
+			if(typeof options.frame == "undefined")
+				throw "SixteenColors Browser: No 'frame' argument provided.";
+			else
+				properties.parentFrame = options.frame;
+
+			if(typeof options.colors != "object")
+				throw "SixteenColors Browser: Invalid or no 'colors' argument provided.";
+			else
+				properties.colors = options.colors;
+
+			if(	typeof options.selectHook != "undefined"
+				&&
+				typeof options.selectHook != "function"
+			) {
+				throw "SixteenColors Browser: 'selectHook' argument is not a function.";
+			} else {
+				properties.selectHook = options.selectHook;
+			}
+
+			if(typeof options.path == "string")
+				properties.path = options.path;
+
+			if(typeof options.cache == "boolean")
+				properties.cache = options.cache;
+
+			if(typeof options.cachettl == "number")
+				properties.cachettl = options.cachettl;
+
+		}
+
+		var initCache = function() {
+			if(!properties.cache)
+				return;
+			if(!file_isdir(properties.cachePath))
+				mkpath(properties.cachePath);
+		}
+
+		var listYears = function() {
+
+			if(properties.cache) {
+				var cacheDir = properties.cachePath + "years/";
+				if(!file_isdir(cacheDir))
+					mkpath(cacheDir);
+				if(	!file_exists(cacheDir + "list.json")
+					||
+					time() - file_date(cacheDir + "list.json") > properties.cachettl
+				) {
+					var list = api.years();
+					if(list === null) {
+						errorItem();
+						return;
+					}
+					var f = new File(cacheDir + "list.json");
+					f.open("w");
+					f.write(JSON.stringify(list));
+					f.close();
+				} else {
+					var f = new File(cacheDir + "list.json");
+					f.open("r");
+					var list = JSON.parse(f.read());
+					f.close();
+				}
+			} else {
+				var list = api.years();
+				if(list === null) {
+					errorItem();
+					return;
+				}
+			}
+
+			list.forEach(
+				function(year) {
+					properties.tree.addItem(
+						format("[%s]...%s packs", year.year, year.packs),
+						function() {
+							properties.path = "/years/" + year.year;
+							self.refresh();
+						}
+					);
+				}
+			);
+
+		}
+
+		var listYear = function(year) {
+
+			if(properties.cache) {
+				var cacheDir = properties.cachePath + "years/" + year + "/";
+				if(!file_isdir(cacheDir))
+					mkpath(cacheDir);
+				if(	!file_exists(cacheDir + "list.json")
+					||
+					time() - file_date(cacheDir + "list.json") > properties.cachettl
+				) {
+					var list = api.year(year);
+					if(list === null) {
+						errorItem();
+						return;
+					}
+					var f = new File(cacheDir + "list.json");
+					f.open("w");
+					f.write(JSON.stringify(list));
+					f.close();
+				} else {
+					var f = new File(cacheDir + "list.json");
+					f.open("r");
+					var list = JSON.parse(f.read());
+					f.close();
+				}
+			} else {
+				var list = api.year(year);
+				if(list === null) {
+					errorItem();
+					return;
+				}
+			}
+
+			properties.tree.addItem(
+				"[..]",
+				function() {
+					properties.path = "/years";
+					self.refresh();
+				}
+			);
+
+			list.forEach(
+				function(item) {
+					properties.tree.addItem(
+						format("[%s]", item.name),
+						function() {
+							properties.path = "/pack/" + item.name;
+							self.refresh();
+						}
+					);
+				}
+			);
+
+		}
+
+		var listPack = function(pack) {
+
+			if(properties.cache) {
+				var cacheDir = properties.cachePath + "packs/" + pack + "/";
+				if(!file_isdir(cacheDir))
+					mkpath(cacheDir);
+				if(	!file_exists(cacheDir + "pack.json")
+					||
+					time() - file_date(cacheDir + "pack.json") > properties.cachettl
+				) {
+					var pack = api.pack(pack);
+					if(pack === null) {
+						errorItem();
+						return;
+					}
+					var f = new File(cacheDir + "pack.json");
+					f.open("w");
+					f.write(JSON.stringify(pack));
+					f.close();
+				} else {
+					var f = new File(cacheDir + "pack.json");
+					f.open("r");
+					var pack = JSON.parse(f.read());
+					f.close();
+				}
+			} else {
+				var pack = api.pack(pack);
+				if(pack === null) {
+					errorItem();
+					return;
+				}
+			}
+
+			properties.tree.addItem(
+				"[..]",
+				function() {
+					properties.path = "/years/" + pack.year;
+					self.refresh();
+				}
+			);
+
+			pack.files.forEach(
+				function(item) {
+					for(var b = 0; b < badExtensions.length; b++) {
+						var re = new RegExp(badExtensions[b] + "$", "i");
+						if(item.filename.match(re) !== null)
+							return;
+					}
+					properties.tree.addItem(
+						format("%s", item.filename),
+						function() {
+							properties.index = properties.tree.index;
+							if(properties.cache) {
+								if(!file_exists(cacheDir + item.filename)) {
+									var ansi = api.getFile(item.file_location);
+									if(ansi === null)
+										return;
+									var f = new File(cacheDir + item.filename);
+									f.open("w");
+									f.write(ansi);
+									f.close();
+								}
+								properties.selectHook(cacheDir + item.filename);
+							} else {
+								if(!file_exists(system.temp_dir + item.filename)) {
+									var ansi = api.getFile(item.file_location);
+									if(ansi === null)
+										return;
+									var f = new File(system.temp_dir + item.filename);
+									f.open("w");
+									f.write(ansi);
+									f.close();
+								}
+								properties.selectHook(system.temp_dir + item.filename);
+							}
+							self.refresh();
+							properties.tree.index = properties.index;
+							properties.tree.refresh();
+						}
+					);
+				}
+			);
+
+		}
+
+		var initList = function() {
+			if(properties.path == "/years")
+				listYears();
+			else if(properties.path.match(/^\/years\/\d\d\d\d$/) !== null)
+				listYear(properties.path.split("/")[2]);
+			else if(properties.path.match(/^\/pack\/.*$/) !== null)
+				listPack(properties.path.split("/")[2]);
+		}
+
+		var initDisplay = function() {
+
+			properties.frame = new Frame(
+				properties.parentFrame.x,
+				properties.parentFrame.y,
+				properties.parentFrame.width,
+				properties.parentFrame.height,
+				BG_BLACK|LIGHTGRAY,
+				properties.parentFrame
+			);
+
+			properties.pathFrame = new Frame(
+				properties.frame.x,
+				properties.frame.y,
+				properties.frame.width,
+				1,
+				properties.colors.sbg|properties.colors.sfg,
+				properties.frame
+			);
+			properties.pathFrame.putmsg(
+				"Browsing: " + decodeURIComponent(properties.path)
+			);
+
+			properties.treeFrame = new Frame(
+				properties.frame.x,
+				properties.frame.y + 1,
+				properties.frame.width,
+				properties.frame.height - 1,
+				properties.frame.attr,
+				properties.frame
+			);
+
+			properties.tree = new Tree(properties.treeFrame);
+			for(var color in properties.colors)
+				properties.tree.colors[color] = properties.colors[color];
+			initList();
+			properties.scrollBar = new ScrollBar(properties.tree);
+			properties.frame.open();
+			properties.tree.open();
+
+		}
+
+		var init = function() {
+			initSettings();
+			initCache();
+			initDisplay();
+		}
+
+		this.open = function() {
+			init();
+			properties.frame.draw();
+		}
+
+		this.cycle = function() {
+			properties.scrollBar.cycle();
+		}
+
+		this.getcmd = function(cmd) {
+			properties.tree.getcmd(cmd);
+		}
+
+		this.refresh = function() {
+			this.close();
+			options.path = properties.path;
+			this.open();
+		}
+
+		this.close = function() {
+			properties.tree.close();
+			properties.scrollBar.close();
+			properties.frame.close();
+		}
+
+	}
+
+	var args = JSON.parse(argv[0]);
+	return new Browser(
+		{	'path' : "/years",
+			'frame' : browserFrame,
+			'selectHook' : printFile,
+			'colors' : {
+				'fg' : args.colors.fg,
+				'bg' : args.colors.bg,
+				'lfg' : args.colors.lfg,
+				'lbg' : args.colors.lbg,
+				'sfg' : args.colors.sfg,
+				'sbg' : args.colors.sbg
+			},
+			'cache' : (typeof args.cache == "undefined") ? true : args.cache,
+			'cachettl' : parseInt(args.cachettl)
+		}
+	);
+
+})();
\ No newline at end of file
diff --git a/xtrn/ansiview/thescene.js b/xtrn/ansiview/thescene.js
new file mode 100644
index 0000000000000000000000000000000000000000..f08781c13596691dd5b25dfd5769e5c6dea2c502
--- /dev/null
+++ b/xtrn/ansiview/thescene.js
@@ -0,0 +1,282 @@
+(function() {
+
+	load("http.js");
+
+	var TheScene = function() {
+		var apiUrl = "http://thescene.electronicchicken.com/api";
+		this.list = function(path) {
+			try {
+				return JSON.parse(
+					new HTTPRequest().Get(
+						apiUrl + "/list/" + (typeof path == "undefined" ? "" : path)
+					)
+				);
+			} catch(err) {
+				return null;
+				log(err);
+			}
+		}
+		this.getANSI = function(path) {
+			if(typeof path != "string")
+				throw "TheScene.getANSI: Invalid 'path' argument: " + path;
+			try {
+				return (new HTTPRequest().Get(apiUrl + "/ansi/" + path));
+			} catch(err) {
+				return null;
+				log(err);
+			}
+		}
+	}
+
+	var Browser = function(options) {
+
+		var self = this;
+
+		var properties = {
+			'path' : "",
+			'parentFrame' : null,
+			'frame' : null,
+			'pathFrame' : null,
+			'tree' : null,
+			'treeFrame' : null,
+			'scrollbar' : null,
+			'colors' : {
+				'fg' : WHITE,
+				'bg' : BG_BLACK,
+				'lfg' : WHITE,
+				'lbg' : BG_CYAN,
+				'sfg' : WHITE,
+				'sbg' : BG_BLUE
+			},
+			'index' : 0,
+			'cache' : false,
+			'cachettl' : 0,
+			'cachePath' : root + ".cache/thescene/",
+			'selectHook' : function(item) {}
+		}
+
+		var api = new TheScene();
+
+		var errorItem = function() {
+			properties.tree.addItem(
+				"An error was encountered.  Press 'Q' to quit this module.",
+				function() {}
+			);
+		}
+
+		var initSettings = function() {
+
+			if(typeof options.frame == "undefined")
+				throw "TheScene Browser: No 'frame' argument provided.";
+			else
+				properties.parentFrame = options.frame;
+
+			if(typeof options.colors != "object")
+				throw "TheScene Browser: Invalid or no 'colors' argument provided.";
+			else
+				properties.colors = options.colors;
+
+			if(	typeof options.selectHook != "undefined"
+				&&
+				typeof options.selectHook != "function"
+			) {
+				throw "TheScene Browser: 'selectHook' argument is not a function.";
+			} else {
+				properties.selectHook = options.selectHook;
+			}
+
+			if(typeof options.path == "string")
+				properties.path = options.path;
+
+			if(typeof options.cache == "boolean")
+				properties.cache = options.cache;
+
+			if(typeof options.cachettl == "number")
+				properties.cachettl = options.cachettl;
+
+		}
+
+		var initCache = function() {
+			if(!properties.cache)
+				return;
+			if(!file_isdir(properties.cachePath))
+				mkpath(properties.cachePath);
+		}
+
+		var initList = function() {
+			
+			if(properties.cache) {
+				var cacheDir = properties.cachePath + backslash(decodeURIComponent(properties.path));
+				if(!file_isdir(cacheDir))
+					mkpath(cacheDir);
+				if(	!file_exists(cacheDir + "list.json")
+					||
+					time() - file_date(cacheDir + "list.json") > properties.cachettl
+				) {
+					var list = api.list(properties.path);
+					if(list === null) {
+						errorItem();
+						return;
+					}
+					var f = new File(cacheDir + "list.json");
+					f.open("w");
+					f.write(JSON.stringify(list));
+					f.close();
+				} else {
+					var f = new File(cacheDir + "list.json");
+					f.open("r");
+					var list = JSON.parse(f.read());
+					f.close();
+				}
+			} else {
+				var list = api.list(properties.path);
+				if(list === null) {
+					errorItem();
+					return;
+				}
+			}
+
+			for(var l in list) {
+				(function(item) {
+					if(item.type == "file") {
+						properties.tree.addItem(
+							item.filename,
+							function() {
+								properties.index = properties.tree.index;
+								if(properties.cache) {
+									var cacheItem = properties.cachePath + decodeURIComponent(item.path);
+									if(!file_exists(cacheItem)) {
+										var ansi = api.getANSI(item.path);
+										if(ansi === null)
+											return;
+										var f = new File(cacheItem);
+										f.open("w");
+										f.write(ansi);
+										f.close();
+									}
+									properties.selectHook(cacheItem);
+								} else {
+									var tempItem = system.temp_dir + md5_calc(item.path, true);
+									if(!file_exists(tempItem)) {
+										var ansi = api.getANSI(item.path);
+										if(ansi === null)
+											return;
+										var f = new File(tempItem);
+										f.open("w");
+										f.write(ansi);
+										f.close();
+									}
+									properties.selectHook(tempItem);
+								}
+								self.refresh();
+								properties.tree.index = properties.index;
+								properties.tree.refresh();
+							}
+						);
+					} else {
+						properties.tree.addItem(
+							"[" + item.filename + "]",
+							function() {
+								properties.path = item.path;
+								self.refresh();
+							}
+						);
+					}
+				})(list[l]);
+			}
+		}
+
+		var initDisplay = function() {
+
+			properties.frame = new Frame(
+				properties.parentFrame.x,
+				properties.parentFrame.y,
+				properties.parentFrame.width,
+				properties.parentFrame.height,
+				BG_BLACK|LIGHTGRAY,
+				properties.parentFrame
+			);
+
+			properties.pathFrame = new Frame(
+				properties.frame.x,
+				properties.frame.y,
+				properties.frame.width,
+				1,
+				properties.colors.sbg|properties.colors.sfg,
+				properties.frame
+			);
+			properties.pathFrame.putmsg(
+				"Browsing: /" + decodeURIComponent(properties.path)
+			);
+
+			properties.treeFrame = new Frame(
+				properties.frame.x,
+				properties.frame.y + 1,
+				properties.frame.width,
+				properties.frame.height - 1,
+				properties.frame.attr,
+				properties.frame
+			);
+
+			properties.tree = new Tree(properties.treeFrame);
+			for(var color in properties.colors)
+				properties.tree.colors[color] = properties.colors[color];
+			initList();
+			properties.scrollBar = new ScrollBar(properties.tree);
+			properties.frame.open();
+			properties.tree.open();
+
+		}
+
+		var init = function() {
+			initSettings();
+			initCache();
+			initDisplay();
+		}
+
+		this.open = function() {
+			init();
+			properties.frame.draw();
+		}
+
+		this.cycle = function() {
+			properties.scrollBar.cycle();
+		}
+
+		this.getcmd = function(cmd) {
+			properties.tree.getcmd(cmd);
+		}
+
+		this.refresh = function() {
+			this.close();
+			options.path = properties.path;
+			this.open();
+		}
+
+		this.close = function() {
+			properties.tree.close();
+			properties.scrollBar.close();
+			properties.frame.close();
+		}
+
+	}
+
+	var args = JSON.parse(argv[0]);
+	return new Browser(
+		{	'path' : "",
+			'frame' : browserFrame,
+			'selectHook' : printFile,
+			'colors' : {
+				'fg' : args.colors.fg,
+				'bg' : args.colors.bg,
+				'lfg' : args.colors.lfg,
+				'lbg' : args.colors.lbg,
+				'sfg' : args.colors.sfg,
+				'sbg' : args.colors.sbg
+			},
+			'cache' : (typeof args.cache == "undefined") ? true : args.cache,
+			'cachettl' : parseInt(args.cachettl)
+		}
+	);
+
+})();
\ No newline at end of file