diff --git a/applications/gui/src/config_window.cpp b/applications/gui/src/config_window.cpp
index 6dd1b4d8a6a7311668a0dd7f35b23dfa6117b700..54480a617d4b56a069ab5866e603d8f1c2a1c6d7 100644
--- a/applications/gui/src/config_window.cpp
+++ b/applications/gui/src/config_window.cpp
@@ -1,4 +1,5 @@
 #include "config_window.hpp"
+#include "../screen.hpp"
 
 #include <nanogui/layout.h>
 #include <nanogui/label.h>
@@ -190,7 +191,7 @@ void ConfigWindow::_addElements(nanogui::FormHelper *form, const std::string &su
 			});
 		} else if (i.value().is_object()) {
 			string key = i.key();
-		
+
 			// Checking the URI with exists() prevents unloaded local configurations from being shown.
 			if (suri.find('#') != string::npos && exists(suri+string("/")+key)) {
 				form->addButton(key, [this,suri,key]() {
@@ -213,7 +214,8 @@ void ConfigWindow::_buildForm(const std::string &suri) {
 	FormHelper *form = new FormHelper(this->screen());
 	//form->setWindow(this);
 	form->addWindow(Vector2i(100,50), uri.getFragment());
-	form->window()->setTheme(theme());
+	auto* theme = dynamic_cast<ftl::gui2::Screen*>(screen())->getTheme("window");
+	form->window()->setTheme(theme);
 
 	_addElements(form, suri);
 
diff --git a/applications/gui2/CMakeLists.txt b/applications/gui2/CMakeLists.txt
index 2a1b58a26f6d922b74b6bdaec5e4d520c9f94378..8f6e81cdbd8f67e8756d89954a0af3e8184132b3 100644
--- a/applications/gui2/CMakeLists.txt
+++ b/applications/gui2/CMakeLists.txt
@@ -3,7 +3,10 @@
 #include_directories(${PROJECT_BINARY_DIR})
 
 function(add_gui_module NAME)
-	list(APPEND GUI2SRC "src/modules/${NAME}.cpp")
+	get_filename_component(FULLPATH "src/modules/${NAME}.cpp" ABSOLUTE)
+	if (EXISTS ${FULLPATH})
+		list(APPEND GUI2SRC "src/modules/${NAME}.cpp")
+	endif()
 
 	get_filename_component(FULLPATH "src/views/${NAME}.cpp" ABSOLUTE)
 	if (EXISTS ${FULLPATH})
@@ -22,8 +25,10 @@ set(GUI2SRC
 	src/frameview.cpp
 )
 
+add_gui_module("themes")
 add_gui_module("config")
 add_gui_module("camera")
+add_gui_module("camera3d")
 add_gui_module("thumbnails")
 add_gui_module("soundctrl")
 
diff --git a/applications/gui2/src/frameview.cpp b/applications/gui2/src/frameview.cpp
index 06250a08f197871f3200dd7da2ae14f6d00836b0..5de6193df9c51cd2fb3bb7b37c521bb360638a8e 100644
--- a/applications/gui2/src/frameview.cpp
+++ b/applications/gui2/src/frameview.cpp
@@ -135,7 +135,7 @@ void buildFrameViewShader(nanogui::GLShader &shader, std::string name, std::stri
 // =============================================================================
 
 FrameView::FrameView(nanogui::Widget *parent) :
-		Widget(parent), texture(ftl::gui2::GLTexture::Type::BGRA) {
+		Widget(parent), texture(ftl::gui2::GLTexture::Type::BGRA), fs_(nullptr) {
 
 	if (glfwGetCurrentContext() == nullptr) {
 		throw FTL_Error("No current OpenGL context");
@@ -144,6 +144,13 @@ FrameView::FrameView(nanogui::Widget *parent) :
 	buildShader(Channel::Colour);
 }
 
+void FrameView::reset() {
+	std::atomic_store(&fs_, {});
+	if (texture.isValid()) {
+		texture.free();
+	}
+}
+
 void FrameView::draw(NVGcontext *ctx) {
 	Widget::draw(ctx);
 
diff --git a/applications/gui2/src/frameview.hpp b/applications/gui2/src/frameview.hpp
index ac52045aaaecd681f7e1d841f5f1f0253f7dac4b..1b53d1511c52f86082f756d9c2a693e26178bf9c 100644
--- a/applications/gui2/src/frameview.hpp
+++ b/applications/gui2/src/frameview.hpp
@@ -18,9 +18,8 @@ class FrameView : public nanogui::Widget {
 private:
 	GLTexture texture;
 	nanogui::GLShader mShader;
-	bool flush_ = true;
-
 	std::shared_ptr<ftl::data::FrameSet> fs_;
+	bool flush_ = true;
 	int fid_ = 0;
 	ftl::codecs::Channel channel_ =  ftl::codecs::Channel::Colour;
 	cudaStream_t stream_;
@@ -32,6 +31,7 @@ private:
 	/** loads a new shader if necessary */
 	void buildShader(ftl::codecs::Channel c);
 
+
 public:
 	FrameView(nanogui::Widget* parent);
 	virtual void draw(NVGcontext *ctx) override;
@@ -41,6 +41,7 @@ public:
 	void setVmin(float v) { vmax_ = v; }
 	float vmax() { return vmax_; }
 	float vmin() { return vmin_; }
+	void reset();
 
 	/** Set frame. If copy == true, buffer will be copied to OpenGL framebuffer
 	 *  at next draw() call. Can be called from any thread. Saves parameters
diff --git a/applications/gui2/src/main.cpp b/applications/gui2/src/main.cpp
index c02130d2620a8d7723fc8c0cdf1340ee094ab0b6..8d61ab0e3585e0a657910c9438c6d20c9c396f92 100644
--- a/applications/gui2/src/main.cpp
+++ b/applications/gui2/src/main.cpp
@@ -64,6 +64,7 @@ FTLGui::FTLGui(int argc, char **argv) {
 	net_->start();
 	net_->waitConnections();
 
+	loadModule<Themes>("themes");
 	loadModule<ThumbnailsController>("home")->activate();
 	loadModule<Camera>("camera");
 	loadModule<ConfigCtrl>("configwindow");
diff --git a/applications/gui2/src/modules.hpp b/applications/gui2/src/modules.hpp
index 253a88cabc96961684becb7a5b99a35053a9d8b2..db1cd52b53cbe356e810f742f2f76a2788b6630a 100644
--- a/applications/gui2/src/modules.hpp
+++ b/applications/gui2/src/modules.hpp
@@ -4,3 +4,4 @@
 #include "modules/camera.hpp"
 #include "modules/config.hpp"
 #include "modules/soundctrl.hpp"
+#include "modules/themes.hpp"
diff --git a/applications/gui2/src/modules/camera.cpp b/applications/gui2/src/modules/camera.cpp
index 1a7ab41d87758c4b9f7d2036ea2f15731bd493e6..1c7c656be0ee1e9d8cb6bd2450f23b0925d1ce88 100644
--- a/applications/gui2/src/modules/camera.cpp
+++ b/applications/gui2/src/modules/camera.cpp
@@ -1,23 +1,35 @@
 #include "camera.hpp"
 
+#include "../views/camera3d.hpp"
+
 using ftl::gui2::Camera;
 using ftl::codecs::Channel;
 
 void Camera::activate() {
-	view = new ftl::gui2::CameraView(screen);
+
 	panel = new ftl::gui2::MediaPanel(screen, this);
 	filter = io->feed()->filter({Channel::Left, Channel::Right, Channel::Depth});
 
-	std::unordered_set<Channel> available = filter->availableChannels();
-	panel->setAvailableChannels(available);
-
 	filter->on(
 		[this](const ftl::data::FrameSetPtr& fs){
+			if (paused || !view) { return true; }
+			std::atomic_store(&active_fs, fs);
 			view->update(fs, source_idx);
 			screen->redraw();
 			return true;
 		}
 	);
+}
+
+void Camera::setSource(int idx) {
+	source_idx = idx;
+
+	if (idx == 0) {
+		view = new ftl::gui2::CameraView(screen);
+	}
+	else {
+		view = new ftl::gui2::CameraView3D(screen);
+	}
 
 	view->onClose([filter = this->filter, panel=this->panel](){
 		filter->remove();
@@ -28,6 +40,8 @@ void Camera::activate() {
 		}
 	});
 
+	std::unordered_set<Channel> available = filter->availableChannels();
+	panel->setAvailableChannels(available);
 	if (available.count(channel) > 0) {
 		setChannel(channel);
 	}
@@ -36,14 +50,26 @@ void Camera::activate() {
 	}
 
 	screen->setView(view);
-}
 
-void Camera::setSource(int idx) {
-	source_idx = idx;
+	if (active_fs && !(*active_fs)[source_idx].hasChannel(channel)) {
+		view->reset();
+	}
+
+	if (paused && active_fs) {
+		view->update(active_fs, source_idx);
+	}
 }
 
 void Camera::setChannel(Channel c) {
 	channel = c;
 	view->setChannel(c);
 	panel->setActiveChannel(channel);
+
+	if (active_fs && !(*active_fs)[source_idx].hasChannel(channel)) {
+		view->reset();
+	}
+
+	if (paused && active_fs) {
+		view->update(active_fs, source_idx);
+	}
 }
diff --git a/applications/gui2/src/modules/camera.hpp b/applications/gui2/src/modules/camera.hpp
index f7a1db023465c3b46cfbfa43bf8f627cedfa9c55..203478ef49262d6e234a880ac02a8e7ebca24740 100644
--- a/applications/gui2/src/modules/camera.hpp
+++ b/applications/gui2/src/modules/camera.hpp
@@ -15,11 +15,17 @@ public:
 
 	void setSource(int);
 	void setChannel(ftl::codecs::Channel c);
+	void setPaused(bool set) { paused = set; };
+	bool isPaused() { return paused; }
 
 private:
 	int source_idx = -1;
+	std::atomic_bool paused = false; // TODO: implement in InputOutput
+
 	ftl::codecs::Channel channel = ftl::codecs::Channel::Colour;
 	ftl::stream::Feed::Filter *filter = nullptr;
+	ftl::data::FrameSetPtr active_fs;
+
 	CameraView* view = nullptr;
 	MediaPanel* panel = nullptr;
 };
diff --git a/applications/gui2/src/modules/soundctrl.cpp b/applications/gui2/src/modules/soundctrl.cpp
index 76ebd209be6eadb837db36780197801cfbdef78e..64402d3f6fd69148514bcfbe0f8492a3e2e3c2f3 100644
--- a/applications/gui2/src/modules/soundctrl.cpp
+++ b/applications/gui2/src/modules/soundctrl.cpp
@@ -10,15 +10,30 @@ using ftl::gui2::SoundCtrl;
 
 void SoundCtrl::init() {
 
-	auto button = screen->addButton<nanogui::PopupButton>("", ENTYPO_ICON_CONTROLLER_VOLUME);
+	auto button = screen->addButton<nanogui::PopupButton>("", ENTYPO_ICON_SOUND);
+	if (io->speaker()->volume() == 0.0f) {
+		button->setIcon(ENTYPO_ICON_SOUND_MUTE);
+	}
+	button->setChevronIcon(-1);
+
 	auto popup = button->popup();
 	popup->setLayout(new nanogui::GroupLayout(15, 6, 14, 0));
 	new nanogui::Label(popup, "Volume");
 	auto slider = new nanogui::Slider(popup);
+	slider->setHighlightColor(screen->getColor("highlight1"));
+	slider->setHighlightedRange({0.0f, io->speaker()->volume()});
 	slider->setHeight(20);
 	slider->setValue(io->speaker()->volume());
-	slider->setCallback([io=io](float v){
+	slider->setCallback([io=io, button, slider](float v){
 		io->speaker()->setVolume(v);
+		slider->setHighlightedRange({0.0f, io->speaker()->volume()});
+		if (io->speaker()->volume() == 0.0f) {
+			button->setIcon(ENTYPO_ICON_SOUND_MUTE);
+		}
+		else {
+			button->setIcon(ENTYPO_ICON_SOUND);
+		}
+
 	});
 
 	popup->setFixedWidth(200);
diff --git a/applications/gui2/src/modules/themes.cpp b/applications/gui2/src/modules/themes.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..f32612d201a450c76bcee5d62a03a8a2d7cd55f5
--- /dev/null
+++ b/applications/gui2/src/modules/themes.cpp
@@ -0,0 +1,56 @@
+#include "themes.hpp"
+#include "nanogui/theme.h"
+#include "../screen.hpp"
+
+using ftl::gui2::Themes;
+using nanogui::Theme;
+
+void Themes::init() {
+	auto* toolbuttheme = screen->getTheme("toolbutton");
+	toolbuttheme->mBorderDark = nanogui::Color(0,0);
+	toolbuttheme->mBorderLight = nanogui::Color(0,0);
+	toolbuttheme->mButtonGradientBotFocused = nanogui::Color(60,255);
+	toolbuttheme->mButtonGradientBotUnfocused = nanogui::Color(0,0);
+	toolbuttheme->mButtonGradientTopFocused = nanogui::Color(60,255);
+	toolbuttheme->mButtonGradientTopUnfocused = nanogui::Color(0,0);
+	toolbuttheme->mButtonGradientTopPushed = nanogui::Color(60,180);
+	toolbuttheme->mButtonGradientBotPushed = nanogui::Color(60,180);
+	toolbuttheme->mTextColor = nanogui::Color(0.9f,0.9f,0.9f,0.9f);
+	toolbuttheme->mWindowDropShadowSize = 0;
+	toolbuttheme->mDropShadow = nanogui::Color(0,0);
+
+	auto* windowtheme = screen->getTheme("window");
+	windowtheme->mWindowHeaderGradientBot = nanogui::Color(0,0);
+	windowtheme->mWindowHeaderGradientTop = nanogui::Color(0,0);
+	windowtheme->mTextColor = nanogui::Color(20,255);
+	windowtheme->mWindowCornerRadius = 0;
+	windowtheme->mBorderDark = nanogui::Color(0,0);
+	windowtheme->mBorderMedium = nanogui::Color(0,0);
+	windowtheme->mBorderLight = nanogui::Color(0,0);
+	windowtheme->mWindowFillFocused = nanogui::Color(64, 0);
+	windowtheme->mWindowFillUnfocused= nanogui::Color(64, 0);
+	windowtheme->mWindowDropShadowSize = 0;
+	windowtheme->mDropShadow = nanogui::Color(0, 0);
+
+	auto* mediatheme = screen->getTheme("media");
+	mediatheme->mIconScale = 1.2f;
+	mediatheme->mWindowDropShadowSize = 0;
+	mediatheme->mWindowFillFocused = nanogui::Color(45, 150);
+	mediatheme->mWindowFillUnfocused = nanogui::Color(45, 80);
+	mediatheme->mButtonGradientTopUnfocused = nanogui::Color(0,0);
+	mediatheme->mButtonGradientBotUnfocused = nanogui::Color(0,0);
+	mediatheme->mButtonGradientTopFocused = nanogui::Color(80,230);
+	mediatheme->mButtonGradientBotFocused = nanogui::Color(80,230);
+	mediatheme->mIconColor = nanogui::Color(255,255);
+	mediatheme->mTextColor = nanogui::Color(1.0f,1.0f,1.0f,1.0f);
+	mediatheme->mBorderDark = nanogui::Color(0,0);
+	mediatheme->mBorderMedium = nanogui::Color(0,0);
+	mediatheme->mBorderLight = nanogui::Color(0,0);
+	mediatheme->mDropShadow = nanogui::Color(0,0);
+	mediatheme->mButtonFontSize = 30;
+	mediatheme->mStandardFontSize = 20;
+
+	// https://flatuicolors.com/palette/defo
+	screen->setColor("highlight1", nanogui::Color(231, 76, 60, 255)); // red
+	screen->setColor("highlight2", nanogui::Color(52, 152, 219, 255)); // blue
+}
diff --git a/applications/gui2/src/modules/themes.hpp b/applications/gui2/src/modules/themes.hpp
new file mode 100644
index 0000000000000000000000000000000000000000..af5d7337ba4ecbbb20992f765f959de7da9ea049
--- /dev/null
+++ b/applications/gui2/src/modules/themes.hpp
@@ -0,0 +1,16 @@
+#pragma once
+
+#include "../module.hpp"
+
+namespace ftl
+{
+namespace gui2
+{
+
+class Themes : public Module {
+public:
+	using Module::Module;
+	virtual void init() override;
+};
+}
+}
diff --git a/applications/gui2/src/modules/thumbnails.cpp b/applications/gui2/src/modules/thumbnails.cpp
index 442635ba61e973fe4bf554e2d6aa687bb4bcca8a..c8ed2e6c4ff7260e8a02c3ee36d38470a7144fb8 100644
--- a/applications/gui2/src/modules/thumbnails.cpp
+++ b/applications/gui2/src/modules/thumbnails.cpp
@@ -11,7 +11,7 @@ using ftl::codecs::Channel;
 using ftl::gui2::ThumbnailsController;
 
 void ThumbnailsController::init() {
-	button = screen->addButton(ENTYPO_ICON_CAMERA);
+	button = screen->addButton(ENTYPO_ICON_HOME);
 	button->setCallback([this](){
 		button->setPushed(false);
 		activate();
@@ -47,6 +47,6 @@ void ThumbnailsController::show_thumbnails() {
 
 void ThumbnailsController::show_camera(int frame_idx) {
 	auto* camera = screen->getModule<ftl::gui2::Camera>();
-	camera->setSource(frame_idx);
 	camera->activate();
+	camera->setSource(frame_idx);
 }
diff --git a/applications/gui2/src/screen.cpp b/applications/gui2/src/screen.cpp
index 80faf86aeb8d0161e0fcb774190ba305dde5690c..a971b3149d7e1c5d8045eb7473e5d3d3acb06936 100644
--- a/applications/gui2/src/screen.cpp
+++ b/applications/gui2/src/screen.cpp
@@ -34,55 +34,6 @@ Screen::Screen() :
 
 	setSize(wsize);
 
-	// themes
-	auto toolbuttheme = new Theme(*theme());
-	toolbuttheme->mBorderDark = nanogui::Color(0,0);
-	toolbuttheme->mBorderLight = nanogui::Color(0,0);
-	toolbuttheme->mButtonGradientBotFocused = nanogui::Color(60,255);
-	toolbuttheme->mButtonGradientBotUnfocused = nanogui::Color(0,0);
-	toolbuttheme->mButtonGradientTopFocused = nanogui::Color(60,255);
-	toolbuttheme->mButtonGradientTopUnfocused = nanogui::Color(0,0);
-	toolbuttheme->mButtonGradientTopPushed = nanogui::Color(60,180);
-	toolbuttheme->mButtonGradientBotPushed = nanogui::Color(60,180);
-	toolbuttheme->mTextColor = nanogui::Color(0.9f,0.9f,0.9f,0.9f);
-	toolbuttheme->mWindowDropShadowSize = 0;
-	toolbuttheme->mDropShadow = nanogui::Color(0,0);
-
-	auto windowtheme = new Theme(*theme());
-	windowtheme->mWindowHeaderGradientBot = nanogui::Color(0,0);
-	windowtheme->mWindowHeaderGradientTop = nanogui::Color(0,0);
-	windowtheme->mTextColor = nanogui::Color(20,255);
-	windowtheme->mWindowCornerRadius = 0;
-	windowtheme->mBorderDark = nanogui::Color(0,0);
-	windowtheme->mBorderMedium = nanogui::Color(0,0);
-	windowtheme->mBorderLight = nanogui::Color(0,0);
-	windowtheme->mWindowFillFocused = nanogui::Color(64, 0);
-	windowtheme->mWindowFillUnfocused= nanogui::Color(64, 0);
-	windowtheme->mWindowDropShadowSize = 0;
-	windowtheme->mDropShadow = nanogui::Color(0, 0);
-
-	auto mediatheme = new Theme(*theme());
-	mediatheme->mIconScale = 1.2f;
-	mediatheme->mWindowDropShadowSize = 0;
-	mediatheme->mWindowFillFocused = nanogui::Color(45, 150);
-	mediatheme->mWindowFillUnfocused = nanogui::Color(45, 80);
-	mediatheme->mButtonGradientTopUnfocused = nanogui::Color(0,0);
-	mediatheme->mButtonGradientBotUnfocused = nanogui::Color(0,0);
-	mediatheme->mButtonGradientTopFocused = nanogui::Color(80,230);
-	mediatheme->mButtonGradientBotFocused = nanogui::Color(80,230);
-	mediatheme->mIconColor = nanogui::Color(255,255);
-	mediatheme->mTextColor = nanogui::Color(1.0f,1.0f,1.0f,1.0f);
-	mediatheme->mBorderDark = nanogui::Color(0,0);
-	mediatheme->mBorderMedium = nanogui::Color(0,0);
-	mediatheme->mBorderLight = nanogui::Color(0,0);
-	mediatheme->mDropShadow = nanogui::Color(0,0);
-	mediatheme->mButtonFontSize = 30;
-	mediatheme->mStandardFontSize = 20;
-
-	themes_["toolbutton"] = toolbuttheme;
-	themes_["window"] = windowtheme;
-	themes_["media"] = mediatheme;
-
 	toolbar_ = new FixedWindow(this);
 	toolbar_->setPosition(Vector2i(0,0));
 	toolbar_->setFixedWidth(toolbar_w);
@@ -114,6 +65,25 @@ Screen::~Screen() {
 	}
 }
 
+
+nanogui::Theme* Screen::getTheme(const std::string &name) {
+	if (themes_.count(name) == 0) {
+		themes_[name] = new nanogui::Theme(*theme());
+	}
+	return themes_[name];
+}
+
+nanogui::Color Screen::getColor(const std::string &name) {
+	if (colors_.count(name) == 0) {
+		return nanogui::Color(0, 0, 0, 0);
+	}
+	return colors_[name];
+}
+
+void Screen::setColor(const std::string &name, const nanogui::Color &c) {
+	colors_[name] = c;
+}
+
 void Screen::redraw() {
 	// glfwPostEmptyEvent() is safe to call from any thread
 	// https://www.glfw.org/docs/3.3/intro_guide.html#thread_safety
@@ -170,3 +140,33 @@ ftl::gui2::Module* Screen::addModule_(const std::string &name, ftl::gui2::Module
 	modules_[name] = ptr;
 	return ptr;
 }
+
+
+bool Screen::keyboardEvent(int key, int scancode, int action, int modifiers) {
+
+	if (nanogui::Screen::keyboardEvent(key, scancode, action, modifiers)) {
+		return true;
+	}
+
+	if (active_view_) {
+		// event not processed in any focused widget
+		return active_view_->keyboardEvent(key, scancode, action, modifiers);
+	}
+
+	return false;
+}
+
+bool Screen::keyboardCharacterEvent(unsigned int codepoint) {
+
+	if (nanogui::Screen::keyboardCharacterEvent(codepoint)) {
+		return true;
+	}
+
+	if (active_view_) {
+		// event not processed in any focused widget
+		return active_view_->keyboardCharacterEvent(codepoint);
+	}
+
+	return false;
+}
+
diff --git a/applications/gui2/src/screen.hpp b/applications/gui2/src/screen.hpp
index fe0648a34c5a4a86f569872d18e327cf677920a1..485f4ad5c9aff3d193d2334f64fb35dcf6cd9ac6 100644
--- a/applications/gui2/src/screen.hpp
+++ b/applications/gui2/src/screen.hpp
@@ -20,9 +20,12 @@ namespace gui2 {
  * unless otherwise documented.
  */
 class Screen : public nanogui::Screen {
-	public:
+public:
 	explicit Screen();
-	~Screen();
+	virtual ~Screen();
+
+	virtual bool keyboardEvent(int key, int scancode, int action, int modifiers) override;
+	virtual bool keyboardCharacterEvent(unsigned int codepoint) override;
 
 	void render(); // necessary?
 	/** Redraw the screen (triggers an empty event). Thread safe. */
@@ -63,16 +66,18 @@ class Screen : public nanogui::Screen {
 	template<typename T=nanogui::ToolButton, typename ... Args>
 	T* addButton(Args ... args);
 
-	nanogui::Theme* getTheme(const std::string &name) {
-		return themes_[name];
-	}
+	/** themes/colors */
+	nanogui::Theme* getTheme(const std::string &name);
+	nanogui::Color getColor(const std::string &name);
+	void setColor(const std::string &name, const nanogui::Color &c);
 
-	private:
+private:
 	Module* addModule_(const std::string &name, Module* ptr);
 
 	//std::mutex mtx_; // not used: do not modify gui outside gui (main) thread
 	std::map<std::string, ftl::gui2::Module*> modules_;
 	std::map<std::string, nanogui::ref<nanogui::Theme>> themes_;
+	std::map<std::string, nanogui::Color> colors_;
 
 	nanogui::Widget *toolbar_;
 	nanogui::Widget *tools_;
diff --git a/applications/gui2/src/view.cpp b/applications/gui2/src/view.cpp
index 9e65837bc5a143ed7106d365776d56dbb4fcae56..aeb818b769264713b4bdd1cf8fac5ac87d5f3a57 100644
--- a/applications/gui2/src/view.cpp
+++ b/applications/gui2/src/view.cpp
@@ -1,4 +1,3 @@
 #include "view.hpp"
 
 using ftl::gui2::View;
-
diff --git a/applications/gui2/src/views/camera.cpp b/applications/gui2/src/views/camera.cpp
index 33d6057d93c01d72a5c7f2b7330406b57da16dba..519431e8696dc34f5d50d496b5d4e51958ea0053 100644
--- a/applications/gui2/src/views/camera.cpp
+++ b/applications/gui2/src/views/camera.cpp
@@ -6,8 +6,8 @@
 #include "camera.hpp"
 #include "../modules/camera.hpp"
 
-using ftl::gui2::CameraView;
 using ftl::gui2::MediaPanel;
+using ftl::gui2::CameraView;
 
 using ftl::codecs::Channel;
 
@@ -24,6 +24,27 @@ MediaPanel::MediaPanel(nanogui::Widget *parent, ftl::gui2::Camera* ctrl) :
 	auto theme = dynamic_cast<ftl::gui2::Screen*>(screen())->getTheme("media");
 	this->setTheme(theme);
 
+	// Pause/Unpause
+	// TODO: implement in InputOutput
+
+	auto button_pause = new Button(this, "", ENTYPO_ICON_CONTROLLER_PAUS);
+	if (ctrl->isPaused()) {
+		button_pause->setIcon(ENTYPO_ICON_CONTROLLER_PLAY);
+	}
+
+	button_pause->setCallback([ctrl = ctrl_ ,button_pause]() {
+		ctrl->setPaused(!ctrl->isPaused());
+
+		if (ctrl->isPaused()) {
+			button_pause->setIcon(ENTYPO_ICON_CONTROLLER_PLAY);
+		} else {
+			button_pause->setIcon(ENTYPO_ICON_CONTROLLER_PAUS);
+		}
+	});
+
+	// Channel select. Creates buttons for 32 channels and sets available ones
+	// visible (a bit of a hack, only used here and setAvailableChannels())
+
 	button_channels = new PopupButton(this, "", ENTYPO_ICON_LAYERS);
 	button_channels->setSide(Popup::Side::Right);
 	button_channels->setChevronIcon(ENTYPO_ICON_CHEVRON_SMALL_RIGHT);
@@ -47,14 +68,17 @@ MediaPanel::MediaPanel(nanogui::Widget *parent, ftl::gui2::Camera* ctrl) :
 MediaPanel::~MediaPanel() {
 	auto popup = button_channels->popup();
 	popup->setVisible(false);
-	if (parent()->getRefCount() > 0 ) { // this should be patched in nanogui
-		popup->dispose(); // nanogui doesn't dispose
+	if (parent()->getRefCount() > 0 ) {	// this and dispose should be patched
+		popup->dispose(); 				// in nanogui; nanogui doesn't dispose
 	}
 }
 
 void MediaPanel::draw(NVGcontext *ctx) {
 	auto size = this->size();
-	setPosition(nanogui::Vector2i(screen()->width() / 2 - size[0]/2, screen()->height() - 30 - size[1]));
+	setPosition(
+		nanogui::Vector2i(	screen()->width() / 2 - size[0]/2,
+							screen()->height() - 30 - size[1]));
+
 	FixedWindow::draw(ctx);
 }
 
@@ -119,6 +143,10 @@ void CameraView::update(const ftl::data::FrameSetPtr& fs, int fid) {
 	fview->set(fs, fid, channel, 0, true);
 }
 
+void CameraView::reset() {
+	fview->reset();
+}
+
 void CameraView::setChannel(ftl::codecs::Channel c) {
 	channel = c;
 }
diff --git a/applications/gui2/src/views/camera.hpp b/applications/gui2/src/views/camera.hpp
index b18d50f6d8472c8b0ec2be1a6857f033c6e69438..46101be02560249a11e8a879288856c88468246b 100644
--- a/applications/gui2/src/views/camera.hpp
+++ b/applications/gui2/src/views/camera.hpp
@@ -35,7 +35,11 @@ public:
 
 	void update(const ftl::data::FrameSetPtr& fs, int fid);
 	void setChannel(ftl::codecs::Channel c);
-private:
+
+	/** reset buffer */
+	void reset();
+
+protected:
 	ftl::gui2::FrameView *fview = nullptr;
 	ftl::codecs::Channel channel = ftl::codecs::Channel::Colour;
 };
diff --git a/applications/gui2/src/views/camera3d.cpp b/applications/gui2/src/views/camera3d.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..5a89f32a92655fd788118a9fc3a8f0d68f5c34fa
--- /dev/null
+++ b/applications/gui2/src/views/camera3d.cpp
@@ -0,0 +1,96 @@
+#include "camera3d.hpp"
+
+using ftl::gui2::CameraView3D;
+
+// =============================================================================
+
+static Eigen::Affine3d create_rotation_matrix(float ax, float ay, float az) {
+	Eigen::Affine3d rx =
+		Eigen::Affine3d(Eigen::AngleAxisd(ax, Eigen::Vector3d(1, 0, 0)));
+	Eigen::Affine3d ry =
+		Eigen::Affine3d(Eigen::AngleAxisd(ay, Eigen::Vector3d(0, 1, 0)));
+	Eigen::Affine3d rz =
+		Eigen::Affine3d(Eigen::AngleAxisd(az, Eigen::Vector3d(0, 0, 1)));
+	return rz * rx * ry;
+}
+
+// ==== CameraView3D ===========================================================
+
+CameraView3D::CameraView3D(nanogui::Widget *parent) : CameraView(parent) {
+	eye_ = Eigen::Vector3d::Zero();
+	neye_ = Eigen::Vector4d::Zero();
+	rotmat_.setIdentity();
+	lerp_speed_ = 0.999f;
+}
+
+bool CameraView3D::keyboardEvent(int key, int scancode, int action, int modifiers) {
+	if (key == 263 || key == 262) {
+		float mag = (modifiers & 0x1) ? 0.01f : 0.1f;
+		float scalar = (key == 263) ? -mag : mag;
+		neye_ += rotmat_*Eigen::Vector4d(scalar, 0.0, 0.0, 1.0);
+	}
+	else if (key == 264 || key == 265) {
+		float mag = (modifiers & 0x1) ? 0.01f : 0.1f;
+		float scalar = (key == 264) ? -mag : mag;
+		neye_ += rotmat_*Eigen::Vector4d(0.0, 0.0, scalar, 1.0);
+	}
+	else if (key == 266 || key == 267) {
+		float mag = (modifiers & 0x1) ? 0.01f : 0.1f;
+		float scalar = (key == 266) ? -mag : mag;
+		neye_ += rotmat_*Eigen::Vector4d(0.0, scalar, 0.0, 1.0);
+	}
+	else if (key >= '0' && key <= '5' && modifiers == 2) {  // Ctrl+NUMBER
+		//int ix = key - (int)('0');
+		//transform_ix_ = ix-1;
+	}
+
+	return true;
+}
+
+bool CameraView3D::mouseButtonEvent(const Eigen::Vector2i &p, int button, bool down, int modifiers) {
+	LOG(INFO) << "mouseButtonEvent: " << p;
+	return true;
+}
+
+bool CameraView3D::mouseMotionEvent(const Eigen::Vector2i &p, const Eigen::Vector2i &rel, int button, int modifiers) {
+	if (button != 1) {
+		return false;
+	}
+
+	rx_ += rel[0];
+	ry_ += rel[1];
+
+	return true;
+}
+
+bool CameraView3D::keyboardCharacterEvent(unsigned int codepoint) {
+	LOG(INFO) << "keyboardCharacterEvent: " << codepoint;
+	LOG(INFO) << getPose();
+	return true;
+}
+
+Eigen::Matrix4d CameraView3D::getUpdatedPose() {
+	double now = glfwGetTime();
+	delta_ = now - ftime_;
+	ftime_ = now;
+
+	float rrx = ((float)ry_ * 0.2f * delta_);
+	float rry = (float)rx_ * 0.2f * delta_;
+	float rrz = 0.0;
+
+	Eigen::Affine3d r = create_rotation_matrix(rrx, -rry, rrz);
+	rotmat_ = rotmat_ * r.matrix();
+
+	rx_ = 0;
+	ry_ = 0;
+
+	eye_[0] += (neye_[0] - eye_[0]) * lerp_speed_ * delta_;
+	eye_[1] += (neye_[1] - eye_[1]) * lerp_speed_ * delta_;
+	eye_[2] += (neye_[2] - eye_[2]) * lerp_speed_ * delta_;
+
+	Eigen::Translation3d trans(eye_);
+	Eigen::Affine3d t(trans);
+	return t.matrix() * rotmat_;
+}
+
+// ==== CameraView3DVR =========================================================
diff --git a/applications/gui2/src/views/camera3d.hpp b/applications/gui2/src/views/camera3d.hpp
new file mode 100644
index 0000000000000000000000000000000000000000..e6e65b9781f92a3c6a0ee7573fc8cf46e92d1894
--- /dev/null
+++ b/applications/gui2/src/views/camera3d.hpp
@@ -0,0 +1,44 @@
+#pragma once
+
+#include "../window.hpp"
+#include "../view.hpp"
+#include "../gltexture.hpp"
+#include "../frameview.hpp"
+
+#include "camera.hpp"
+
+namespace ftl {
+namespace gui2 {
+
+class CameraView3D : public CameraView {
+public:
+	CameraView3D(nanogui::Widget *parent);
+
+	virtual bool keyboardEvent(int key, int scancode, int action, int modifiers) override;
+	virtual bool keyboardCharacterEvent(unsigned int codepoint) override;
+	virtual bool mouseMotionEvent(const Eigen::Vector2i &p, const Eigen::Vector2i &rel, int button, int modifiers) override;
+	virtual bool mouseButtonEvent(const Eigen::Vector2i &p, int button, bool down, int modifiers) override;
+
+	Eigen::Matrix4d getUpdatedPose();
+
+protected:
+	// updates from keyboard
+	Eigen::Vector4d neye_;
+
+	// updates from mouse
+	double rx_;
+	double ry_;
+
+	// current
+	Eigen::Vector3d eye_;
+	Eigen::Matrix4d rotmat_;
+
+	// times for pose update
+	double ftime_;
+	double delta_;
+
+	double lerp_speed_;
+};
+
+}
+}
diff --git a/components/audio/src/speaker.cpp b/components/audio/src/speaker.cpp
index f6b20e546f43f1b258b2eb5201db0e7e66ef0837..b9abfb6d6ec5a59a00579e344f927580260d8f6b 100644
--- a/components/audio/src/speaker.cpp
+++ b/components/audio/src/speaker.cpp
@@ -139,11 +139,16 @@ void Speaker::queue(int64_t ts, ftl::audio::Frame &frame) {
 
 	//LOG(INFO) << "Buffer Fullness (" << ts << "): " << buffer_->size() << " - " << audio.size();
 	for (const auto &d : audio) {
-		auto data = d.data();
-		for (auto &v : data) {
-			v = v * volume_;
+		if (volume_ != 1.0) {
+			auto data = d.data();
+			for (auto &v : data) {
+				v = v * volume_;
+			}
+			buffer_->write(data);
+		}
+		else {
+			buffer_->write(d.data());
 		}
-		buffer_->write(data);
 	}
 	//LOG(INFO) << "Audio delay: " << buffer_.delay() << "s";
 }
@@ -159,7 +164,8 @@ void Speaker::setDelay(int64_t ms) {
 }
 
 void Speaker::setVolume(float value) {
-	volume_ = value;
+	// TODO: adjust volume using system mixer
+	volume_ = std::max(0.0f, std::min(1.0f, value));
 }
 
 float Speaker::volume() {