From 19e8f19e521234d0da6dd0906a1e07bd9726f747 Mon Sep 17 00:00:00 2001
From: Thomas Goyne <plorkyeran@aegisub.org>
Date: Wed, 21 May 2014 16:23:28 -0700
Subject: [PATCH] Redesign project file handling

Add a new Project class which is responsible for everything related to
opening and closing audio, video, subtitles, timecodes and keyframes.
This pulls almost everything not directly related to playing audio/video
out of the audio and video controllers, pulls more crap out of
FrameMain, and happens to make things a little simpler in the process.
---
 build/Aegisub/Aegisub.vcxproj                 |  10 +-
 build/Aegisub/Aegisub.vcxproj.filters         |  24 +-
 libaegisub/include/libaegisub/signal.h        |  48 +-
 src/Makefile                                  |   5 +-
 src/ass_exporter.cpp                          |   6 +-
 ...me_source.cpp => async_video_provider.cpp} |  32 +-
 ..._frame_source.h => async_video_provider.h} |  33 +-
 src/audio_box.cpp                             |   3 +-
 src/audio_controller.cpp                      | 200 +-------
 src/audio_controller.h                        |  84 +---
 src/audio_display.cpp                         |  25 +-
 src/audio_display.h                           |   3 +-
 src/audio_karaoke.cpp                         |  16 +-
 src/audio_karaoke.h                           |  11 +-
 src/audio_marker.cpp                          |  13 +-
 src/audio_marker.h                            |  14 +-
 src/audio_provider_avs.cpp                    |   2 -
 src/audio_provider_pcm.cpp                    |   4 -
 src/audio_timing_karaoke.cpp                  |  14 +-
 src/auto4_base.cpp                            |   6 +-
 src/auto4_base.h                              |   3 +-
 src/auto4_lua.cpp                             |  18 +-
 src/base_grid.cpp                             |  59 +--
 src/command/app.cpp                           |  13 +-
 src/command/audio.cpp                         | 124 +++--
 src/command/automation.cpp                    |   2 +-
 src/command/edit.cpp                          |   5 +-
 src/command/keyframe.cpp                      |  17 +-
 src/command/recent.cpp                        |  22 +-
 src/command/subtitle.cpp                      |  20 +-
 src/command/time.cpp                          |  55 ++-
 src/command/timecode.cpp                      |  26 +-
 src/command/tool.cpp                          |   2 +-
 src/command/video.cpp                         |  44 +-
 src/command/vis_tool.cpp                      |   5 +-
 src/context.cpp                               |   8 +-
 src/dialog_detached_video.cpp                 |  14 +-
 src/dialog_jumpto.cpp                         |  10 +-
 src/dialog_properties.cpp                     |   9 +-
 src/dialog_resample.cpp                       |  14 +-
 src/dialog_shift_times.cpp                    |   6 +-
 src/dialog_styling_assistant.cpp              |  11 +-
 src/dialog_timing_processor.cpp               |  62 +--
 src/dialog_translation.cpp                    |   7 +-
 src/dialog_video_details.cpp                  |  18 +-
 src/dialog_video_properties.cpp               |  13 +-
 src/dialog_video_properties.h                 |   5 +-
 src/export_framerate.cpp                      |  63 ++-
 src/export_framerate.h                        |  12 +-
 src/frame_main.cpp                            | 273 ++---------
 src/frame_main.h                              |  24 +-
 src/grid_column.cpp                           |   2 +-
 src/include/aegisub/audio_provider.h          |   3 -
 src/include/aegisub/context.h                 |   8 +-
 src/include/aegisub/video_provider.h          |   2 +-
 src/main.cpp                                  |  19 +-
 src/project.cpp                               | 443 +++++++++++++++++
 src/project.h                                 | 104 ++++
 src/subs_controller.cpp                       |  64 +--
 src/subs_controller.h                         |   4 +-
 src/subs_edit_box.cpp                         |  26 +-
 src/subs_edit_box.h                           |   3 +-
 src/subtitle_format.cpp                       |   2 +-
 src/subtitle_format_microdvd.cpp              |   2 +-
 src/timeedit_ctrl.cpp                         |  12 +-
 src/utils.h                                   |   9 -
 src/validators.h                              |   2 +-
 src/video_box.cpp                             |  37 +-
 src/video_box.h                               |   4 +-
 src/video_context.cpp                         | 445 ------------------
 src/video_controller.cpp                      | 274 +++++++++++
 src/{video_context.h => video_controller.h}   | 124 +----
 src/video_display.cpp                         |  47 +-
 src/video_display.h                           |   6 +-
 src/video_provider_ffmpegsource.cpp           |   2 +-
 src/video_slider.cpp                          |  25 +-
 src/video_slider.h                            |  15 +-
 src/visual_tool.cpp                           |   2 +-
 src/visual_tool.h                             |   3 +-
 src/visual_tool_drag.cpp                      |   4 +-
 80 files changed, 1493 insertions(+), 1717 deletions(-)
 rename src/{threaded_frame_source.cpp => async_video_provider.cpp} (80%)
 rename src/{threaded_frame_source.h => async_video_provider.h} (80%)
 create mode 100644 src/project.cpp
 create mode 100644 src/project.h
 delete mode 100644 src/video_context.cpp
 create mode 100644 src/video_controller.cpp
 rename src/{video_context.h => video_controller.h} (56%)

diff --git a/build/Aegisub/Aegisub.vcxproj b/build/Aegisub/Aegisub.vcxproj
index ce9b19969..7d6b05077 100644
--- a/build/Aegisub/Aegisub.vcxproj
+++ b/build/Aegisub/Aegisub.vcxproj
@@ -205,6 +205,7 @@
     <ClInclude Include="$(SrcDir)placeholder_ctrl.h" />
     <ClInclude Include="$(SrcDir)preferences.h" />
     <ClInclude Include="$(SrcDir)preferences_base.h" />
+    <ClInclude Include="$(SrcDir)project.h" />
     <ClInclude Include="$(SrcDir)resolution_resampler.h" />
     <ClInclude Include="$(SrcDir)scintilla_text_ctrl.h" />
     <ClInclude Include="$(SrcDir)search_replace_engine.h" />
@@ -234,7 +235,7 @@
     <ClInclude Include="$(SrcDir)text_file_writer.h" />
     <ClInclude Include="$(SrcDir)text_selection_controller.h" />
     <ClInclude Include="$(SrcDir)thesaurus.h" />
-    <ClInclude Include="$(SrcDir)threaded_frame_source.h" />
+    <ClInclude Include="$(SrcDir)async_video_provider.h" />
     <ClInclude Include="$(SrcDir)time_range.h" />
     <ClInclude Include="$(SrcDir)timeedit_ctrl.h" />
     <ClInclude Include="$(SrcDir)toggle_bitmap.h" />
@@ -244,7 +245,7 @@
     <ClInclude Include="$(SrcDir)vector2d.h" />
     <ClInclude Include="$(SrcDir)version.h" />
     <ClInclude Include="$(SrcDir)video_box.h" />
-    <ClInclude Include="$(SrcDir)video_context.h" />
+    <ClInclude Include="$(SrcDir)video_controller.h" />
     <ClInclude Include="$(SrcDir)video_display.h" />
     <ClInclude Include="$(SrcDir)video_frame.h" />
     <ClInclude Include="$(SrcDir)video_out_gl.h" />
@@ -282,6 +283,7 @@
     <ClCompile Include="$(SrcDir)ass_style.cpp" />
     <ClCompile Include="$(SrcDir)ass_style_storage.cpp" />
     <ClCompile Include="$(SrcDir)ass_time.cpp" />
+    <ClCompile Include="$(SrcDir)async_video_provider.cpp" />
     <ClCompile Include="$(SrcDir)audio_box.cpp" />
     <ClCompile Include="$(SrcDir)audio_colorscheme.cpp" />
     <ClCompile Include="$(SrcDir)audio_controller.cpp" />
@@ -395,6 +397,7 @@
     <ClCompile Include="$(SrcDir)persist_location.cpp" />
     <ClCompile Include="$(SrcDir)preferences.cpp" />
     <ClCompile Include="$(SrcDir)preferences_base.cpp" />
+    <ClCompile Include="$(SrcDir)project.cpp" />
     <ClCompile Include="$(SrcDir)resolution_resampler.cpp" />
     <ClCompile Include="$(SrcDir)scintilla_text_ctrl.cpp" />
     <ClCompile Include="$(SrcDir)search_replace_engine.cpp" />
@@ -426,7 +429,6 @@
     <ClCompile Include="$(SrcDir)text_file_writer.cpp" />
     <ClCompile Include="$(SrcDir)text_selection_controller.cpp" />
     <ClCompile Include="$(SrcDir)thesaurus.cpp" />
-    <ClCompile Include="$(SrcDir)threaded_frame_source.cpp" />
     <ClCompile Include="$(SrcDir)timeedit_ctrl.cpp" />
     <ClCompile Include="$(SrcDir)toggle_bitmap.cpp" />
     <ClCompile Include="$(SrcDir)toolbar.cpp" />
@@ -436,7 +438,7 @@
     <ClCompile Include="$(SrcDir)vector2d.cpp" />
     <ClCompile Include="$(SrcDir)version.cpp" />
     <ClCompile Include="$(SrcDir)video_box.cpp" />
-    <ClCompile Include="$(SrcDir)video_context.cpp" />
+    <ClCompile Include="$(SrcDir)video_controller.cpp" />
     <ClCompile Include="$(SrcDir)video_display.cpp" />
     <ClCompile Include="$(SrcDir)video_frame.cpp" />
     <ClCompile Include="$(SrcDir)video_out_gl.cpp" />
diff --git a/build/Aegisub/Aegisub.vcxproj.filters b/build/Aegisub/Aegisub.vcxproj.filters
index c29884ca8..75c0d3d9f 100644
--- a/build/Aegisub/Aegisub.vcxproj.filters
+++ b/build/Aegisub/Aegisub.vcxproj.filters
@@ -348,10 +348,7 @@
     <ClInclude Include="$(SrcDir)dialog_version_check.h">
       <Filter>Features\Update checker</Filter>
     </ClInclude>
-    <ClInclude Include="$(SrcDir)threaded_frame_source.h">
-      <Filter>Video\Providers</Filter>
-    </ClInclude>
-    <ClInclude Include="$(SrcDir)video_context.h">
+    <ClInclude Include="$(SrcDir)video_controller.h">
       <Filter>Video</Filter>
     </ClInclude>
     <ClInclude Include="$(SrcDir)video_frame.h">
@@ -624,6 +621,12 @@
     <ClInclude Include="$(SrcDir)dialog_video_properties.h">
       <Filter>Features\Resolution resampler</Filter>
     </ClInclude>
+    <ClInclude Include="$(SrcDir)async_video_provider.h">
+      <Filter>Video\Providers</Filter>
+    </ClInclude>
+    <ClInclude Include="$(SrcDir)project.h">
+      <Filter>Main UI</Filter>
+    </ClInclude>
   </ItemGroup>
   <ItemGroup>
     <ClCompile Include="$(SrcDir)ass_dialogue.cpp">
@@ -950,10 +953,7 @@
     <ClCompile Include="$(SrcDir)dialog_version_check.cpp">
       <Filter>Features\Update checker</Filter>
     </ClCompile>
-    <ClCompile Include="$(SrcDir)threaded_frame_source.cpp">
-      <Filter>Video\Providers</Filter>
-    </ClCompile>
-    <ClCompile Include="$(SrcDir)video_context.cpp">
+    <ClCompile Include="$(SrcDir)video_controller.cpp">
       <Filter>Video</Filter>
     </ClCompile>
     <ClCompile Include="$(SrcDir)video_slider.cpp">
@@ -1181,9 +1181,15 @@
     <ClCompile Include="$(SrcDir)dialog_video_properties.cpp">
       <Filter>Features\Resolution resampler</Filter>
     </ClCompile>
+    <ClCompile Include="$(SrcDir)async_video_provider.cpp">
+      <Filter>Video\Providers</Filter>
+    </ClCompile>
+    <ClCompile Include="$(SrcDir)project.cpp">
+      <Filter>Main UI</Filter>
+    </ClCompile>
   </ItemGroup>
   <ItemGroup>
     <ResourceCompile Include="$(SrcDir)res\res.rc" />
     <ResourceCompile Include="$(SrcDir)res\strings.rc" />
   </ItemGroup>
-</Project>
\ No newline at end of file
+</Project>
diff --git a/libaegisub/include/libaegisub/signal.h b/libaegisub/include/libaegisub/signal.h
index bf465ac53..50394896f 100644
--- a/libaegisub/include/libaegisub/signal.h
+++ b/libaegisub/include/libaegisub/signal.h
@@ -12,10 +12,6 @@
 // ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
 // OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
 
-/// @file signal.h
-/// @brief 
-/// @ingroup libaegisub
-
 #pragma once
 
 #include <boost/container/map.hpp>
@@ -45,11 +41,29 @@ namespace detail {
 	};
 }
 
+/// A connection which is not automatically closed
+///
+/// Connections initially start out owned by the signal. If a slot knows that it
+/// will outlive a signal and does not need to be able to block a connection, it
+/// can simply ignore the return value of Connect.
+///
+/// If a slot needs to be able to disconnect from a signal, it should store the
+/// returned connection in a Connection, which transfers ownership of the
+/// connection to the slot. If there is any chance that the signal will outlive
+/// the slot, this must be done.
+class UnscopedConnection {
+	friend class Connection;
+	detail::ConnectionToken *token;
+public:
+	UnscopedConnection(detail::ConnectionToken *token) : token(token) { }
+};
+
 /// Object representing a connection to a signal
 class Connection {
 	std::unique_ptr<detail::ConnectionToken> token;
 public:
 	Connection() = default;
+	Connection(UnscopedConnection src) BOOST_NOEXCEPT : token(src.token) { token->claimed = true; }
 	Connection(Connection&& that) BOOST_NOEXCEPT : token(std::move(that.token)) { }
 	Connection(detail::ConnectionToken *token) BOOST_NOEXCEPT : token(token) { token->claimed = true; }
 	Connection& operator=(Connection&& that) BOOST_NOEXCEPT { token = std::move(that.token); return *this; }
@@ -69,23 +83,6 @@ public:
 	void Unblock() { if (token) token->blocked = false; }
 };
 
-/// A connection which is not automatically closed
-///
-/// Connections initially start out owned by the signal. If a slot knows that it
-/// will outlive a signal and does not need to be able to block a connection, it
-/// can simply ignore the return value of Connect.
-///
-/// If a slot needs to be able to disconnect from a signal, it should store the
-/// returned connection in a Connection, which transfers ownership of the
-/// connection to the slot. If there is any chance that the signal will outlive
-/// the slot, this must be done.
-class UnscopedConnection {
-	detail::ConnectionToken *token;
-public:
-	UnscopedConnection(detail::ConnectionToken *token) : token(token) { }
-	operator Connection() { return Connection(token); }
-};
-
 namespace detail {
 	/// Polymorphic base class for slots
 	///
@@ -198,6 +195,15 @@ public:
 	}
 };
 
+/// Create a vector of scoped connections from an initializer list
+///
+/// Required due to that initializer lists copy their input, and trying to pass
+/// an initializer list directly to a vector results in a
+/// std::initializer_list<Connection>, which can't be copied.
+inline std::vector<Connection> make_vector(std::initializer_list<UnscopedConnection> connections) {
+	return std::vector<Connection>(std::begin(connections), std::end(connections));
+}
+
 } }
 
 /// @brief Define functions which forward their arguments to the connect method
diff --git a/src/Makefile b/src/Makefile
index 32b187536..e6d056023 100644
--- a/src/Makefile
+++ b/src/Makefile
@@ -122,6 +122,7 @@ SRC += \
 	ass_style.cpp \
 	ass_style_storage.cpp \
 	ass_time.cpp \
+	async_video_provider.cpp \
 	audio_box.cpp \
 	audio_colorscheme.cpp \
 	audio_controller.cpp \
@@ -205,6 +206,7 @@ SRC += \
 	persist_location.cpp \
 	preferences.cpp \
 	preferences_base.cpp \
+	project.cpp \
 	resolution_resampler.cpp \
 	scintilla_text_ctrl.cpp \
 	search_replace_engine.cpp \
@@ -233,7 +235,6 @@ SRC += \
 	text_file_writer.cpp \
 	text_selection_controller.cpp \
 	thesaurus.cpp \
-	threaded_frame_source.cpp \
 	timeedit_ctrl.cpp \
 	toggle_bitmap.cpp \
 	toolbar.cpp \
@@ -243,7 +244,7 @@ SRC += \
 	vector2d.cpp \
 	version.cpp \
 	video_box.cpp \
-	video_context.cpp \
+	video_controller.cpp \
 	video_display.cpp \
 	video_frame.cpp \
 	video_out_gl.cpp \
diff --git a/src/ass_exporter.cpp b/src/ass_exporter.cpp
index 33739c6fb..1935284cb 100644
--- a/src/ass_exporter.cpp
+++ b/src/ass_exporter.cpp
@@ -38,8 +38,8 @@
 #include "ass_file.h"
 #include "compat.h"
 #include "include/aegisub/context.h"
+#include "project.h"
 #include "subtitle_format.h"
-#include "video_context.h"
 
 #include <memory>
 #include <wx/sizer.h>
@@ -51,7 +51,7 @@ void AssExporter::DrawSettings(wxWindow *parent, wxSizer *target_sizer) {
 	for (auto& filter : *AssExportFilterChain::GetFilterList()) {
 		// Make sure to construct static box sizer first, so it won't overlap
 		// the controls on wxMac.
-		wxSizer *box = new wxStaticBoxSizer(wxVERTICAL, parent, to_wx(filter.GetName()));
+		auto box = new wxStaticBoxSizer(wxVERTICAL, parent, to_wx(filter.GetName()));
 		wxWindow *window = filter.GetConfigDialogWindow(parent, c);
 		if (window) {
 			box->Add(window, 0, wxEXPAND, 0);
@@ -92,7 +92,7 @@ void AssExporter::Export(agi::fs::path const& filename, std::string const& chars
 	if (!writer)
 		throw "Unknown file type.";
 
-	writer->WriteFile(&subs, filename, c->videoController->FPS(), charset);
+	writer->WriteFile(&subs, filename, c->project->Timecodes(), charset);
 }
 
 wxSizer *AssExporter::GetSettingsSizer(std::string const& name) {
diff --git a/src/threaded_frame_source.cpp b/src/async_video_provider.cpp
similarity index 80%
rename from src/threaded_frame_source.cpp
rename to src/async_video_provider.cpp
index 16f3eb880..b57ff95c0 100644
--- a/src/threaded_frame_source.cpp
+++ b/src/async_video_provider.cpp
@@ -14,18 +14,12 @@
 //
 // Aegisub Project http://www.aegisub.org/
 
-/// @file threaded_frame_source.cpp
-/// @see threaded_frame_source.h
-/// @ingroup video
-///
-
-#include "threaded_frame_source.h"
+#include "async_video_provider.h"
 
 #include "ass_dialogue.h"
 #include "ass_file.h"
 #include "export_fixstyle.h"
 #include "include/aegisub/subtitles_provider.h"
-#include "include/aegisub/video_provider.h"
 #include "video_frame.h"
 #include "video_provider_manager.h"
 
@@ -40,11 +34,11 @@ enum {
 	SUBS_FILE_ALREADY_LOADED = -2
 };
 
-std::shared_ptr<VideoFrame> ThreadedFrameSource::ProcFrame(int frame_number, double time, bool raw) {
+std::shared_ptr<VideoFrame> AsyncVideoProvider::ProcFrame(int frame_number, double time, bool raw) {
 	std::shared_ptr<VideoFrame> frame;
 
 	try {
-		frame = video_provider->GetFrame(frame_number);
+		frame = source_provider->GetFrame(frame_number);
 	}
 	catch (VideoProviderError const& err) { throw VideoProviderErrorEvent(err); }
 
@@ -89,20 +83,20 @@ static std::unique_ptr<SubtitlesProvider> get_subs_provider(wxEvtHandler *evt_ha
 	}
 }
 
-ThreadedFrameSource::ThreadedFrameSource(agi::fs::path const& video_filename, std::string const& colormatrix, wxEvtHandler *parent, agi::BackgroundRunner *br)
+AsyncVideoProvider::AsyncVideoProvider(agi::fs::path const& video_filename, std::string const& colormatrix, wxEvtHandler *parent, agi::BackgroundRunner *br)
 : worker(agi::dispatch::Create())
 , subs_provider(get_subs_provider(parent, br))
-, video_provider(VideoProviderFactory::GetProvider(video_filename, colormatrix, br))
+, source_provider(VideoProviderFactory::GetProvider(video_filename, colormatrix, br))
 , parent(parent)
 {
 }
 
-ThreadedFrameSource::~ThreadedFrameSource() {
+AsyncVideoProvider::~AsyncVideoProvider() {
 	// Block until all currently queued jobs are complete
 	worker->Sync([]{});
 }
 
-void ThreadedFrameSource::LoadSubtitles(const AssFile *new_subs) throw() {
+void AsyncVideoProvider::LoadSubtitles(const AssFile *new_subs) throw() {
 	uint_fast32_t req_version = ++version;
 
 	auto copy = new AssFile(*new_subs);
@@ -113,7 +107,7 @@ void ThreadedFrameSource::LoadSubtitles(const AssFile *new_subs) throw() {
 	});
 }
 
-void ThreadedFrameSource::UpdateSubtitles(const AssFile *new_subs, std::set<const AssDialogue*> const& changes) throw() {
+void AsyncVideoProvider::UpdateSubtitles(const AssFile *new_subs, std::set<const AssDialogue*> const& changes) throw() {
 	uint_fast32_t req_version = ++version;
 
 	// Copy just the lines which were changed, then replace the lines at the
@@ -141,7 +135,7 @@ void ThreadedFrameSource::UpdateSubtitles(const AssFile *new_subs, std::set<cons
 	});
 }
 
-void ThreadedFrameSource::RequestFrame(int new_frame, double new_time) throw() {
+void AsyncVideoProvider::RequestFrame(int new_frame, double new_time) throw() {
 	uint_fast32_t req_version = ++version;
 
 	worker->Async([=]{
@@ -151,7 +145,7 @@ void ThreadedFrameSource::RequestFrame(int new_frame, double new_time) throw() {
 	});
 }
 
-void ThreadedFrameSource::ProcAsync(uint_fast32_t req_version) {
+void AsyncVideoProvider::ProcAsync(uint_fast32_t req_version) {
 	// Only actually produce the frame if there's no queued changes waiting
 	if (req_version < version || frame_number < 0) return;
 
@@ -166,14 +160,14 @@ void ThreadedFrameSource::ProcAsync(uint_fast32_t req_version) {
 	}
 }
 
-std::shared_ptr<VideoFrame> ThreadedFrameSource::GetFrame(int frame, double time, bool raw) {
+std::shared_ptr<VideoFrame> AsyncVideoProvider::GetFrame(int frame, double time, bool raw) {
 	std::shared_ptr<VideoFrame> ret;
 	worker->Sync([&]{ ret = ProcFrame(frame, time, raw); });
 	return ret;
 }
 
-void ThreadedFrameSource::SetColorSpace(std::string const& matrix) {
-	worker->Async([=] { video_provider->SetColorSpace(matrix); });
+void AsyncVideoProvider::SetColorSpace(std::string const& matrix) {
+	worker->Async([=] { source_provider->SetColorSpace(matrix); });
 }
 
 wxDEFINE_EVENT(EVT_FRAME_READY, FrameReadyEvent);
diff --git a/src/threaded_frame_source.h b/src/async_video_provider.h
similarity index 80%
rename from src/threaded_frame_source.h
rename to src/async_video_provider.h
index e347f4cd4..317c8cabb 100644
--- a/src/threaded_frame_source.h
+++ b/src/async_video_provider.h
@@ -14,19 +14,14 @@
 //
 // Aegisub Project http://www.aegisub.org/
 
-/// @file threaded_frame_source.h
-/// @see threaded_frame_source.cpp
-/// @ingroup video
-///
+#include "include/aegisub/video_provider.h"
 
 #include <libaegisub/exception.h>
 #include <libaegisub/fs_fwd.h>
 
 #include <atomic>
-#include <deque>
 #include <memory>
 #include <set>
-
 #include <wx/event.h>
 
 class AssDialogue;
@@ -40,16 +35,15 @@ namespace agi {
 	namespace dispatch { class Queue; }
 }
 
-/// @class ThreadedFrameSource
-/// @brief An asynchronous video decoding and subtitle rendering wrapper
-class ThreadedFrameSource {
+/// An asynchronous video decoding and subtitle rendering wrapper
+class AsyncVideoProvider {
 	/// Asynchronous work queue
 	std::unique_ptr<agi::dispatch::Queue> worker;
 
 	/// Subtitles provider
 	std::unique_ptr<SubtitlesProvider> subs_provider;
 	/// Video provider
-	std::unique_ptr<VideoProvider> video_provider;
+	std::unique_ptr<VideoProvider> source_provider;
 	/// Event handler to send FrameReady events to
 	wxEvtHandler *parent;
 
@@ -103,17 +97,26 @@ public:
 	/// @brief raw   Get raw frame without subtitles
 	std::shared_ptr<VideoFrame> GetFrame(int frame, double time, bool raw = false);
 
-	/// Get a reference to the video provider this is using
-	VideoProvider *GetVideoProvider() const { return video_provider.get(); }
-
 	/// Ask the video provider to change YCbCr matricies
 	void SetColorSpace(std::string const& matrix);
 
+	int GetFrameCount() const             { return source_provider->GetFrameCount(); }
+	int GetWidth() const                  { return source_provider->GetWidth(); }
+	int GetHeight() const                 { return source_provider->GetHeight(); }
+	double GetDAR() const                 { return source_provider->GetDAR(); }
+	agi::vfr::Framerate GetFPS() const    { return source_provider->GetFPS(); }
+	std::vector<int> GetKeyFrames() const { return source_provider->GetKeyFrames(); }
+	std::string GetColorSpace() const     { return source_provider->GetColorSpace(); }
+	std::string GetRealColorSpace() const { return source_provider->GetRealColorSpace(); }
+	std::string GetWarning() const        { return source_provider->GetWarning(); }
+	std::string GetDecoderName() const    { return source_provider->GetDecoderName(); }
+	bool ShouldSetVideoProperties() const { return source_provider->ShouldSetVideoProperties(); }
+
 	/// @brief Constructor
 	/// @param videoFileName File to open
 	/// @param parent Event handler to send FrameReady events to
-	ThreadedFrameSource(agi::fs::path const& filename, std::string const& colormatrix, wxEvtHandler *parent, agi::BackgroundRunner *br);
-	~ThreadedFrameSource();
+	AsyncVideoProvider(agi::fs::path const& filename, std::string const& colormatrix, wxEvtHandler *parent, agi::BackgroundRunner *br);
+	~AsyncVideoProvider();
 };
 
 /// Event which signals that a requested frame is ready
diff --git a/src/audio_box.cpp b/src/audio_box.cpp
index f21e79238..61947d910 100644
--- a/src/audio_box.cpp
+++ b/src/audio_box.cpp
@@ -61,6 +61,7 @@
 #include "command/command.h"
 #include "libresrc/libresrc.h"
 #include "options.h"
+#include "project.h"
 #include "toggle_bitmap.h"
 #include "selection_controller.h"
 #include "utils.h"
@@ -75,7 +76,7 @@ AudioBox::AudioBox(wxWindow *parent, agi::Context *context)
 : wxSashWindow(parent, -1, wxDefaultPosition, wxDefaultSize, wxSW_3D | wxCLIP_CHILDREN)
 , controller(context->audioController.get())
 , context(context)
-, audio_open_connection(controller->AddAudioOpenListener(&AudioBox::OnAudioOpen, this))
+, audio_open_connection(context->project->AddAudioProviderListener(&AudioBox::OnAudioOpen, this))
 , panel(new wxPanel(this, -1, wxDefaultPosition, wxDefaultSize, wxTAB_TRAVERSAL | wxBORDER_RAISED))
 , audioDisplay(new AudioDisplay(panel, context->audioController.get(), context))
 , HorizontalZoom(new wxSlider(panel, Audio_Horizontal_Zoom, -OPT_GET("Audio/Zoom/Horizontal")->GetInt(), -50, 30, wxDefaultPosition, wxSize(-1, 20), wxSL_VERTICAL|wxSL_BOTH))
diff --git a/src/audio_controller.cpp b/src/audio_controller.cpp
index b37ba6952..709f1067d 100644
--- a/src/audio_controller.cpp
+++ b/src/audio_controller.cpp
@@ -27,36 +27,23 @@
 //
 // Aegisub Project http://www.aegisub.org/
 
-/// @file audio_controller.cpp
-/// @brief Manage open audio and abstract state away from display
-/// @ingroup audio_ui
-///
-
 #include "audio_controller.h"
 
-#include "ass_file.h"
 #include "audio_timing.h"
-#include "compat.h"
-#include "dialog_progress.h"
 #include "include/aegisub/audio_player.h"
 #include "include/aegisub/audio_provider.h"
 #include "include/aegisub/context.h"
-#include "pen.h"
 #include "options.h"
+#include "project.h"
 #include "selection_controller.h"
-#include "subs_controller.h"
 #include "utils.h"
-#include "video_context.h"
-
-#include <libaegisub/io.h>
-#include <libaegisub/path.h>
 
 #include <algorithm>
 
 AudioController::AudioController(agi::Context *context)
 : context(context)
-, subtitle_save_slot(context->subsController->AddFileSaveListener(&AudioController::OnSubtitlesSave, this))
 , playback_timer(this)
+, provider_connection(context->project->AddAudioProviderListener(&AudioController::OnAudioProvider, this))
 {
 	Bind(wxEVT_TIMER, &AudioController::OnPlaybackTimer, this, playback_timer.GetId());
 
@@ -66,25 +53,18 @@ AudioController::AudioController(agi::Context *context)
 #endif
 
 	OPT_SUB("Audio/Player", &AudioController::OnAudioPlayerChanged, this);
-	OPT_SUB("Audio/Provider", &AudioController::OnAudioProviderChanged, this);
-	OPT_SUB("Audio/Cache/Type", &AudioController::OnAudioProviderChanged, this);
-
-#ifdef WITH_FFMS2
-	// As with the video ones, it'd be nice to figure out a decent way to move
-	// this to the provider itself
-	OPT_SUB("Provider/Audio/FFmpegSource/Decode Error Handling", &AudioController::OnAudioProviderChanged, this);
-#endif
 }
 
 AudioController::~AudioController()
 {
-	CloseAudio();
+	Stop();
 }
 
 void AudioController::OnPlaybackTimer(wxTimerEvent &)
 {
-	int64_t pos = player->GetCurrentPosition();
+	if (!player) return;
 
+	int64_t pos = player->GetCurrentPosition();
 	if (!player->IsPlaying() ||
 		(playback_mode != PM_ToEnd && pos >= player->GetEndPosition()+200))
 	{
@@ -107,113 +87,40 @@ void AudioController::OnComputerSuspending(wxPowerEvent &)
 
 void AudioController::OnComputerResuming(wxPowerEvent &)
 {
-	if (provider)
-	{
-		try
-		{
-			player = AudioPlayerFactory::GetAudioPlayer(provider.get(), context->parent);
-		}
-		catch (...)
-		{
-			CloseAudio();
-		}
-	}
+	OnAudioPlayerChanged();
 }
 #endif
 
 void AudioController::OnAudioPlayerChanged()
 {
-	if (!IsAudioOpen()) return;
+	if (!provider) return;
 
 	Stop();
-
 	player.reset();
 
 	try
 	{
-		player = AudioPlayerFactory::GetAudioPlayer(provider.get(), context->parent);
+		player = AudioPlayerFactory::GetAudioPlayer(provider, context->parent);
 	}
 	catch (...)
 	{
-		CloseAudio();
-		throw;
+		context->project->CloseAudio();
 	}
 }
 
-void AudioController::OnAudioProviderChanged()
-{
-	if (IsAudioOpen())
-		// url is cloned because CloseAudio clears it and OpenAudio takes a const reference
-		OpenAudio(agi::fs::path(audio_url));
-}
-
-void AudioController::OpenAudio(agi::fs::path const& url)
-{
-	if (url.empty())
-		throw agi::InternalError("AudioController::OpenAudio() was passed an empty string. This must not happen.", nullptr);
-
-	std::unique_ptr<AudioProvider> new_provider;
-	try {
-		DialogProgress progress(context->parent);
-		new_provider = AudioProviderFactory::GetProvider(url, &progress);
-		config::path->SetToken("?audio", url);
-	}
-	catch (agi::UserCancelException const&) {
-		throw;
-	}
-	catch (...) {
-		config::mru->Remove("Audio", url);
-		throw;
-	}
-
-	CloseAudio();
-
-	player = AudioPlayerFactory::GetAudioPlayer(new_provider.get(), context->parent);
-	provider = std::move(new_provider);
-
-	audio_url = url;
-
-	config::mru->Add("Audio", url);
-
-	try
-	{
-		AnnounceAudioOpen(provider.get());
-	}
-	catch (...)
-	{
-		CloseAudio();
-		throw;
-	}
-}
-
-void AudioController::CloseAudio()
+void AudioController::OnAudioProvider(AudioProvider *new_provider)
 {
+	provider = new_provider;
 	Stop();
-
 	player.reset();
-	provider.reset();
-	player = nullptr;
-	provider = nullptr;
-
-	audio_url.clear();
-
-	config::path->SetToken("?audio", "");
-
-	AnnounceAudioClose();
-}
-
-bool AudioController::IsAudioOpen() const
-{
-	return player && provider;
+	OnAudioPlayerChanged();
 }
 
 void AudioController::SetTimingController(std::unique_ptr<AudioTimingController> new_controller)
 {
 	timing_controller = std::move(new_controller);
 	if (timing_controller)
-	{
 		timing_controller->AddUpdatedPrimaryRangeListener(&AudioController::OnTimingControllerUpdatedPrimaryRange, this);
-	}
 
 	AnnounceTimingControllerChanged();
 }
@@ -224,17 +131,9 @@ void AudioController::OnTimingControllerUpdatedPrimaryRange()
 		player->SetEndPosition(SamplesFromMilliseconds(timing_controller->GetPrimaryPlaybackRange().end()));
 }
 
-void AudioController::OnSubtitlesSave()
-{
-	if (IsAudioOpen())
-		context->ass->SetScriptInfo("Audio URI", config::path->MakeRelative(audio_url, "?script").generic_string());
-	else
-		context->ass->SetScriptInfo("Audio URI", "");
-}
-
 void AudioController::PlayRange(const TimeRange &range)
 {
-	if (!IsAudioOpen()) return;
+	if (!player) return;
 
 	player->Play(SamplesFromMilliseconds(range.begin()), SamplesFromMilliseconds(range.length()));
 	playback_mode = PM_Range;
@@ -252,8 +151,6 @@ void AudioController::PlayPrimaryRange()
 
 void AudioController::PlayToEndOfPrimary(int start_ms)
 {
-	if (!IsAudioOpen()) return;
-
 	PlayRange(TimeRange(start_ms, GetPrimaryPlaybackRange().end()));
 	if (playback_mode == PM_Range)
 		playback_mode = PM_PrimaryRange;
@@ -261,7 +158,7 @@ void AudioController::PlayToEndOfPrimary(int start_ms)
 
 void AudioController::PlayToEnd(int start_ms)
 {
-	if (!IsAudioOpen()) return;
+	if (!player) return;
 
 	int64_t start_sample = SamplesFromMilliseconds(start_ms);
 	player->Play(start_sample, provider->GetNumSamples()-start_sample);
@@ -273,7 +170,7 @@ void AudioController::PlayToEnd(int start_ms)
 
 void AudioController::Stop()
 {
-	if (!IsAudioOpen()) return;
+	if (!player) return;
 
 	player->Stop();
 	playback_mode = PM_NotPlaying;
@@ -284,7 +181,7 @@ void AudioController::Stop()
 
 bool AudioController::IsPlaying()
 {
-	return IsAudioOpen() && playback_mode != PM_NotPlaying;
+	return player && playback_mode != PM_NotPlaying;
 }
 
 int AudioController::GetPlaybackPosition()
@@ -297,88 +194,31 @@ int AudioController::GetPlaybackPosition()
 int AudioController::GetDuration() const
 {
 	if (!provider) return 0;
-
 	return (provider->GetNumSamples() * 1000 + provider->GetSampleRate() - 1) / provider->GetSampleRate();
 }
 
 TimeRange AudioController::GetPrimaryPlaybackRange() const
 {
 	if (timing_controller)
-	{
 		return timing_controller->GetPrimaryPlaybackRange();
-	}
 	else
-	{
-		return TimeRange(0, 0);
-	}
+		return TimeRange{0, 0};
 }
 
 void AudioController::SetVolume(double volume)
 {
-	if (!IsAudioOpen()) return;
+	if (!player) return;
 	player->SetVolume(volume);
 }
 
 int64_t AudioController::SamplesFromMilliseconds(int64_t ms) const
 {
-	/// @todo There might be some subtle rounding errors here.
-
 	if (!provider) return 0;
-
-	int64_t sr = provider->GetSampleRate();
-
-	int64_t millisamples = ms * sr;
-
-	return (millisamples + 999) / 1000;
+	return (ms * provider->GetSampleRate() + 999) / 1000;
 }
 
 int64_t AudioController::MillisecondsFromSamples(int64_t samples) const
 {
-	/// @todo There might be some subtle rounding errors here.
-
 	if (!provider) return 0;
-
-	int64_t sr = provider->GetSampleRate();
-
-	int64_t millisamples = samples * 1000;
-
-	return millisamples / sr;
-}
-
-void AudioController::SaveClip(agi::fs::path const& filename, TimeRange const& range) const
-{
-	int64_t start_sample = SamplesFromMilliseconds(range.begin());
-	int64_t end_sample = SamplesFromMilliseconds(range.end());
-	if (filename.empty() || start_sample > provider->GetNumSamples() || range.length() == 0) return;
-
-	agi::io::Save outfile(filename, true);
-	std::ostream& out(outfile.Get());
-
-	size_t bytes_per_sample = provider->GetBytesPerSample() * provider->GetChannels();
-	size_t bufsize = (end_sample - start_sample) * bytes_per_sample;
-
-	int intval;
-	short shortval;
-
-	out << "RIFF";
-	out.write((char*)&(intval=bufsize+36),4);
-	out<< "WAVEfmt ";
-	out.write((char*)&(intval=16),4);
-	out.write((char*)&(shortval=1),2);
-	out.write((char*)&(shortval=provider->GetChannels()),2);
-	out.write((char*)&(intval=provider->GetSampleRate()),4);
-	out.write((char*)&(intval=provider->GetSampleRate()*provider->GetChannels()*provider->GetBytesPerSample()),4);
-	out.write((char*)&(intval=provider->GetChannels()*provider->GetBytesPerSample()),2);
-	out.write((char*)&(shortval=provider->GetBytesPerSample()<<3),2);
-	out << "data";
-	out.write((char*)&bufsize,4);
-
-	//samples per read
-	size_t spr = 65536 / bytes_per_sample;
-	std::vector<char> buf(bufsize);
-	for(int64_t i = start_sample; i < end_sample; i += spr) {
-		size_t len = std::min<size_t>(spr, end_sample - i);
-		provider->GetAudio(&buf[0], i, len);
-		out.write(&buf[0], len * bytes_per_sample);
-	}
+	return samples * 1000 / provider->GetSampleRate();
 }
diff --git a/src/audio_controller.h b/src/audio_controller.h
index d2687d2a9..87f3dcdcd 100644
--- a/src/audio_controller.h
+++ b/src/audio_controller.h
@@ -27,23 +27,14 @@
 //
 // Aegisub Project http://www.aegisub.org/
 
-/// @file audio_controller.h
-/// @see audio_controller.cpp
-/// @ingroup audio_ui
-
-#pragma once
+#include <libaegisub/exception.h>
+#include <libaegisub/fs_fwd.h>
+#include <libaegisub/signal.h>
 
-#include <boost/filesystem/path.hpp>
 #include <cstdint>
-#include <memory>
-
 #include <wx/event.h>
-#include <wx/timer.h>
 #include <wx/power.h>
-
-#include <libaegisub/exception.h>
-#include <libaegisub/fs_fwd.h>
-#include <libaegisub/signal.h>
+#include <wx/timer.h>
 
 class AudioPlayer;
 class AudioProvider;
@@ -52,17 +43,10 @@ namespace agi { struct Context; }
 class TimeRange;
 
 /// @class AudioController
-/// @brief Manage an open audio stream
-///
-/// Creates and destroys audio providers and players. This behaviour should at
-/// some point be moved to a separate class, as it adds too many
-/// responsibilities to this class, but at the time of writing, it would extend
-/// the scope of reworking components too much.
+/// @brief Manage playback of an open audio stream
 ///
-/// There is not supposed to be a way to get direct access to the audio
-/// providers or players owned by a controller. If some operation that isn't
-/// possible in the existing design is needed, the controller should be
-/// extended in some way to allow it.
+/// AudioController owns an AudioPlayer and uses it to play audio from the
+/// project's current audio provider.
 class AudioController final : public wxEvtHandler {
 	/// Project context this controller belongs to
 	agi::Context *context;
@@ -70,12 +54,6 @@ class AudioController final : public wxEvtHandler {
 	/// Slot for subtitles save signal
 	agi::signal::Connection subtitle_save_slot;
 
-	/// A new audio stream was opened (and any previously open was closed)
-	agi::signal::Signal<AudioProvider*> AnnounceAudioOpen;
-
-	/// The current audio stream was closed
-	agi::signal::Signal<> AnnounceAudioClose;
-
 	/// Playback is in progress and the current position was updated
 	agi::signal::Signal<int> AnnouncePlaybackPosition;
 
@@ -88,16 +66,9 @@ class AudioController final : public wxEvtHandler {
 	/// The audio output object
 	std::unique_ptr<AudioPlayer> player;
 
-	/// The audio provider
-	std::unique_ptr<AudioProvider> provider;
-
 	/// The current timing mode, if any; owned by the audio controller
 	std::unique_ptr<AudioTimingController> timing_controller;
 
-	/// The URL of the currently open audio, if any
-	agi::fs::path audio_url;
-
-
 	enum PlaybackMode {
 		PM_NotPlaying,
 		PM_Range,
@@ -110,6 +81,12 @@ class AudioController final : public wxEvtHandler {
 	/// Timer used for playback position updates
 	wxTimer playback_timer;
 
+	/// The audio provider
+	AudioProvider *provider = nullptr;
+	agi::signal::Connection provider_connection;
+
+	void OnAudioProvider(AudioProvider *new_provider);
+
 	/// Event handler for the playback timer
 	void OnPlaybackTimer(wxTimerEvent &event);
 
@@ -119,15 +96,9 @@ class AudioController final : public wxEvtHandler {
 	/// @brief Timing controller signals that the rendering style ranges have changed
 	void OnTimingControllerUpdatedStyleRanges();
 
-	/// Subtitles save slot which adds the audio uri to the subtitles
-	void OnSubtitlesSave();
-
 	/// Handler for the current audio player changing
 	void OnAudioPlayerChanged();
 
-	/// Handler for the current audio provider changing
-	void OnAudioProviderChanged();
-
 #ifdef wxHAS_POWER_EVENTS
 	/// Handle computer going into suspend mode by stopping audio and closing device
 	void OnComputerSuspending(wxPowerEvent &event);
@@ -146,31 +117,9 @@ class AudioController final : public wxEvtHandler {
 	int64_t SamplesFromMilliseconds(int64_t ms) const;
 
 public:
-	/// @brief Constructor
 	AudioController(agi::Context *context);
-
-	/// @brief Destructor
 	~AudioController();
 
-	/// @brief Open an audio stream
-	/// @param url URL of the stream to open
-	void OpenAudio(agi::fs::path const& url);
-
-	/// @brief Closes the current audio stream
-	void CloseAudio();
-
-	/// @brief Determine whether audio is currently open
-	/// @return True if an audio stream is open and can be played back
-	bool IsAudioOpen() const;
-
-	/// @brief Get the URL for the current open audio stream
-	/// @return The URL for the audio stream
-	///
-	/// The returned URL can be passed into OpenAudio() later to open the same
-	/// stream again.
-	agi::fs::path GetAudioURL() const { return audio_url; }
-
-
 	/// @brief Start or restart audio playback, playing a range
 	/// @param range The range of audio to play back
 	///
@@ -233,13 +182,6 @@ public:
 	/// @param new_mode The new timing controller or nullptr
 	void SetTimingController(std::unique_ptr<AudioTimingController> new_controller);
 
-	/// @brief Save a portion of the decoded loaded audio to a wav file
-	/// @param filename File to save to
-	/// @param range Time range to save
-	void SaveClip(agi::fs::path const& filename, TimeRange const& range) const;
-
-	DEFINE_SIGNAL_ADDERS(AnnounceAudioOpen,               AddAudioOpenListener)
-	DEFINE_SIGNAL_ADDERS(AnnounceAudioClose,              AddAudioCloseListener)
 	DEFINE_SIGNAL_ADDERS(AnnouncePlaybackPosition,        AddPlaybackPositionListener)
 	DEFINE_SIGNAL_ADDERS(AnnouncePlaybackStop,            AddPlaybackStopListener)
 	DEFINE_SIGNAL_ADDERS(AnnounceTimingControllerChanged, AddTimingControllerListener)
diff --git a/src/audio_display.cpp b/src/audio_display.cpp
index b1d8e4707..3843e2b9a 100644
--- a/src/audio_display.cpp
+++ b/src/audio_display.cpp
@@ -48,9 +48,10 @@
 #include "include/aegisub/context.h"
 #include "include/aegisub/hotkey.h"
 #include "options.h"
+#include "project.h"
 #include "selection_controller.h"
 #include "utils.h"
-#include "video_context.h"
+#include "video_controller.h"
 
 #include <libaegisub/make_unique.h>
 
@@ -547,7 +548,7 @@ public:
 
 AudioDisplay::AudioDisplay(wxWindow *parent, AudioController *controller, agi::Context *context)
 : wxWindow(parent, -1, wxDefaultPosition, wxDefaultSize, wxWANTS_CHARS|wxBORDER_SIMPLE)
-, audio_open_connection(controller->AddAudioOpenListener(&AudioDisplay::OnAudioOpen, this))
+, audio_open_connection(context->project->AddAudioProviderListener(&AudioDisplay::OnAudioOpen, this))
 , context(context)
 , audio_renderer(agi::make_unique<AudioRenderer>())
 , controller(controller)
@@ -1193,16 +1194,16 @@ void AudioDisplay::OnAudioOpen(AudioProvider *provider)
 	{
 		if (connections.empty())
 		{
-			connections.push_back(controller->AddAudioCloseListener([=] { OnAudioOpen(nullptr); }));
-			connections.push_back(controller->AddPlaybackPositionListener(&AudioDisplay::OnPlaybackPosition, this));
-			connections.push_back(controller->AddPlaybackStopListener(&AudioDisplay::RemoveTrackCursor, this));
-			connections.push_back(controller->AddTimingControllerListener(&AudioDisplay::OnTimingController, this));
-			connections.push_back(OPT_SUB("Audio/Spectrum", &AudioDisplay::ReloadRenderingSettings, this));
-			connections.push_back(OPT_SUB("Audio/Display/Waveform Style", &AudioDisplay::ReloadRenderingSettings, this));
-			connections.push_back(OPT_SUB("Colour/Audio Display/Spectrum", &AudioDisplay::ReloadRenderingSettings, this));
-			connections.push_back(OPT_SUB("Colour/Audio Display/Waveform", &AudioDisplay::ReloadRenderingSettings, this));
-			connections.push_back(OPT_SUB("Audio/Renderer/Spectrum/Quality", &AudioDisplay::ReloadRenderingSettings, this));
-
+			connections = agi::signal::make_vector({
+				controller->AddPlaybackPositionListener(&AudioDisplay::OnPlaybackPosition, this),
+				controller->AddPlaybackStopListener(&AudioDisplay::RemoveTrackCursor, this),
+				controller->AddTimingControllerListener(&AudioDisplay::OnTimingController, this),
+				OPT_SUB("Audio/Spectrum", &AudioDisplay::ReloadRenderingSettings, this),
+				OPT_SUB("Audio/Display/Waveform Style", &AudioDisplay::ReloadRenderingSettings, this),
+				OPT_SUB("Colour/Audio Display/Spectrum", &AudioDisplay::ReloadRenderingSettings, this),
+				OPT_SUB("Colour/Audio Display/Waveform", &AudioDisplay::ReloadRenderingSettings, this),
+				OPT_SUB("Audio/Renderer/Spectrum/Quality", &AudioDisplay::ReloadRenderingSettings, this),
+			});
 			OnTimingController();
 		}
 
diff --git a/src/audio_display.h b/src/audio_display.h
index ca230382d..4c17d4be5 100644
--- a/src/audio_display.h
+++ b/src/audio_display.h
@@ -35,7 +35,6 @@
 
 #include <chrono>
 #include <cstdint>
-#include <deque>
 #include <map>
 #include <memory>
 
@@ -103,7 +102,7 @@ public:
 class AudioDisplay: public wxWindow {
 	agi::signal::Connection audio_open_connection;
 
-	std::deque<agi::signal::Connection> connections;
+	std::vector<agi::signal::Connection> connections;
 	agi::Context *context;
 
 	/// The audio renderer manager
diff --git a/src/audio_karaoke.cpp b/src/audio_karaoke.cpp
index 7879b235e..56ec1288a 100644
--- a/src/audio_karaoke.cpp
+++ b/src/audio_karaoke.cpp
@@ -32,6 +32,7 @@
 #include "compat.h"
 #include "libresrc/libresrc.h"
 #include "options.h"
+#include "project.h"
 #include "selection_controller.h"
 #include "utils.h"
 
@@ -40,7 +41,6 @@
 #include <algorithm>
 #include <boost/locale/boundary.hpp>
 #include <numeric>
-
 #include <wx/bmpbuttn.h>
 #include <wx/button.h>
 #include <wx/dcclient.h>
@@ -63,8 +63,7 @@ AudioKaraoke::AudioKaraoke(wxWindow *parent, agi::Context *c)
 : wxWindow(parent, -1, wxDefaultPosition, wxDefaultSize, wxTAB_TRAVERSAL | wxBORDER_SUNKEN)
 , c(c)
 , file_changed(c->ass->AddCommitListener(&AudioKaraoke::OnFileChanged, this))
-, audio_opened(c->audioController->AddAudioOpenListener(&AudioKaraoke::OnAudioOpened, this))
-, audio_closed(c->audioController->AddAudioCloseListener(&AudioKaraoke::OnAudioClosed, this))
+, audio_opened(c->project->AddAudioProviderListener(&AudioKaraoke::OnAudioOpened, this))
 , active_line_changed(c->selectionController->AddActiveLineListener(&AudioKaraoke::OnActiveLineChanged, this))
 , kara(agi::make_unique<AssKaraoke>())
 {
@@ -122,12 +121,11 @@ void AudioKaraoke::OnFileChanged(int type, std::set<const AssDialogue *> const&
 	}
 }
 
-void AudioKaraoke::OnAudioOpened() {
-	SetEnabled(enabled);
-}
-
-void AudioKaraoke::OnAudioClosed() {
-	c->audioController->SetTimingController(nullptr);
+void AudioKaraoke::OnAudioOpened(AudioProvider *provider) {
+	if (provider)
+		SetEnabled(enabled);
+	else
+		c->audioController->SetTimingController(nullptr);
 }
 
 void AudioKaraoke::SetEnabled(bool en) {
diff --git a/src/audio_karaoke.h b/src/audio_karaoke.h
index c7c16942c..0cc7c3b39 100644
--- a/src/audio_karaoke.h
+++ b/src/audio_karaoke.h
@@ -14,26 +14,20 @@
 //
 // Aegisub Project http://www.aegisub.org/
 
-/// @file audio_karaoke.h
-/// @see audio_karaoke.cpp
-/// @ingroup audio_ui
-///
-
 #include <libaegisub/signal.h>
 
 #include <memory>
 #include <set>
 #include <unordered_map>
 #include <vector>
-
 #include <wx/bitmap.h>
 #include <wx/timer.h>
 #include <wx/window.h>
 
 class AssDialogue;
 class AssKaraoke;
+class AudioProvider;
 class wxButton;
-
 namespace agi { struct Context; }
 
 /// @class AudioKaraoke
@@ -147,8 +141,7 @@ class AudioKaraoke final : public wxWindow {
 	void OnMouse(wxMouseEvent &event);
 	void OnPaint(wxPaintEvent &event);
 	void OnSize(wxSizeEvent &event);
-	void OnAudioOpened();
-	void OnAudioClosed();
+	void OnAudioOpened(AudioProvider *provider);
 	void OnScrollTimer(wxTimerEvent &event);
 
 public:
diff --git a/src/audio_marker.cpp b/src/audio_marker.cpp
index dbf96f8c3..c627d5b96 100644
--- a/src/audio_marker.cpp
+++ b/src/audio_marker.cpp
@@ -24,7 +24,8 @@
 #include "include/aegisub/context.h"
 #include "options.h"
 #include "pen.h"
-#include "video_context.h"
+#include "project.h"
+#include "video_controller.h"
 
 #include <libaegisub/make_unique.h>
 
@@ -42,9 +43,9 @@ public:
 };
 
 AudioMarkerProviderKeyframes::AudioMarkerProviderKeyframes(agi::Context *c, const char *opt_name)
-: vc(c->videoController.get())
-, keyframe_slot(vc->AddKeyframesListener(&AudioMarkerProviderKeyframes::Update, this))
-, timecode_slot(vc->AddTimecodesListener(&AudioMarkerProviderKeyframes::Update, this))
+: p(c->project.get())
+, keyframe_slot(p->AddKeyframesListener(&AudioMarkerProviderKeyframes::Update, this))
+, timecode_slot(p->AddTimecodesListener(&AudioMarkerProviderKeyframes::Update, this))
 , enabled_slot(OPT_SUB(opt_name, &AudioMarkerProviderKeyframes::Update, this))
 , enabled_opt(OPT_GET(opt_name))
 , style(agi::make_unique<Pen>("Colour/Audio Display/Keyframe"))
@@ -55,8 +56,8 @@ AudioMarkerProviderKeyframes::AudioMarkerProviderKeyframes(agi::Context *c, cons
 AudioMarkerProviderKeyframes::~AudioMarkerProviderKeyframes() { }
 
 void AudioMarkerProviderKeyframes::Update() {
-	std::vector<int> const& keyframes = vc->GetKeyFrames();
-	agi::vfr::Framerate const& timecodes = vc->FPS();
+	auto const& keyframes = p->Keyframes();
+	auto const& timecodes = p->Timecodes();
 
 	if (keyframes.empty() || !timecodes.IsLoaded() || !enabled_opt->GetBool()) {
 		if (!markers.empty()) {
diff --git a/src/audio_marker.h b/src/audio_marker.h
index f8ac06f3e..b953b6cd4 100644
--- a/src/audio_marker.h
+++ b/src/audio_marker.h
@@ -14,11 +14,6 @@
 //
 // Aegisub Project http://www.aegisub.org/
 
-/// @file audio_marker.h
-/// @see audio_marker.cpp
-/// @ingroup audio_ui
-///
-
 #pragma once
 
 #include <libaegisub/signal.h>
@@ -30,7 +25,8 @@
 
 class AudioMarkerKeyframe;
 class Pen;
-class VideoContext;
+class Project;
+class VideoController;
 class TimeRange;
 class VideoPositionMarker;
 class wxPen;
@@ -113,8 +109,8 @@ public:
 
 /// Marker provider for video keyframes
 class AudioMarkerProviderKeyframes final : public AudioMarkerProvider {
-	/// Video controller to get keyframes from
-	VideoContext *vc;
+	/// Project to get keyframes from
+	Project *p;
 
 	agi::signal::Connection keyframe_slot;
 	agi::signal::Connection timecode_slot;
@@ -146,7 +142,7 @@ public:
 
 /// Marker provider for the current video playback position
 class VideoPositionMarkerProvider final : public AudioMarkerProvider {
-	VideoContext *vc;
+	VideoController *vc;
 
 	std::unique_ptr<VideoPositionMarker> marker;
 
diff --git a/src/audio_provider_avs.cpp b/src/audio_provider_avs.cpp
index 8af99ae2e..11c8922aa 100644
--- a/src/audio_provider_avs.cpp
+++ b/src/audio_provider_avs.cpp
@@ -64,8 +64,6 @@ public:
 };
 
 AvisynthAudioProvider::AvisynthAudioProvider(agi::fs::path const& filename) {
-	this->filename = filename;
-
 	agi::acs::CheckFileRead(filename);
 
 	std::lock_guard<std::mutex> lock(avs_wrapper.GetMutex());
diff --git a/src/audio_provider_pcm.cpp b/src/audio_provider_pcm.cpp
index e669067c4..024b73d12 100644
--- a/src/audio_provider_pcm.cpp
+++ b/src/audio_provider_pcm.cpp
@@ -158,8 +158,6 @@ public:
 	RiffWavPCMAudioProvider(agi::fs::path const& filename)
 	: PCMAudioProvider(filename)
 	{
-		this->filename = filename;
-
 		// Read header
 		auto const& header = Read<RIFFChunk>(0);
 
@@ -292,8 +290,6 @@ public:
 	Wave64AudioProvider(agi::fs::path const& filename)
 	: PCMAudioProvider(filename)
 	{
-		this->filename = filename;
-
 		size_t smallest_possible_file = sizeof(RiffChunk) + sizeof(FormatChunk) + sizeof(DataChunk);
 
 		if (file->size() < smallest_possible_file)
diff --git a/src/audio_timing_karaoke.cpp b/src/audio_timing_karaoke.cpp
index 78f8d9b4c..dd66288e8 100644
--- a/src/audio_timing_karaoke.cpp
+++ b/src/audio_timing_karaoke.cpp
@@ -39,7 +39,6 @@
 #include <boost/range/algorithm/copy.hpp>
 #include <boost/range/adaptor/filtered.hpp>
 #include <boost/range/adaptor/sliced.hpp>
-#include <deque>
 
 /// @class KaraokeMarker
 /// @brief AudioMarker implementation for AudioTimingControllerKaraoke
@@ -55,6 +54,8 @@ public:
 
 	void Move(int new_pos) { position = new_pos; }
 
+	KaraokeMarker(int position) : position(position) { }
+
 	KaraokeMarker(int position, Pen *pen, FeetStyle style)
 	: position(position)
 	, pen(pen)
@@ -62,11 +63,6 @@ public:
 	{
 	}
 
-	KaraokeMarker(int position)
-	: position(position)
-	{
-	}
-
 	operator int() const { return position; }
 };
 
@@ -79,7 +75,7 @@ public:
 /// This does not support \kt, as it inherently requires that the end time of
 /// one syllable be the same as the start time of the next one.
 class AudioTimingControllerKaraoke final : public AudioTimingController {
-	std::deque<agi::signal::Connection> slots;
+	std::vector<agi::signal::Connection> connections;
 	agi::signal::Connection& file_changed_slot;
 
 	agi::Context *c;          ///< Project context
@@ -161,8 +157,8 @@ AudioTimingControllerKaraoke::AudioTimingControllerKaraoke(agi::Context *c, AssK
 , keyframes_provider(c, "Audio/Display/Draw/Keyframes in Karaoke Mode")
 , video_position_provider(c)
 {
-	slots.push_back(kara->AddSyllablesChangedListener(&AudioTimingControllerKaraoke::Revert, this));
-	slots.push_back(OPT_SUB("Audio/Auto/Commit", [=](agi::OptionValue const& opt) { auto_commit = opt.GetBool(); }));
+	connections.push_back(kara->AddSyllablesChangedListener(&AudioTimingControllerKaraoke::Revert, this));
+	connections.push_back(OPT_SUB("Audio/Auto/Commit", [=](agi::OptionValue const& opt) { auto_commit = opt.GetBool(); }));
 
 	keyframes_provider.AddMarkerMovedListener([=]{ AnnounceMarkerMoved(); });
 	video_position_provider.AddMarkerMovedListener([=]{ AnnounceMarkerMoved(); });
diff --git a/src/auto4_base.cpp b/src/auto4_base.cpp
index 05a0bb96e..16eb35acd 100644
--- a/src/auto4_base.cpp
+++ b/src/auto4_base.cpp
@@ -373,9 +373,11 @@ namespace Automation4 {
 
 	LocalScriptManager::LocalScriptManager(agi::Context *c)
 	: context(c)
+	, connections(agi::signal::make_vector({
+		c->subsController->AddFileSaveListener(&LocalScriptManager::OnSubtitlesSave, this),
+		c->subsController->AddFileOpenListener(&LocalScriptManager::Reload, this),
+	}))
 	{
-		slots.push_back(c->subsController->AddFileSaveListener(&LocalScriptManager::OnSubtitlesSave, this));
-		slots.push_back(c->subsController->AddFileOpenListener(&LocalScriptManager::Reload, this));
 	}
 
 	void LocalScriptManager::Reload()
diff --git a/src/auto4_base.h b/src/auto4_base.h
index 6b53bdb89..0faaacbc6 100644
--- a/src/auto4_base.h
+++ b/src/auto4_base.h
@@ -43,7 +43,6 @@
 #include "compat.h"
 
 #include <boost/filesystem/path.hpp>
-#include <deque>
 #include <memory>
 #include <vector>
 
@@ -215,8 +214,8 @@ namespace Automation4 {
 
 	/// Manager for scripts specified by a subtitle file
 	class LocalScriptManager final : public ScriptManager {
-		std::deque<agi::signal::Connection> slots;
 		agi::Context *context;
+		std::vector<agi::signal::Connection> connections;
 
 		void OnSubtitlesSave();
 	public:
diff --git a/src/auto4_lua.cpp b/src/auto4_lua.cpp
index 98960bea9..b8ef66615 100644
--- a/src/auto4_lua.cpp
+++ b/src/auto4_lua.cpp
@@ -39,15 +39,18 @@
 #include "ass_info.h"
 #include "ass_file.h"
 #include "ass_style.h"
+#include "async_video_provider.h"
 #include "auto4_lua_factory.h"
 #include "command/command.h"
 #include "compat.h"
 #include "include/aegisub/context.h"
+#include "include/aegisub/video_provider.h"
 #include "main.h"
 #include "options.h"
+#include "project.h"
 #include "selection_controller.h"
 #include "subs_controller.h"
-#include "video_context.h"
+#include "video_controller.h"
 #include "utils.h"
 
 #include <libaegisub/access.h>
@@ -169,7 +172,7 @@ namespace {
 		const agi::Context *c = get_context(L);
 		int ms = lua_tointeger(L, -1);
 		lua_pop(L, 1);
-		if (c && c->videoController->TimecodesLoaded())
+		if (c && c->project->Timecodes().IsLoaded())
 			push_value(L, c->videoController->FrameAtTime(ms, agi::vfr::START));
 		else
 			lua_pushnil(L);
@@ -182,7 +185,7 @@ namespace {
 		const agi::Context *c = get_context(L);
 		int frame = lua_tointeger(L, -1);
 		lua_pop(L, 1);
-		if (c && c->videoController->TimecodesLoaded())
+		if (c && c->project->Timecodes().IsLoaded())
 			push_value(L, c->videoController->TimeAtFrame(frame, agi::vfr::START));
 		else
 			lua_pushnil(L);
@@ -192,9 +195,10 @@ namespace {
 	int video_size(lua_State *L)
 	{
 		const agi::Context *c = get_context(L);
-		if (c && c->videoController->IsLoaded()) {
-			push_value(L, c->videoController->GetWidth());
-			push_value(L, c->videoController->GetHeight());
+		if (c && c->project->VideoProvider()) {
+			auto provider = c->project->VideoProvider();
+			push_value(L, provider->GetWidth());
+			push_value(L, provider->GetHeight());
 			push_value(L, c->videoController->GetAspectRatioValue());
 			push_value(L, (int)c->videoController->GetAspectRatioType());
 			return 4;
@@ -209,7 +213,7 @@ namespace {
 	{
 		const agi::Context *c = get_context(L);
 		if (c)
-			push_value(L, c->videoController->GetKeyFrames());
+			push_value(L, c->project->Keyframes());
 		else
 			lua_pushnil(L);
 		return 1;
diff --git a/src/base_grid.cpp b/src/base_grid.cpp
index a36902ffa..a5cc1240e 100644
--- a/src/base_grid.cpp
+++ b/src/base_grid.cpp
@@ -40,10 +40,11 @@
 #include "frame_main.h"
 #include "grid_column.h"
 #include "options.h"
+#include "project.h"
 #include "utils.h"
 #include "selection_controller.h"
 #include "subs_controller.h"
-#include "video_context.h"
+#include "video_controller.h"
 
 #include <libaegisub/util.h>
 
@@ -123,30 +124,32 @@ BaseGrid::BaseGrid(wxWindow* parent, agi::Context *context)
 	UpdateStyle();
 	OnHighlightVisibleChange(*OPT_GET("Subtitle/Grid/Highlight Subtitles in Frame"));
 
-	connections.push_back(context->ass->AddCommitListener(&BaseGrid::OnSubtitlesCommit, this));
-	connections.push_back(context->subsController->AddFileOpenListener(&BaseGrid::OnSubtitlesOpen, this));
-	connections.push_back(context->subsController->AddFileSaveListener(&BaseGrid::OnSubtitlesSave, this));
-
-	connections.push_back(context->selectionController->AddActiveLineListener(&BaseGrid::OnActiveLineChanged, this));
-	connections.push_back(context->selectionController->AddSelectionListener([&]{ Refresh(false); }));
-
-	connections.push_back(OPT_SUB("Subtitle/Grid/Font Face", &BaseGrid::UpdateStyle, this));
-	connections.push_back(OPT_SUB("Subtitle/Grid/Font Size", &BaseGrid::UpdateStyle, this));
-	connections.push_back(OPT_SUB("Colour/Subtitle Grid/Active Border", &BaseGrid::UpdateStyle, this));
-	connections.push_back(OPT_SUB("Colour/Subtitle Grid/Background/Background", &BaseGrid::UpdateStyle, this));
-	connections.push_back(OPT_SUB("Colour/Subtitle Grid/Background/Comment", &BaseGrid::UpdateStyle, this));
-	connections.push_back(OPT_SUB("Colour/Subtitle Grid/Background/Inframe", &BaseGrid::UpdateStyle, this));
-	connections.push_back(OPT_SUB("Colour/Subtitle Grid/Background/Selected Comment", &BaseGrid::UpdateStyle, this));
-	connections.push_back(OPT_SUB("Colour/Subtitle Grid/Background/Selection", &BaseGrid::UpdateStyle, this));
-	connections.push_back(OPT_SUB("Colour/Subtitle Grid/Collision", &BaseGrid::UpdateStyle, this));
-	connections.push_back(OPT_SUB("Colour/Subtitle Grid/Header", &BaseGrid::UpdateStyle, this));
-	connections.push_back(OPT_SUB("Colour/Subtitle Grid/Left Column", &BaseGrid::UpdateStyle, this));
-	connections.push_back(OPT_SUB("Colour/Subtitle Grid/Lines", &BaseGrid::UpdateStyle, this));
-	connections.push_back(OPT_SUB("Colour/Subtitle Grid/Selection", &BaseGrid::UpdateStyle, this));
-	connections.push_back(OPT_SUB("Colour/Subtitle Grid/Standard", &BaseGrid::UpdateStyle, this));
-
-	connections.push_back(OPT_SUB("Subtitle/Grid/Highlight Subtitles in Frame", &BaseGrid::OnHighlightVisibleChange, this));
-	connections.push_back(OPT_SUB("Subtitle/Grid/Hide Overrides", [&](agi::OptionValue const&) { Refresh(false); }));
+	connections = agi::signal::make_vector({
+		context->ass->AddCommitListener(&BaseGrid::OnSubtitlesCommit, this),
+		context->subsController->AddFileOpenListener(&BaseGrid::OnSubtitlesOpen, this),
+		context->subsController->AddFileSaveListener(&BaseGrid::OnSubtitlesSave, this),
+
+		context->selectionController->AddActiveLineListener(&BaseGrid::OnActiveLineChanged, this),
+		context->selectionController->AddSelectionListener([&]{ Refresh(false); }),
+
+		OPT_SUB("Subtitle/Grid/Font Face", &BaseGrid::UpdateStyle, this),
+		OPT_SUB("Subtitle/Grid/Font Size", &BaseGrid::UpdateStyle, this),
+		OPT_SUB("Colour/Subtitle Grid/Active Border", &BaseGrid::UpdateStyle, this),
+		OPT_SUB("Colour/Subtitle Grid/Background/Background", &BaseGrid::UpdateStyle, this),
+		OPT_SUB("Colour/Subtitle Grid/Background/Comment", &BaseGrid::UpdateStyle, this),
+		OPT_SUB("Colour/Subtitle Grid/Background/Inframe", &BaseGrid::UpdateStyle, this),
+		OPT_SUB("Colour/Subtitle Grid/Background/Selected Comment", &BaseGrid::UpdateStyle, this),
+		OPT_SUB("Colour/Subtitle Grid/Background/Selection", &BaseGrid::UpdateStyle, this),
+		OPT_SUB("Colour/Subtitle Grid/Collision", &BaseGrid::UpdateStyle, this),
+		OPT_SUB("Colour/Subtitle Grid/Header", &BaseGrid::UpdateStyle, this),
+		OPT_SUB("Colour/Subtitle Grid/Left Column", &BaseGrid::UpdateStyle, this),
+		OPT_SUB("Colour/Subtitle Grid/Lines", &BaseGrid::UpdateStyle, this),
+		OPT_SUB("Colour/Subtitle Grid/Selection", &BaseGrid::UpdateStyle, this),
+		OPT_SUB("Colour/Subtitle Grid/Standard", &BaseGrid::UpdateStyle, this),
+
+		OPT_SUB("Subtitle/Grid/Highlight Subtitles in Frame", &BaseGrid::OnHighlightVisibleChange, this),
+		OPT_SUB("Subtitle/Grid/Hide Overrides", [&](agi::OptionValue const&) { Refresh(false); }),
+	});
 
 	Bind(wxEVT_CONTEXT_MENU, &BaseGrid::OnContextMenu, this);
 }
@@ -634,10 +637,10 @@ AssDialogue *BaseGrid::GetDialogue(int n) const {
 }
 
 bool BaseGrid::IsDisplayed(const AssDialogue *line) const {
-	if (!context->videoController->IsLoaded()) return false;
+	if (!context->project->VideoProvider()) return false;
 	int frame = context->videoController->GetFrameN();
-	return context->videoController->FrameAtTime(line->Start,agi::vfr::START) <= frame
-		&& context->videoController->FrameAtTime(line->End,agi::vfr::END) >= frame;
+	return context->project->Timecodes().FrameAtTime(line->Start, agi::vfr::START) <= frame
+		&& context->project->Timecodes().FrameAtTime(line->End, agi::vfr::END) >= frame;
 }
 
 void BaseGrid::OnCharHook(wxKeyEvent &event) {
diff --git a/src/command/app.cpp b/src/command/app.cpp
index 33b337093..d867140b7 100644
--- a/src/command/app.cpp
+++ b/src/command/app.cpp
@@ -36,7 +36,6 @@
 #include <libaegisub/log.h>
 #include <libaegisub/make_unique.h>
 
-#include "../audio_controller.h"
 #include "../compat.h"
 #include "../dialog_about.h"
 #include "../dialog_detached_video.h"
@@ -48,9 +47,9 @@
 #include "../libresrc/libresrc.h"
 #include "../main.h"
 #include "../options.h"
+#include "../project.h"
 #include "../preferences.h"
 #include "../utils.h"
-#include "../video_context.h"
 
 namespace {
 	using cmd::Command;
@@ -79,7 +78,7 @@ struct app_display_audio_subs final : public Command {
 	}
 
 	bool Validate(const agi::Context *c) override {
-		return c->audioController->IsAudioOpen();
+		return !!c->project->AudioProvider();
 	}
 
 	bool IsActive(const agi::Context *c) override {
@@ -99,7 +98,7 @@ struct app_display_full final : public Command {
 	}
 
 	bool Validate(const agi::Context *c) override {
-		return c->audioController->IsAudioOpen() && c->videoController->IsLoaded() && !c->dialog->Get<DialogDetachedVideo>();
+		return c->project->AudioProvider() && c->project->VideoProvider() && !c->dialog->Get<DialogDetachedVideo>();
 	}
 
 	bool IsActive(const agi::Context *c) override {
@@ -115,7 +114,7 @@ struct app_display_subs final : public Command {
 	CMD_TYPE(COMMAND_VALIDATE | COMMAND_RADIO)
 
 	void operator()(agi::Context *c) override {
-		c->frame->SetDisplayMode(0,0);
+		c->frame->SetDisplayMode(0, 0);
 	}
 
 	bool IsActive(const agi::Context *c) override {
@@ -131,11 +130,11 @@ struct app_display_video_subs final : public Command {
 	CMD_TYPE(COMMAND_VALIDATE | COMMAND_RADIO)
 
 	void operator()(agi::Context *c) override {
-		c->frame->SetDisplayMode(1,0);
+		c->frame->SetDisplayMode(1, 0);
 	}
 
 	bool Validate(const agi::Context *c) override {
-		return c->videoController->IsLoaded() && !c->dialog->Get<DialogDetachedVideo>();
+		return c->project->VideoProvider() && !c->dialog->Get<DialogDetachedVideo>();
 	}
 
 	bool IsActive(const agi::Context *c) override {
diff --git a/src/command/audio.cpp b/src/command/audio.cpp
index 84b346fb7..45ee75eac 100644
--- a/src/command/audio.cpp
+++ b/src/command/audio.cpp
@@ -37,15 +37,18 @@
 #include "../audio_karaoke.h"
 #include "../audio_timing.h"
 #include "../compat.h"
+#include "../include/aegisub/audio_provider.h"
 #include "../include/aegisub/context.h"
 #include "../libresrc/libresrc.h"
 #include "../options.h"
+#include "../project.h"
 #include "../selection_controller.h"
 #include "../utils.h"
-#include "../video_context.h"
+#include "../video_controller.h"
 
 #include <libaegisub/make_unique.h>
 #include <libaegisub/fs.h>
+#include <libaegisub/io.h>
 
 #include <wx/msgdlg.h>
 
@@ -55,7 +58,7 @@ namespace {
 	struct validate_audio_open : public Command {
 		CMD_TYPE(COMMAND_VALIDATE)
 		bool Validate(const agi::Context *c) override {
-			return c->audioController->IsAudioOpen();
+			return !!c->project->AudioProvider();
 		}
 	};
 
@@ -67,35 +70,11 @@ struct audio_close final : public validate_audio_open {
 	STR_HELP("Close the currently open audio file")
 
 	void operator()(agi::Context *c) override {
-		c->audioController->CloseAudio();
+		c->project->CloseAudio();
 	}
 };
 
-namespace {
-	struct audio_open_from_file : public Command {
-	protected:
-		void do_open(agi::Context *c, agi::fs::path const& filename) {
-			try {
-				c->audioController->OpenAudio(filename);
-			}
-			catch (agi::UserCancelException const&) {}
-			catch (agi::fs::FileNotFound const& e) {
-				wxMessageBox(_("The audio file was not found: ") + to_wx(e.GetChainedMessage()), "Error loading file", wxOK | wxICON_ERROR | wxCENTER, c->parent);
-			}
-			catch (agi::AudioDataNotFoundError const& e) {
-				wxMessageBox(_("None of the available audio providers recognised the selected file as containing audio data.\n\nThe following providers were tried:\n") + to_wx(e.GetChainedMessage()), "Error loading file", wxOK | wxICON_ERROR | wxCENTER, c->parent);
-			}
-			catch (agi::AudioProviderOpenError const& e) {
-				wxMessageBox(_("None of the available audio providers have a codec available to handle the selected file.\n\nThe following providers were tried:\n") + to_wx(e.GetChainedMessage()), "Error loading file", wxOK | wxICON_ERROR | wxCENTER, c->parent);
-			}
-			catch (agi::Exception const& e) {
-				wxMessageBox(to_wx(e.GetChainedMessage()), "Error loading file", wxOK | wxICON_ERROR | wxCENTER, c->parent);
-			}
-		}
-	};
-};
-
-struct audio_open final : public audio_open_from_file {
+struct audio_open final : public Command {
 	CMD_NAME("audio/open")
 	CMD_ICON(open_audio_menu)
 	STR_MENU("&Open Audio File...")
@@ -107,9 +86,8 @@ struct audio_open final : public audio_open_from_file {
 					+ _("Video Formats") + " (*.asf,*.avi,*.avs,*.d2v,*.m2ts,*.m4v,*.mkv,*.mov,*.mp4,*.mpeg,*.mpg,*.ogm,*.webm,*.wmv,*.ts)|*.asf;*.avi;*.avs;*.d2v;*.m2ts;*.m4v;*.mkv;*.mov;*.mp4;*.mpeg;*.mpg;*.ogm;*.webm;*.wmv;*.ts|"
 					+ _("All Files") + " (*.*)|*.*";
 		auto filename = OpenFileSelector(_("Open Audio File"), "Path/Last/Audio", "", "", str, c->parent);
-		if (filename.empty()) return;
-
-		do_open(c, filename);
+		if (!filename.empty())
+			c->project->LoadAudio(filename);
 	}
 };
 
@@ -120,12 +98,7 @@ struct audio_open_blank final : public Command {
 	STR_HELP("Open a 150 minutes blank audio clip, for debugging")
 
 	void operator()(agi::Context *c) override {
-		try {
-			c->audioController->OpenAudio("dummy-audio:silence?sr=44100&bd=16&ch=1&ln=396900000");
-		}
-		catch (agi::Exception const& e) {
-			wxMessageBox(to_wx(e.GetChainedMessage()), "Error loading file", wxOK | wxICON_ERROR | wxCENTER, c->parent);
-		}
+		c->project->LoadAudio("dummy-audio:silence?sr=44100&bd=16&ch=1&ln=396900000");
 	}
 };
 
@@ -136,16 +109,11 @@ struct audio_open_noise final : public Command {
 	STR_HELP("Open a 150 minutes noise-filled audio clip, for debugging")
 
 	void operator()(agi::Context *c) override {
-		try {
-			c->audioController->OpenAudio("dummy-audio:noise?sr=44100&bd=16&ch=1&ln=396900000");
-		}
-		catch (agi::Exception const& e) {
-			wxMessageBox(to_wx(e.GetChainedMessage()), "Error loading file", wxOK | wxICON_ERROR | wxCENTER, c->parent);
-		}
+		c->project->LoadAudio("dummy-audio:noise?sr=44100&bd=16&ch=1&ln=396900000");
 	}
 };
 
-struct audio_open_video final : public audio_open_from_file {
+struct audio_open_video final : public Command {
 	CMD_NAME("audio/open/video")
 	CMD_ICON(open_audio_from_video_menu)
 	STR_MENU("Open Audio from &Video")
@@ -154,11 +122,11 @@ struct audio_open_video final : public audio_open_from_file {
 	CMD_TYPE(COMMAND_VALIDATE)
 
 	bool Validate(const agi::Context *c) override {
-		return c->videoController->IsLoaded();
+		return !c->project->VideoName().empty();
 	}
 
 	void operator()(agi::Context *c) override {
-		do_open(c, c->videoController->GetVideoName());
+		c->project->LoadAudio(c->project->VideoName());
 	}
 };
 
@@ -194,6 +162,29 @@ struct audio_view_waveform final : public Command {
 	}
 };
 
+class writer {
+	agi::io::Save outfile;
+	std::ostream& out;
+
+public:
+	writer(agi::fs::path const& filename) : outfile(filename, true), out(outfile.Get()) { }
+
+	template<int N>
+	void write(const char(&str)[N]) {
+		out.write(str, N - 1);
+	}
+
+	void write(std::vector<char> const& data) {
+		out.write(data.data(), data.size());
+	}
+
+	template<typename Dest, typename Src>
+	void write(Src v) {
+		auto converted = static_cast<Dest>(v);
+		out.write(reinterpret_cast<char *>(&converted), sizeof(Dest));
+	}
+};
+
 struct audio_save_clip final : public Command {
 	CMD_NAME("audio/save/clip")
 	STR_MENU("Create audio clip")
@@ -202,22 +193,55 @@ struct audio_save_clip final : public Command {
 	CMD_TYPE(COMMAND_VALIDATE)
 
 	bool Validate(const agi::Context *c) override {
-		return c->audioController->IsAudioOpen() && !c->selectionController->GetSelectedSet().empty();
+		return c->project->AudioProvider() && !c->selectionController->GetSelectedSet().empty();
 	}
 
 	void operator()(agi::Context *c) override {
 		auto const& sel = c->selectionController->GetSelectedSet();
 		if (sel.empty()) return;
 
+		auto filename = SaveFileSelector(_("Save audio clip"), "", "", "wav", "", c->parent);
+		if (filename.empty()) return;
+
 		AssTime start = INT_MAX, end = 0;
 		for (auto line : sel) {
 			start = std::min(start, line->Start);
 			end = std::max(end, line->End);
 		}
 
-		c->audioController->SaveClip(
-			SaveFileSelector(_("Save audio clip"), "", "", "wav", "", c->parent),
-			TimeRange(start, end));
+		auto provider = c->project->AudioProvider();
+
+		auto start_sample = (start * provider->GetSampleRate() + 999) / 1000;
+		auto end_sample = (end * provider->GetSampleRate() + 999) / 1000;
+		if (start_sample >= provider->GetNumSamples() || start_sample <= end_sample) return;
+
+		size_t bytes_per_sample = provider->GetBytesPerSample() * provider->GetChannels();
+		size_t bufsize = (end_sample - start_sample) * bytes_per_sample;
+
+		writer out{filename};
+		out.write("RIFF");
+		out.write<int32_t>(bufsize + 36);
+
+		out.write("WAVEfmt ");
+		out.write<int32_t>(16); // Size of chunk
+		out.write<int16_t>(1);  // compression format (PCM)
+		out.write<int16_t>(provider->GetChannels());
+		out.write<int32_t>(provider->GetSampleRate());
+		out.write<int32_t>(provider->GetSampleRate() * provider->GetChannels() * provider->GetBytesPerSample());
+		out.write<int16_t>(provider->GetChannels() * provider->GetBytesPerSample());
+		out.write<int16_t>(provider->GetBytesPerSample() * 8);
+
+		out.write("data");
+		out.write<int32_t>(bufsize);
+
+		// samples per read
+		size_t spr = 65536 / bytes_per_sample;
+		std::vector<char> buf(bufsize);
+		for (int64_t i = start_sample; i < end_sample; i += spr) {
+			buf.resize(std::min<size_t>(spr, end_sample - i));
+			provider->GetAudio(&buf[0], i, buf.size());
+			out.write(buf);
+		}
 	}
 };
 
diff --git a/src/command/automation.cpp b/src/command/automation.cpp
index b939aa3eb..83d435bb0 100644
--- a/src/command/automation.cpp
+++ b/src/command/automation.cpp
@@ -40,7 +40,7 @@
 #include "../main.h"
 #include "../options.h"
 #include "../utils.h"
-#include "../video_context.h"
+#include "../video_controller.h"
 
 #include <libaegisub/make_unique.h>
 
diff --git a/src/command/edit.cpp b/src/command/edit.cpp
index fc2655e3a..5153921d5 100644
--- a/src/command/edit.cpp
+++ b/src/command/edit.cpp
@@ -43,13 +43,14 @@
 #include "../initial_line_state.h"
 #include "../libresrc/libresrc.h"
 #include "../options.h"
+#include "../project.h"
 #include "../search_replace_engine.h"
 #include "../selection_controller.h"
 #include "../subs_controller.h"
 #include "../subs_edit_ctrl.h"
 #include "../text_selection_controller.h"
 #include "../utils.h"
-#include "../video_context.h"
+#include "../video_controller.h"
 
 #include <libaegisub/address_of_adaptor.h>
 #include <libaegisub/of_type_adaptor.h>
@@ -83,7 +84,7 @@ struct validate_sel_nonempty : public Command {
 struct validate_video_and_sel_nonempty : public Command {
 	CMD_TYPE(COMMAND_VALIDATE)
 	bool Validate(const agi::Context *c) override {
-		return c->videoController->IsLoaded() && !c->selectionController->GetSelectedSet().empty();
+		return c->project->VideoProvider() && !c->selectionController->GetSelectedSet().empty();
 	}
 };
 
diff --git a/src/command/keyframe.cpp b/src/command/keyframe.cpp
index 5cf62fc5c..c1c16f6a1 100644
--- a/src/command/keyframe.cpp
+++ b/src/command/keyframe.cpp
@@ -34,9 +34,10 @@
 #include "../include/aegisub/context.h"
 #include "../libresrc/libresrc.h"
 #include "../options.h"
+#include "../project.h"
 #include "../utils.h"
-#include "../video_context.h"
 
+#include <libaegisub/keyframe.h>
 #include <libaegisub/make_unique.h>
 
 namespace {
@@ -51,11 +52,11 @@ struct keyframe_close final : public Command {
 	CMD_TYPE(COMMAND_VALIDATE)
 
 	bool Validate(const agi::Context *c) override {
-		return c->videoController->OverKeyFramesLoaded();
+		return c->project->CanCloseKeyframes();
 	}
 
 	void operator()(agi::Context *c) override {
-		c->videoController->CloseKeyframes();
+		c->project->CloseKeyframes();
 	}
 };
 
@@ -74,7 +75,7 @@ struct keyframe_open final : public Command {
 			c->parent);
 
 		if (!filename.empty())
-			c->videoController->LoadKeyframes(filename);
+			c->project->LoadKeyframes(filename);
 	}
 };
 
@@ -87,13 +88,15 @@ struct keyframe_save final : public Command {
 	CMD_TYPE(COMMAND_VALIDATE)
 
 	bool Validate(const agi::Context *c) override {
-		return c->videoController->KeyFramesLoaded();
+		return !c->project->Keyframes().empty();
 	}
 
 	void operator()(agi::Context *c) override {
 		auto filename = SaveFileSelector(_("Save keyframes file"), "Path/Last/Keyframes", "", "*.key.txt", "Text files (*.txt)|*.txt", c->parent);
-		if (!filename.empty())
-			c->videoController->SaveKeyframes(filename);
+		if (filename.empty()) return;
+
+		agi::keyframe::Save(filename, c->project->Keyframes());
+		config::mru->Add("Keyframes", filename);
 	}
 };
 }
diff --git a/src/command/recent.cpp b/src/command/recent.cpp
index 1a3133b11..21e954d8a 100644
--- a/src/command/recent.cpp
+++ b/src/command/recent.cpp
@@ -29,19 +29,15 @@
 
 #include "command.h"
 
-#include "../audio_controller.h"
 #include "../compat.h"
 #include "../include/aegisub/context.h"
 #include "../libresrc/libresrc.h"
 #include "../options.h"
+#include "../project.h"
 #include "../subs_controller.h"
-#include "../video_context.h"
 
 #include <libaegisub/make_unique.h>
 
-#include <wx/event.h>
-#include <wx/msgdlg.h>
-
 namespace {
 	using cmd::Command;
 
@@ -58,13 +54,7 @@ struct recent_audio_entry : public Command {
 	STR_HELP("Open recent audio")
 
 	void operator()(agi::Context *c, int id) {
-		try {
-			c->audioController->OpenAudio(config::mru->GetEntry("Audio", id));
-		}
-		catch (agi::UserCancelException const&) { }
-		catch (agi::Exception const& e) {
-			wxMessageBox(to_wx(e.GetChainedMessage()), "Error loading file", wxOK | wxICON_ERROR | wxCENTER, c->parent);
-		}
+		c->project->LoadAudio(config::mru->GetEntry("Audio", id));
 	}
 };
 
@@ -75,7 +65,7 @@ struct recent_keyframes_entry : public Command {
 	STR_HELP("Open recent keyframes")
 
 	void operator()(agi::Context *c, int id) {
-		c->videoController->LoadKeyframes(config::mru->GetEntry("Keyframes", id));
+		c->project->LoadKeyframes(config::mru->GetEntry("Keyframes", id));
 	}
 };
 
@@ -87,7 +77,7 @@ struct recent_subtitle_entry : public Command {
 
 	void operator()(agi::Context *c, int id) {
 		if (c->subsController->TryToClose() == wxCANCEL) return;
-		c->subsController->Load(config::mru->GetEntry("Subtitle", id));
+		c->project->LoadSubtitles(config::mru->GetEntry("Subtitle", id));
 	}
 };
 
@@ -98,7 +88,7 @@ struct recent_timecodes_entry : public Command {
 	STR_HELP("Open recent timecodes")
 
 	void operator()(agi::Context *c, int id) {
-		c->videoController->LoadTimecodes(config::mru->GetEntry("Timecodes", id));
+		c->project->LoadTimecodes(config::mru->GetEntry("Timecodes", id));
 	}
 };
 
@@ -109,7 +99,7 @@ struct recent_video_entry : public Command {
 	STR_HELP("Open recent videos")
 
 	void operator()(agi::Context *c, int id) {
-		c->videoController->SetVideo(config::mru->GetEntry("Video", id));
+		c->project->LoadVideo(config::mru->GetEntry("Video", id));
 	}
 };
 
diff --git a/src/command/subtitle.cpp b/src/command/subtitle.cpp
index ea9119936..b20cda562 100644
--- a/src/command/subtitle.cpp
+++ b/src/command/subtitle.cpp
@@ -43,12 +43,13 @@
 #include "../include/aegisub/context.h"
 #include "../libresrc/libresrc.h"
 #include "../options.h"
+#include "../project.h"
 #include "../search_replace_engine.h"
 #include "../selection_controller.h"
 #include "../subs_controller.h"
 #include "../subtitle_format.h"
 #include "../utils.h"
-#include "../video_context.h"
+#include "../video_controller.h"
 
 #include <libaegisub/address_of_adaptor.h>
 #include <libaegisub/charset_conv.h>
@@ -71,7 +72,7 @@ struct validate_nonempty_selection : public Command {
 struct validate_nonempty_selection_video_loaded : public Command {
 	CMD_TYPE(COMMAND_VALIDATE)
 	bool Validate(const agi::Context *c) override {
-		return c->videoController->IsLoaded() && !c->selectionController->GetSelectedSet().empty();
+		return c->project->VideoProvider() && !c->selectionController->GetSelectedSet().empty();
 	}
 };
 
@@ -227,7 +228,7 @@ struct subtitle_new final : public Command {
 
 	void operator()(agi::Context *c) override {
 		if (c->subsController->TryToClose() != wxCANCEL)
-			c->subsController->Close();
+			c->project->CloseSubtitles();
 	}
 };
 
@@ -242,7 +243,7 @@ struct subtitle_open final : public Command {
 		if (c->subsController->TryToClose() == wxCANCEL) return;
 		auto filename = OpenFileSelector(_("Open subtitles file"), "Path/Last/Subtitles", "","", SubtitleFormat::GetWildcards(0), c->parent);
 		if (!filename.empty())
-			c->subsController->Load(filename);
+			c->project->LoadSubtitles(filename);
 	}
 };
 
@@ -256,7 +257,7 @@ struct subtitle_open_autosave final : public Command {
 		if (c->subsController->TryToClose() == wxCANCEL) return;
 		DialogAutosave dialog(c->parent);
 		if (dialog.ShowModal() == wxID_OK)
-			c->subsController->Load(dialog.ChosenFile());
+			c->project->LoadSubtitles(dialog.ChosenFile());
 	}
 };
 
@@ -276,7 +277,7 @@ struct subtitle_open_charset final : public Command {
 		wxString charset = wxGetSingleChoice(_("Choose charset code:"), _("Charset"), agi::charset::GetEncodingsList<wxArrayString>(), c->parent, -1, -1, true, 250, 200);
 		if (charset.empty()) return;
 
-		c->subsController->Load(filename, from_wx(charset));
+		c->project->LoadSubtitles(filename, from_wx(charset));
 	}
 };
 
@@ -289,11 +290,11 @@ struct subtitle_open_video final : public Command {
 
 	void operator()(agi::Context *c) override {
 		if (c->subsController->TryToClose() == wxCANCEL) return;
-		c->subsController->Load(c->videoController->GetVideoName(), "binary");
+		c->subsController->Load(c->project->VideoName(), "binary");
 	}
 
 	bool Validate(const agi::Context *c) override {
-		return c->videoController->IsLoaded() && c->videoController->HasSubtitles();
+		return c->project->CanLoadSubtitlesFromVideo();
 	}
 };
 
@@ -384,7 +385,6 @@ struct subtitle_select_visible final : public Command {
 	CMD_TYPE(COMMAND_VALIDATE)
 
 	void operator()(agi::Context *c) override {
-		if (!c->videoController->IsLoaded()) return;
 		c->videoController->Stop();
 
 		Selection new_selection;
@@ -404,7 +404,7 @@ struct subtitle_select_visible final : public Command {
 	}
 
 	bool Validate(const agi::Context *c) override {
-		return c->videoController->IsLoaded();
+		return !!c->project->VideoProvider();
 	}
 };
 
diff --git a/src/command/time.cpp b/src/command/time.cpp
index 8130532ed..9ebaa16ea 100644
--- a/src/command/time.cpp
+++ b/src/command/time.cpp
@@ -33,6 +33,7 @@
 
 #include "../ass_dialogue.h"
 #include "../ass_file.h"
+#include "../async_video_provider.h"
 #include "../audio_controller.h"
 #include "../audio_timing.h"
 #include "../dialog_manager.h"
@@ -40,38 +41,39 @@
 #include "../include/aegisub/context.h"
 #include "../libresrc/libresrc.h"
 #include "../options.h"
+#include "../project.h"
 #include "../selection_controller.h"
-#include "../video_context.h"
+#include "../video_controller.h"
 
 #include <libaegisub/make_unique.h>
 
 #include <algorithm>
 
 namespace {
-	using cmd::Command;
+using cmd::Command;
 
-	struct validate_video_loaded : public Command {
-		CMD_TYPE(COMMAND_VALIDATE)
-		bool Validate(const agi::Context *c) override {
-			return c->videoController->IsLoaded();
-		}
-	};
+struct validate_video_loaded : public Command {
+	CMD_TYPE(COMMAND_VALIDATE)
+	bool Validate(const agi::Context *c) override {
+		return !!c->project->VideoProvider();
+	}
+};
 
-	struct validate_adjoinable : public Command {
-		CMD_TYPE(COMMAND_VALIDATE)
-		bool Validate(const agi::Context *c) override {
-			auto sel = c->selectionController->GetSortedSelection();
-			if (sel.empty()) return false;
+struct validate_adjoinable : public Command {
+	CMD_TYPE(COMMAND_VALIDATE)
+	bool Validate(const agi::Context *c) override {
+		auto sel = c->selectionController->GetSortedSelection();
+		if (sel.empty()) return false;
 
-			for (size_t i = 1; i < sel.size(); ++i) {
-				if (sel[i]->Row != sel[i - 1]->Row + 1)
-					return false;
-			}
-			return true;
+		for (size_t i = 1; i < sel.size(); ++i) {
+			if (sel[i]->Row != sel[i - 1]->Row + 1)
+				return false;
 		}
-	};
+		return true;
+	}
+};
 
-static void adjoin_lines(agi::Context *c, bool set_start) {
+void adjoin_lines(agi::Context *c, bool set_start) {
 	auto const& sel = c->selectionController->GetSelectedSet();
 	AssDialogue *prev = nullptr;
 	size_t seen = 0;
@@ -129,8 +131,6 @@ struct time_frame_current final : public validate_video_loaded {
 	STR_HELP("Shift selection so that the active line starts at current frame")
 
 	void operator()(agi::Context *c) override {
-		if (!c->videoController->IsLoaded()) return;
-
 		auto const& sel = c->selectionController->GetSelectedSet();
 		const auto active_line = c->selectionController->GetActiveLine();
 
@@ -162,8 +162,7 @@ struct time_shift final : public Command {
 
 static void snap_subs_video(agi::Context *c, bool set_start) {
 	auto const& sel = c->selectionController->GetSelectedSet();
-
-	if (!c->videoController->IsLoaded() || sel.empty()) return;
+	if (sel.empty()) return;
 
 	int start = c->videoController->TimeAtFrame(c->videoController->GetFrameN(), agi::vfr::START);
 	int end = c->videoController->TimeAtFrame(c->videoController->GetFrameN(), agi::vfr::END);
@@ -198,19 +197,19 @@ struct time_snap_scene final : public validate_video_loaded {
 	STR_HELP("Set start and end of subtitles to the keyframes around current video frame")
 
 	void operator()(agi::Context *c) override {
-		VideoContext *con = c->videoController.get();
-		if (!con->IsLoaded() || !con->KeyFramesLoaded()) return;
+		auto const& keyframes = c->project->Keyframes();
+		if (keyframes.empty()) return;
 
+		VideoController *con = c->videoController.get();
 		int curFrame = con->GetFrameN();
 		int prev = 0;
 		int next = 0;
 
-		auto const& keyframes = con->GetKeyFrames();
 		if (curFrame < keyframes.front())
 			next = keyframes.front();
 		else if (curFrame >= keyframes.back()) {
 			prev = keyframes.back();
-			next = con->GetLength();
+			next = c->project->VideoProvider()->GetFrameCount();
 		}
 		else {
 			auto kf = std::lower_bound(keyframes.begin(), keyframes.end(), curFrame);
diff --git a/src/command/timecode.cpp b/src/command/timecode.cpp
index 69ab7ff5d..1a8bcfef1 100644
--- a/src/command/timecode.cpp
+++ b/src/command/timecode.cpp
@@ -31,14 +31,18 @@
 
 #include "command.h"
 
+#include "../async_video_provider.h"
+#include "../compat.h"
 #include "../include/aegisub/context.h"
 #include "../libresrc/libresrc.h"
 #include "../options.h"
+#include "../project.h"
 #include "../utils.h"
-#include "../video_context.h"
 
 #include <libaegisub/make_unique.h>
 
+#include <wx/msgdlg.h>
+
 namespace {
 	using cmd::Command;
 
@@ -51,11 +55,11 @@ struct timecode_close final : public Command {
 	CMD_TYPE(COMMAND_VALIDATE)
 
 	bool Validate(const agi::Context *c) override {
-		return c->videoController->OverTimecodesLoaded();
+		return c->project->CanCloseTimecodes();
 	}
 
 	void operator()(agi::Context *c) override {
-		c->videoController->CloseTimecodes();
+		c->project->CloseTimecodes();
 	}
 };
 
@@ -70,7 +74,7 @@ struct timecode_open final : public Command {
 		auto str = _("All Supported Formats") + " (*.txt)|*.txt|" + _("All Files") + " (*.*)|*.*";
 		auto filename = OpenFileSelector(_("Open Timecodes File"), "Path/Last/Timecodes", "", "", str, c->parent);
 		if (!filename.empty())
-			c->videoController->LoadTimecodes(filename);
+			c->project->LoadTimecodes(filename);
 	}
 };
 
@@ -83,14 +87,22 @@ struct timecode_save final : public Command {
 	CMD_TYPE(COMMAND_VALIDATE)
 
 	bool Validate(const agi::Context *c) override {
-		return c->videoController->TimecodesLoaded();
+		return c->project->Timecodes().IsLoaded();
 	}
 
 	void operator()(agi::Context *c) override {
 		auto str = _("All Supported Formats") + " (*.txt)|*.txt|" + _("All Files") + " (*.*)|*.*";
 		auto filename = SaveFileSelector(_("Save Timecodes File"), "Path/Last/Timecodes", "", "", str, c->parent);
-		if (!filename.empty())
-			c->videoController->SaveTimecodes(filename);
+		if (filename.empty()) return;
+
+		try {
+			auto provider = c->project->VideoProvider();
+			c->project->Timecodes().Save(filename, provider ? provider->GetFrameCount() : -1);
+			config::mru->Add("Timecodes", filename);
+		}
+		catch (agi::Exception const& err) {
+			wxMessageBox(to_wx(err.GetMessage()), "Error saving timecodes", wxOK | wxICON_ERROR | wxCENTER, c->parent);
+		}
 	}
 };
 }
diff --git a/src/command/tool.cpp b/src/command/tool.cpp
index 79dc0829e..5be716035 100644
--- a/src/command/tool.cpp
+++ b/src/command/tool.cpp
@@ -46,7 +46,7 @@
 #include "../libresrc/libresrc.h"
 #include "../options.h"
 #include "../resolution_resampler.h"
-#include "../video_context.h"
+#include "../video_controller.h"
 
 #include <libaegisub/fs.h>
 #include <libaegisub/path.h>
diff --git a/src/command/video.cpp b/src/command/video.cpp
index 260bcfe4b..48ff9d8a0 100644
--- a/src/command/video.cpp
+++ b/src/command/video.cpp
@@ -33,6 +33,7 @@
 
 #include "../ass_dialogue.h"
 #include "../ass_time.h"
+#include "../async_video_provider.h"
 #include "../compat.h"
 #include "../dialog_detached_video.h"
 #include "../dialog_dummy_video.h"
@@ -44,10 +45,11 @@
 #include "../include/aegisub/subtitles_provider.h"
 #include "../libresrc/libresrc.h"
 #include "../options.h"
+#include "../project.h"
 #include "../selection_controller.h"
 #include "../utils.h"
 #include "../video_box.h"
-#include "../video_context.h"
+#include "../video_controller.h"
 #include "../video_display.h"
 #include "../video_frame.h"
 #include "../video_slider.h"
@@ -61,7 +63,6 @@
 #include <boost/algorithm/string/predicate.hpp>
 #include <boost/algorithm/string/split.hpp>
 #include <boost/format.hpp>
-
 #include <wx/clipbrd.h>
 #include <wx/msgdlg.h>
 #include <wx/textdlg.h>
@@ -72,14 +73,14 @@ namespace {
 struct validator_video_loaded : public Command {
 	CMD_TYPE(COMMAND_VALIDATE)
 	bool Validate(const agi::Context *c) override {
-		return c->videoController->IsLoaded();
+		return !!c->project->VideoProvider();
 	}
 };
 
 struct validator_video_attached : public Command {
 	CMD_TYPE(COMMAND_VALIDATE)
 	bool Validate(const agi::Context *c) override {
-		return c->videoController->IsLoaded() && !c->dialog->Get<DialogDetachedVideo>();
+		return !!c->project->VideoProvider() && !c->dialog->Get<DialogDetachedVideo>();
 	}
 };
 
@@ -206,7 +207,7 @@ struct video_close final : public validator_video_loaded {
 	STR_HELP("Close the currently open video file")
 
 	void operator()(agi::Context *c) override {
-		c->videoController->SetVideo("");
+		c->project->CloseVideo();
 	}
 };
 
@@ -291,6 +292,10 @@ struct video_focus_seek final : public validator_video_loaded {
 	}
 };
 
+wxImage get_image(agi::Context *c, bool raw) {
+	return GetImage(*c->project->VideoProvider()->GetFrame(c->videoController->GetFrameN(), raw));
+}
+
 struct video_frame_copy final : public validator_video_loaded {
 	CMD_NAME("video/frame/copy")
 	STR_MENU("Copy image to Clipboard")
@@ -298,7 +303,7 @@ struct video_frame_copy final : public validator_video_loaded {
 	STR_HELP("Copy the currently displayed frame to the clipboard")
 
 	void operator()(agi::Context *c) override {
-		SetClipboard(wxBitmap(GetImage(*c->videoController->GetFrame(c->videoController->GetFrameN())), 24));
+		SetClipboard(wxBitmap(get_image(c, false), 24));
 	}
 };
 
@@ -309,7 +314,7 @@ struct video_frame_copy_raw final : public validator_video_loaded {
 	STR_HELP("Copy the currently displayed frame to the clipboard, without the subtitles")
 
 	void operator()(agi::Context *c) override {
-		SetClipboard(wxBitmap(GetImage(*c->videoController->GetFrame(c->videoController->GetFrameN(), true)), 24));
+		SetClipboard(wxBitmap(get_image(c, true), 24));
 	}
 };
 
@@ -360,10 +365,10 @@ struct video_frame_next_keyframe final : public validator_video_loaded {
 	STR_HELP("Seek to the next keyframe")
 
 	void operator()(agi::Context *c) override {
-		auto const& kf = c->videoController->GetKeyFrames();
+		auto const& kf = c->project->Keyframes();
 		auto pos = lower_bound(kf.begin(), kf.end(), c->videoController->GetFrameN() + 1);
 
-		c->videoController->JumpToFrame(pos == kf.end() ? c->videoController->GetLength() - 1 : *pos);
+		c->videoController->JumpToFrame(pos == kf.end() ? c->project->VideoProvider()->GetFrameCount() - 1 : *pos);
 	}
 };
 
@@ -427,7 +432,7 @@ struct video_frame_prev_keyframe final : public validator_video_loaded {
 	STR_HELP("Seek to the previous keyframe")
 
 	void operator()(agi::Context *c) override {
-		auto const& kf = c->videoController->GetKeyFrames();
+		auto const& kf = c->project->Keyframes();
 		if (kf.empty()) {
 			c->videoController->JumpToFrame(0);
 			return;
@@ -459,7 +464,7 @@ static void save_snapshot(agi::Context *c, bool raw) {
 	auto option = OPT_GET("Path/Screenshot")->GetString();
 	agi::fs::path basepath;
 
-	auto videoname = c->videoController->GetVideoName();
+	auto videoname = c->project->VideoName();
 	bool is_dummy = boost::starts_with(videoname.string(), "?dummy");
 
 	// Is it a path specifier and not an actual fixed path?
@@ -490,7 +495,7 @@ static void save_snapshot(agi::Context *c, bool raw) {
 		path = str(boost::format("%s_%03d_%d.png") % basepath.string() % session_shot_count++ % c->videoController->GetFrameN());
 	} while (agi::fs::FileExists(path));
 
-	GetImage(*c->videoController->GetFrame(c->videoController->GetFrameN(), raw)).SaveFile(to_wx(path), wxBITMAP_TYPE_PNG);
+	get_image(c, raw).SaveFile(to_wx(path), wxBITMAP_TYPE_PNG);
 }
 
 struct video_frame_save final : public validator_video_loaded {
@@ -524,10 +529,8 @@ struct video_jump final : public validator_video_loaded {
 
 	void operator()(agi::Context *c) override {
 		c->videoController->Stop();
-		if (c->videoController->IsLoaded()) {
-			DialogJumpTo(c).ShowModal();
-			c->videoSlider->SetFocus();
-		}
+		DialogJumpTo(c).ShowModal();
+		c->videoSlider->SetFocus();
 	}
 };
 
@@ -539,9 +542,8 @@ struct video_jump_end final : public validator_video_loaded {
 	STR_HELP("Jump the video to the end frame of current subtitle")
 
 	void operator()(agi::Context *c) override {
-		if (AssDialogue *active_line = c->selectionController->GetActiveLine()) {
+		if (auto active_line = c->selectionController->GetActiveLine())
 			c->videoController->JumpToTime(active_line->End, agi::vfr::END);
-		}
 	}
 };
 
@@ -553,7 +555,7 @@ struct video_jump_start final : public validator_video_loaded {
 	STR_HELP("Jump the video to the start frame of current subtitle")
 
 	void operator()(agi::Context *c) override {
-		if (AssDialogue *active_line = c->selectionController->GetActiveLine())
+		if (auto active_line = c->selectionController->GetActiveLine())
 			c->videoController->JumpToTime(active_line->Start);
 	}
 };
@@ -570,7 +572,7 @@ struct video_open final : public Command {
 		         + _("All Files") + " (*.*)|*.*";
 		auto filename = OpenFileSelector(_("Open video file"), "Path/Last/Video", "", "", str, c->parent);
 		if (!filename.empty())
-			c->videoController->SetVideo(filename);
+			c->project->LoadVideo(filename);
 	}
 };
 
@@ -584,7 +586,7 @@ struct video_open_dummy final : public Command {
 	void operator()(agi::Context *c) override {
 		std::string fn = DialogDummyVideo::CreateDummyVideo(c->parent);
 		if (!fn.empty())
-			c->videoController->SetVideo(fn);
+			c->project->LoadVideo(fn);
 	}
 };
 
diff --git a/src/command/vis_tool.cpp b/src/command/vis_tool.cpp
index a4bbc0756..0cfd308b1 100644
--- a/src/command/vis_tool.cpp
+++ b/src/command/vis_tool.cpp
@@ -18,8 +18,9 @@
 
 #include "../include/aegisub/context.h"
 #include "../libresrc/libresrc.h"
+#include "../project.h"
 #include "../video_box.h"
-#include "../video_context.h"
+#include "../video_controller.h"
 #include "../video_display.h"
 #include "../visual_tool_clip.h"
 #include "../visual_tool_cross.h"
@@ -39,7 +40,7 @@ namespace {
 		CMD_TYPE(COMMAND_VALIDATE | COMMAND_RADIO)
 
 		bool Validate(const agi::Context *c) override {
-			return c->videoController->IsLoaded();
+			return !!c->project->VideoProvider();
 		}
 
 		bool IsActive(const agi::Context *c) override {
diff --git a/src/context.cpp b/src/context.cpp
index c742ee3cb..8923d0884 100644
--- a/src/context.cpp
+++ b/src/context.cpp
@@ -21,11 +21,12 @@
 #include "auto4_base.h"
 #include "dialog_manager.h"
 #include "initial_line_state.h"
+#include "project.h"
 #include "search_replace_engine.h"
 #include "selection_controller.h"
 #include "subs_controller.h"
 #include "text_selection_controller.h"
-#include "video_context.h"
+#include "video_controller.h"
 
 #include <libaegisub/make_unique.h>
 
@@ -34,10 +35,11 @@ Context::Context()
 : ass(make_unique<AssFile>())
 , textSelectionController(make_unique<TextSelectionController>())
 , subsController(make_unique<SubsController>(this))
+, project(make_unique<Project>(this))
 , local_scripts(make_unique<Automation4::LocalScriptManager>(this))
-, videoController(make_unique<VideoContext>(this))
-, audioController(make_unique<AudioController>(this))
 , selectionController(make_unique<SelectionController>(this))
+, videoController(make_unique<VideoController>(this))
+, audioController(make_unique<AudioController>(this))
 , initialLineState(make_unique<InitialLineState>(this))
 , search(make_unique<SearchReplaceEngine>(this))
 , dialog(make_unique<DialogManager>())
diff --git a/src/dialog_detached_video.cpp b/src/dialog_detached_video.cpp
index a181a021e..727eca4fd 100644
--- a/src/dialog_detached_video.cpp
+++ b/src/dialog_detached_video.cpp
@@ -38,9 +38,10 @@
 #include "include/aegisub/hotkey.h"
 #include "options.h"
 #include "persist_location.h"
+#include "project.h"
 #include "utils.h"
 #include "video_box.h"
-#include "video_context.h"
+#include "video_controller.h"
 #include "video_display.h"
 
 #include <libaegisub/make_unique.h>
@@ -55,12 +56,12 @@ DialogDetachedVideo::DialogDetachedVideo(agi::Context *context)
 , context(context)
 , old_display(context->videoDisplay)
 , old_slider(context->videoSlider)
-, video_open(context->videoController->AddVideoOpenListener(&DialogDetachedVideo::OnVideoOpen, this))
+, video_open(context->project->AddVideoProviderListener(&DialogDetachedVideo::OnVideoOpen, this))
 {
 	// Set obscure stuff
 	SetExtraStyle((GetExtraStyle() & ~wxWS_EX_BLOCK_EVENTS) | wxWS_EX_PROCESS_UI_UPDATES);
 
-	SetTitle(wxString::Format(_("Video: %s"), context->videoController->GetVideoName().filename().wstring()));
+	SetTitle(wxString::Format(_("Video: %s"), context->project->VideoName().filename().wstring()));
 
 	old_display->Unload();
 
@@ -108,8 +109,7 @@ void DialogDetachedVideo::OnClose(wxCloseEvent &evt) {
 
 	OPT_SET("Video/Detached/Enabled")->SetBool(false);
 
-	if (context->videoController->IsLoaded())
-		context->videoController->JumpToFrame(context->videoController->GetFrameN());
+	context->videoController->JumpToFrame(context->videoController->GetFrameN());
 
 	evt.Skip();
 }
@@ -128,8 +128,8 @@ void DialogDetachedVideo::OnKeyDown(wxKeyEvent &evt) {
 }
 
 void DialogDetachedVideo::OnVideoOpen() {
-	if (context->videoController->IsLoaded())
-		SetTitle(wxString::Format(_("Video: %s"), context->videoController->GetVideoName().filename().wstring()));
+	if (context->project->VideoProvider())
+		SetTitle(wxString::Format(_("Video: %s"), context->project->VideoName().filename().wstring()));
 	else {
 		Close();
 		OPT_SET("Video/Detached/Enabled")->SetBool(true);
diff --git a/src/dialog_jumpto.cpp b/src/dialog_jumpto.cpp
index 91c9d5828..f86671cc5 100644
--- a/src/dialog_jumpto.cpp
+++ b/src/dialog_jumpto.cpp
@@ -34,12 +34,14 @@
 
 #include "dialog_jumpto.h"
 
-#include "include/aegisub/context.h"
 #include "ass_time.h"
+#include "async_video_provider.h"
+#include "include/aegisub/context.h"
 #include "libresrc/libresrc.h"
+#include "project.h"
 #include "timeedit_ctrl.h"
 #include "validators.h"
-#include "video_context.h"
+#include "video_controller.h"
 
 #include <wx/button.h>
 #include <wx/sizer.h>
@@ -57,7 +59,7 @@ DialogJumpTo::DialogJumpTo(agi::Context *c)
 	auto LabelTime = new wxStaticText(this, -1, _("Time: "));
 
 	JumpFrame = new wxTextCtrl(this,-1,"",wxDefaultPosition,wxSize(-1,-1),wxTE_PROCESS_ENTER, IntValidator((int)jumpframe));
-	JumpFrame->SetMaxLength(std::to_string(c->videoController->GetLength() - 1).size());
+	JumpFrame->SetMaxLength(std::to_string(c->project->VideoProvider()->GetFrameCount() - 1).size());
 	JumpTime = new TimeEdit(this, -1, c, AssTime(c->videoController->TimeAtFrame(jumpframe)).GetAssFormated(), wxSize(-1,-1));
 
 	auto TimesSizer = new wxGridSizer(2, 5, 5);
@@ -95,7 +97,7 @@ void DialogJumpTo::OnInitDialog(wxInitDialogEvent&) {
 
 void DialogJumpTo::OnOK(wxCommandEvent &) {
 	EndModal(0);
-	c->videoController->JumpToFrame(std::min<int>(jumpframe, c->videoController->GetLength() - 1));
+	c->videoController->JumpToFrame(jumpframe);
 }
 
 void DialogJumpTo::OnEditTime (wxCommandEvent &) {
diff --git a/src/dialog_properties.cpp b/src/dialog_properties.cpp
index 7518c2258..a59f00714 100644
--- a/src/dialog_properties.cpp
+++ b/src/dialog_properties.cpp
@@ -35,13 +35,14 @@
 #include "dialog_properties.h"
 
 #include "ass_file.h"
+#include "async_video_provider.h"
 #include "compat.h"
 #include "help_button.h"
 #include "include/aegisub/context.h"
 #include "libresrc/libresrc.h"
+#include "project.h"
 #include "resolution_resampler.h"
 #include "validators.h"
-#include "video_context.h"
 
 #include <algorithm>
 #include <boost/algorithm/string/predicate.hpp>
@@ -80,7 +81,7 @@ DialogProperties::DialogProperties(agi::Context *c)
 	ResY = new wxTextCtrl(this,-1,"",wxDefaultPosition,wxSize(50,20),0,IntValidator(c->ass->GetScriptInfoAsInt("PlayResY")));
 
 	wxButton *FromVideo = new wxButton(this,-1,_("From &video"));
-	if (!c->videoController->IsLoaded())
+	if (!c->project->VideoProvider())
 		FromVideo->Enable(false);
 	else
 		FromVideo->Bind(wxEVT_BUTTON, &DialogProperties::OnSetFromVideo, this);
@@ -172,6 +173,6 @@ int DialogProperties::SetInfoIfDifferent(std::string const& key, std::string con
 }
 
 void DialogProperties::OnSetFromVideo(wxCommandEvent &) {
-	ResX->SetValue(std::to_wstring(c->videoController->GetWidth()));
-	ResY->SetValue(std::to_wstring(c->videoController->GetHeight()));
+	ResX->SetValue(std::to_wstring(c->project->VideoProvider()->GetWidth()));
+	ResY->SetValue(std::to_wstring(c->project->VideoProvider()->GetHeight()));
 }
diff --git a/src/dialog_resample.cpp b/src/dialog_resample.cpp
index dc5049d38..2b022f488 100644
--- a/src/dialog_resample.cpp
+++ b/src/dialog_resample.cpp
@@ -20,14 +20,14 @@
 #include "dialog_resample.h"
 
 #include "ass_file.h"
+#include "async_video_provider.h"
 #include "compat.h"
 #include "help_button.h"
 #include "include/aegisub/context.h"
-#include "include/aegisub/video_provider.h"
 #include "libresrc/libresrc.h"
+#include "project.h"
 #include "resolution_resampler.h"
 #include "validators.h"
-#include "video_context.h"
 
 #include <boost/range/size.hpp>
 #include <wx/checkbox.h>
@@ -57,10 +57,10 @@ DialogResample::DialogResample(agi::Context *c, ResampleSettings &settings)
 	settings.source_y = script_h;
 	settings.source_matrix = script_mat = MatrixFromString(c->ass->GetScriptInfo("YCbCr Matrix"));
 
-	if (c->videoController->IsLoaded()) {
-		settings.dest_x = video_w = c->videoController->GetWidth();
-		settings.dest_y = video_h = c->videoController->GetHeight();
-		settings.dest_matrix = video_mat = MatrixFromString(c->videoController->GetProvider()->GetRealColorSpace());
+	if (auto provider = c->project->VideoProvider()) {
+		settings.dest_x = video_w = provider->GetWidth();
+		settings.dest_y = video_h = provider->GetHeight();
+		settings.dest_matrix = video_mat = MatrixFromString(provider->GetRealColorSpace());
 	}
 	else {
 		settings.dest_x = script_w;
@@ -186,7 +186,7 @@ void DialogResample::SetSourceFromScript(wxCommandEvent&) {
 }
 
 void DialogResample::UpdateButtons() {
-	from_video->Enable(c->videoController->IsLoaded() &&
+	from_video->Enable(c->project->VideoProvider() &&
 		(dest_x->GetValue() != video_w || dest_y->GetValue() != video_h));
 	from_script->Enable(source_x->GetValue() != script_w || source_y->GetValue() != script_h);
 
diff --git a/src/dialog_shift_times.cpp b/src/dialog_shift_times.cpp
index 116aa0a03..51edaa02d 100644
--- a/src/dialog_shift_times.cpp
+++ b/src/dialog_shift_times.cpp
@@ -29,10 +29,10 @@
 #include "help_button.h"
 #include "libresrc/libresrc.h"
 #include "options.h"
+#include "project.h"
 #include "selection_controller.h"
 #include "subs_controller.h"
 #include "timeedit_ctrl.h"
-#include "video_context.h"
 
 #include <libaegisub/fs.h>
 #include <libaegisub/io.h>
@@ -100,7 +100,7 @@ DialogShiftTimes::DialogShiftTimes(agi::Context *context)
 , context(context)
 , history_filename(config::path->Decode("?user/shift_history.json"))
 , history(agi::make_unique<json::Array>())
-, timecodes_loaded_slot(context->videoController->AddTimecodesListener(&DialogShiftTimes::OnTimecodesLoaded, this))
+, timecodes_loaded_slot(context->project->AddTimecodesListener(&DialogShiftTimes::OnTimecodesLoaded, this))
 , selected_set_changed_slot(context->selectionController->AddSelectionListener(&DialogShiftTimes::OnSelectedSetChanged, this))
 {
 	SetIcon(GETICON(shift_times_toolbutton_16));
@@ -138,7 +138,7 @@ DialogShiftTimes::DialogShiftTimes(agi::Context *context)
 	clear_button->Bind(wxEVT_BUTTON, &DialogShiftTimes::OnClear, this);
 
 	// Set initial control states
-	OnTimecodesLoaded(context->videoController->FPS());
+	OnTimecodesLoaded(context->project->Timecodes());
 	OnSelectedSetChanged();
 	LoadHistory();
 
diff --git a/src/dialog_styling_assistant.cpp b/src/dialog_styling_assistant.cpp
index 814ff0377..d8e7c8b2a 100644
--- a/src/dialog_styling_assistant.cpp
+++ b/src/dialog_styling_assistant.cpp
@@ -33,8 +33,9 @@
 #include "help_button.h"
 #include "libresrc/libresrc.h"
 #include "persist_location.h"
+#include "project.h"
 #include "selection_controller.h"
-#include "video_context.h"
+#include "video_controller.h"
 
 #include <libaegisub/make_unique.h>
 
@@ -111,11 +112,11 @@ DialogStyling::DialogStyling(agi::Context *context)
 		actions_box->AddStretchSpacer(1);
 
 		play_audio = new wxButton(this, -1, _("Play &Audio"));
-		play_audio->Enable(c->audioController->IsAudioOpen());
+		play_audio->Enable(!!c->project->AudioProvider());
 		actions_box->Add(play_audio, 0, wxLEFT | wxRIGHT | wxBOTTOM, 5);
 
 		play_video = new wxButton(this, -1, _("Play &Video"));
-		play_video->Enable(c->videoController->IsLoaded());
+		play_video->Enable(!!c->project->VideoProvider());
 		actions_box->Add(play_video, 0, wxBOTTOM | wxRIGHT, 5);
 
 		actions_box->AddStretchSpacer(1);
@@ -180,8 +181,8 @@ void DialogStyling::Commit(bool next) {
 void DialogStyling::OnActivate(wxActivateEvent &) {
 	if (!IsActive()) return;
 
-	play_video->Enable(c->videoController->IsLoaded());
-	play_audio->Enable(c->audioController->IsAudioOpen());
+	play_video->Enable(!!c->project->VideoProvider());
+	play_audio->Enable(!!c->project->AudioProvider());
 
 	style_list->Set(to_wx(c->ass->GetStyles()));
 
diff --git a/src/dialog_timing_processor.cpp b/src/dialog_timing_processor.cpp
index 4c3bb0022..b43773ab5 100644
--- a/src/dialog_timing_processor.cpp
+++ b/src/dialog_timing_processor.cpp
@@ -37,14 +37,15 @@
 #include "ass_dialogue.h"
 #include "ass_file.h"
 #include "ass_time.h"
+#include "async_video_provider.h"
 #include "compat.h"
 #include "help_button.h"
 #include "include/aegisub/context.h"
 #include "libresrc/libresrc.h"
 #include "options.h"
+#include "project.h"
 #include "selection_controller.h"
 #include "utils.h"
-#include "video_context.h"
 
 #include <libaegisub/address_of_adaptor.h>
 
@@ -118,24 +119,24 @@ DialogTimingProcessor::DialogTimingProcessor(agi::Context *c)
 	adjOverlap = OPT_GET("Tool/Timing Post Processor/Threshold/Adjacent Overlap")->GetInt();
 
 	// Styles box
-	wxSizer *LeftSizer = new wxStaticBoxSizer(wxVERTICAL,this,_("Apply to styles"));
+	auto LeftSizer = new wxStaticBoxSizer(wxVERTICAL,this,_("Apply to styles"));
 	StyleList = new wxCheckListBox(this, -1, wxDefaultPosition, wxSize(150,150), to_wx(c->ass->GetStyles()));
 	StyleList->SetToolTip(_("Select styles to process. Unchecked ones will be ignored."));
 
-	wxButton *all = new wxButton(this,-1,_("&All"));
+	auto all = new wxButton(this,-1,_("&All"));
 	all->SetToolTip(_("Select all styles"));
 
-	wxButton *none = new wxButton(this,-1,_("&None"));
+	auto none = new wxButton(this,-1,_("&None"));
 	none->SetToolTip(_("Deselect all styles"));
 
 	// Options box
-	wxStaticBoxSizer *optionsSizer = new wxStaticBoxSizer(wxHORIZONTAL,this,_("Options"));
+	auto optionsSizer = new wxStaticBoxSizer(wxHORIZONTAL,this,_("Options"));
 	onlySelection = new wxCheckBox(this,-1,_("Affect &selection only"));
 	onlySelection->SetValue(OPT_GET("Tool/Timing Post Processor/Only Selection")->GetBool());
 	optionsSizer->Add(onlySelection,1,wxALL,0);
 
 	// Lead-in/out box
-	wxStaticBoxSizer *LeadSizer = new wxStaticBoxSizer(wxHORIZONTAL, this, _("Lead-in/Lead-out"));
+	auto LeadSizer = new wxStaticBoxSizer(wxHORIZONTAL, this, _("Lead-in/Lead-out"));
 
 	hasLeadIn = make_check(LeadSizer, _("Add lead &in:"),
 		"Tool/Timing Post Processor/Enable/Lead/IN",
@@ -150,12 +151,12 @@ DialogTimingProcessor::DialogTimingProcessor(agi::Context *c)
 	LeadSizer->AddStretchSpacer(1);
 
 	// Adjacent subs sizer
-	wxStaticBoxSizer *AdjacentSizer = new wxStaticBoxSizer(wxHORIZONTAL, this, _("Make adjacent subtitles continuous"));
+	auto AdjacentSizer = new wxStaticBoxSizer(wxHORIZONTAL, this, _("Make adjacent subtitles continuous"));
 	adjsEnable = make_check(AdjacentSizer, _("&Enable"),
 		"Tool/Timing Post Processor/Enable/Adjacent",
 		_("Enable snapping of subtitles together if they are within a certain distance of each other"));
 
-	wxSizer *adjBoxes = new wxBoxSizer(wxHORIZONTAL);
+	auto adjBoxes = new wxBoxSizer(wxHORIZONTAL);
 	make_ctrl(this, adjBoxes, _("Max gap:"), &adjGap, adjsEnable,
 		_("Maximum difference between start and end time for two subtitles to be made continuous, in milliseconds"));
 	make_ctrl(this, adjBoxes, _("Max overlap:"), &adjOverlap, adjsEnable,
@@ -164,19 +165,19 @@ DialogTimingProcessor::DialogTimingProcessor(agi::Context *c)
 	adjacentBias = new wxSlider(this, -1, mid<int>(0, OPT_GET("Tool/Timing Post Processor/Adjacent Bias")->GetDouble() * 100, 100), 0, 100, wxDefaultPosition, wxSize(-1,20));
 	adjacentBias->SetToolTip(_("Sets how to set the adjoining of lines. If set totally to left, it will extend or shrink start time of the second line; if totally to right, it will extend or shrink the end time of the first line."));
 
-	wxSizer *adjSliderSizer = new wxBoxSizer(wxHORIZONTAL);
+	auto adjSliderSizer = new wxBoxSizer(wxHORIZONTAL);
 	adjSliderSizer->Add(new wxStaticText(this, -1, _("Bias: Start <- ")), wxSizerFlags().Center());
 	adjSliderSizer->Add(adjacentBias, wxSizerFlags(1).Center());
 	adjSliderSizer->Add(new wxStaticText(this, -1, _(" -> End")), wxSizerFlags().Center());
 
-	wxSizer *adjRightSizer = new wxBoxSizer(wxVERTICAL);
+	auto adjRightSizer = new wxBoxSizer(wxVERTICAL);
 	adjRightSizer->Add(adjBoxes, wxSizerFlags().Expand());
 	adjRightSizer->Add(adjSliderSizer, wxSizerFlags().Expand().Border(wxTOP));
 	AdjacentSizer->Add(adjRightSizer);
 
 	// Keyframes sizer
-	wxStaticBoxSizer *KeyframesSizer = new wxStaticBoxSizer(wxHORIZONTAL, this, _("Keyframe snapping"));
-	wxSizer *KeyframesFlexSizer = new wxFlexGridSizer(2,5,5,0);
+	auto KeyframesSizer = new wxStaticBoxSizer(wxHORIZONTAL, this, _("Keyframe snapping"));
+	auto KeyframesFlexSizer = new wxFlexGridSizer(2,5,5,0);
 
 	keysEnable = new wxCheckBox(this, -1, _("E&nable"));
 	keysEnable->SetToolTip(_("Enable snapping of subtitles to nearest keyframe, if distance is within threshold"));
@@ -184,7 +185,7 @@ DialogTimingProcessor::DialogTimingProcessor(agi::Context *c)
 	KeyframesFlexSizer->Add(keysEnable,0,wxRIGHT|wxEXPAND,10);
 
 	// Keyframes are only available if timecodes are loaded
-	bool keysAvailable = c->videoController->KeyFramesLoaded() && c->videoController->TimecodesLoaded();
+	bool keysAvailable = !c->project->Keyframes().empty() && c->project->Timecodes().IsLoaded();
 	if (!keysAvailable) {
 		keysEnable->SetValue(false);
 		keysEnable->Enable(false);
@@ -208,12 +209,12 @@ DialogTimingProcessor::DialogTimingProcessor(agi::Context *c)
 	KeyframesSizer->AddStretchSpacer(1);
 
 	// Button sizer
-	wxStdDialogButtonSizer *ButtonSizer = CreateStdDialogButtonSizer(wxOK | wxCANCEL | wxHELP);
+	auto ButtonSizer = CreateStdDialogButtonSizer(wxOK | wxCANCEL | wxHELP);
 	ApplyButton = ButtonSizer->GetAffirmativeButton();
 	ButtonSizer->GetHelpButton()->Bind(wxEVT_BUTTON, bind(&HelpButton::OpenPage, "Timing Processor"));
 
 	// Right Sizer
-	wxSizer *RightSizer = new wxBoxSizer(wxVERTICAL);
+	auto RightSizer = new wxBoxSizer(wxVERTICAL);
 	RightSizer->Add(optionsSizer,0,wxBOTTOM|wxEXPAND,5);
 	RightSizer->Add(LeadSizer,0,wxBOTTOM|wxEXPAND,5);
 	RightSizer->Add(AdjacentSizer,0,wxBOTTOM|wxEXPAND,5);
@@ -222,7 +223,7 @@ DialogTimingProcessor::DialogTimingProcessor(agi::Context *c)
 	RightSizer->Add(ButtonSizer,0,wxLEFT|wxRIGHT|wxBOTTOM|wxEXPAND,0);
 
 	// Style buttons sizer
-	wxSizer *StyleButtonsSizer = new wxBoxSizer(wxHORIZONTAL);
+	auto StyleButtonsSizer = new wxBoxSizer(wxHORIZONTAL);
 	StyleButtonsSizer->Add(all,1,0,0);
 	StyleButtonsSizer->Add(none,1,0,0);
 
@@ -231,12 +232,12 @@ DialogTimingProcessor::DialogTimingProcessor(agi::Context *c)
 	LeftSizer->Add(StyleButtonsSizer, wxSizerFlags().Expand());
 
 	// Top Sizer
-	wxSizer *TopSizer = new wxBoxSizer(wxHORIZONTAL);
+	auto TopSizer = new wxBoxSizer(wxHORIZONTAL);
 	TopSizer->Add(LeftSizer,0,wxRIGHT|wxEXPAND,5);
 	TopSizer->Add(RightSizer,1,wxALL|wxEXPAND,0);
 
 	// Main Sizer
-	wxSizer *MainSizer = new wxBoxSizer(wxVERTICAL);
+	auto MainSizer = new wxBoxSizer(wxVERTICAL);
 	MainSizer->Add(TopSizer,1,wxALL|wxEXPAND,5);
 	SetSizerAndFit(MainSizer);
 	CenterOnParent();
@@ -289,10 +290,10 @@ void DialogTimingProcessor::OnApply(wxCommandEvent &) {
 }
 
 std::vector<AssDialogue*> DialogTimingProcessor::SortDialogues() {
-	std::set<std::string> styles;
+	std::set<boost::flyweight<std::string>> styles;
 	for (size_t i = 0; i < StyleList->GetCount(); ++i) {
 		if (StyleList->IsChecked(i))
-			styles.insert(from_wx(StyleList->GetString(i)));
+			styles.insert(boost::flyweight<std::string>(from_wx(StyleList->GetString(i))));
 	}
 
 	std::vector<AssDialogue*> sorted;
@@ -382,28 +383,27 @@ void DialogTimingProcessor::Process() {
 
 	// Keyframe snapping
 	if (keysEnable->IsChecked()) {
-		std::vector<int> kf = c->videoController->GetKeyFrames();
-		if (c->videoController->IsLoaded())
-			kf.push_back(c->videoController->GetLength() - 1);
+		std::vector<int> kf = c->project->Keyframes();
+		auto fps = c->project->Timecodes();
+		if (auto provider = c->project->VideoProvider())
+			kf.push_back(provider->GetFrameCount() - 1);
 
 		for (AssDialogue *cur : sorted) {
 			// Get start/end frames
-			int startF = c->videoController->FrameAtTime(cur->Start, agi::vfr::START);
-			int endF = c->videoController->FrameAtTime(cur->End, agi::vfr::END);
+			int startF = fps.FrameAtTime(cur->Start, agi::vfr::START);
+			int endF = fps.FrameAtTime(cur->End, agi::vfr::END);
 
 			// Get closest for start
 			int closest = get_closest_kf(kf, startF);
-			int time = c->videoController->TimeAtFrame(closest, agi::vfr::START);
-			if ((closest > startF && time - cur->Start <= beforeStart) || (closest < startF && cur->Start - time <= afterStart)) {
+			int time = fps.TimeAtFrame(closest, agi::vfr::START);
+			if ((closest > startF && time - cur->Start <= beforeStart) || (closest < startF && cur->Start - time <= afterStart))
 				cur->Start = time;
-			}
 
 			// Get closest for end
 			closest = get_closest_kf(kf, endF) - 1;
-			time = c->videoController->TimeAtFrame(closest, agi::vfr::END);
-			if ((closest > endF && time - cur->End <= beforeEnd) || (closest < endF && cur->End - time <= afterEnd)) {
+			time = fps.TimeAtFrame(closest, agi::vfr::END);
+			if ((closest > endF && time - cur->End <= beforeEnd) || (closest < endF && cur->End - time <= afterEnd))
 				cur->End = time;
-			}
 		}
 	}
 
diff --git a/src/dialog_translation.cpp b/src/dialog_translation.cpp
index e3b3eba97..755d4436a 100644
--- a/src/dialog_translation.cpp
+++ b/src/dialog_translation.cpp
@@ -32,10 +32,11 @@
 #include "help_button.h"
 #include "libresrc/libresrc.h"
 #include "persist_location.h"
+#include "project.h"
 #include "subs_edit_ctrl.h"
 #include "selection_controller.h"
 #include "utils.h"
-#include "video_context.h"
+#include "video_controller.h"
 
 #include <libaegisub/make_unique.h>
 
@@ -127,12 +128,12 @@ DialogTranslation::DialogTranslation(agi::Context *c)
 		wxStaticBoxSizer *actions_box = new wxStaticBoxSizer(wxVERTICAL, this, _("Actions"));
 
 		wxButton *play_audio = new wxButton(this, -1, _("Play &Audio"));
-		play_audio->Enable(c->audioController->IsAudioOpen());
+		play_audio->Enable(!!c->project->AudioProvider());
 		play_audio->Bind(wxEVT_BUTTON, &DialogTranslation::OnPlayAudioButton, this);
 		actions_box->Add(play_audio, 0, wxALL, 5);
 
 		wxButton *play_video = new wxButton(this, -1, _("Play &Video"));
-		play_video->Enable(c->videoController->IsLoaded());
+		play_video->Enable(!!c->project->VideoProvider());
 		play_video->Bind(wxEVT_BUTTON, &DialogTranslation::OnPlayVideoButton, this);
 		actions_box->Add(play_video, 0, wxLEFT | wxRIGHT | wxBOTTOM, 5);
 
diff --git a/src/dialog_video_details.cpp b/src/dialog_video_details.cpp
index 96acc1082..0ff50cf69 100644
--- a/src/dialog_video_details.cpp
+++ b/src/dialog_video_details.cpp
@@ -35,13 +35,12 @@
 #include "dialog_video_details.h"
 
 #include "ass_time.h"
+#include "async_video_provider.h"
 #include "compat.h"
 #include "include/aegisub/context.h"
-#include "include/aegisub/video_provider.h"
-#include "video_context.h"
+#include "project.h"
 
 #include <boost/rational.hpp>
-
 #include <wx/sizer.h>
 #include <wx/stattext.h>
 #include <wx/textctrl.h>
@@ -49,10 +48,11 @@
 DialogVideoDetails::DialogVideoDetails(agi::Context *c)
 : wxDialog(c->parent , -1, _("Video Details"))
 {
-	auto width = c->videoController->GetWidth();
-	auto height = c->videoController->GetHeight();
-	auto framecount = c->videoController->GetLength();
-	auto fps = c->videoController->FPS();
+	auto provider = c->project->VideoProvider();
+	auto width = provider->GetWidth();
+	auto height = provider->GetHeight();
+	auto framecount = provider->GetFrameCount();
+	auto fps = provider->GetFPS();
 	boost::rational<int> ar(width, height);
 
 	auto fg = new wxFlexGridSizer(2, 5, 10);
@@ -60,11 +60,11 @@ DialogVideoDetails::DialogVideoDetails(agi::Context *c)
 		fg->Add(new wxStaticText(this, -1, name), 0, wxALIGN_CENTRE_VERTICAL);
 		fg->Add(new wxTextCtrl(this, -1, value, wxDefaultPosition, wxSize(300,-1), wxTE_READONLY), 0, wxALIGN_CENTRE_VERTICAL | wxEXPAND);
 	};
-	make_field(_("File name:"), c->videoController->GetVideoName().wstring());
+	make_field(_("File name:"), c->project->VideoName().wstring());
 	make_field(_("FPS:"), wxString::Format("%.3f", fps.FPS()));
 	make_field(_("Resolution:"), wxString::Format("%dx%d (%d:%d)", width, height, ar.numerator(), ar.denominator()));
 	make_field(_("Length:"), wxString::Format(_("%d frames (%s)"), framecount, to_wx(AssTime(fps.TimeAtFrame(framecount - 1)).GetAssFormated(true))));
-	make_field(_("Decoder:"), to_wx(c->videoController->GetProvider()->GetDecoderName()));
+	make_field(_("Decoder:"), to_wx(provider->GetDecoderName()));
 
 	wxStaticBoxSizer *video_sizer = new wxStaticBoxSizer(wxVERTICAL, this, _("Video"));
 	video_sizer->Add(fg);
diff --git a/src/dialog_video_properties.cpp b/src/dialog_video_properties.cpp
index b9dc872d6..dd7b5d8d1 100644
--- a/src/dialog_video_properties.cpp
+++ b/src/dialog_video_properties.cpp
@@ -17,11 +17,12 @@
 #include "dialog_video_properties.h"
 
 #include "ass_file.h"
-#include "include/aegisub/video_provider.h"
+#include "async_video_provider.h"
 #include "options.h"
 #include "resolution_resampler.h"
 
 #include <wx/dialog.h>
+#include <wx/intl.h>
 #include <wx/radiobox.h>
 #include <wx/sizer.h>
 #include <wx/stattext.h>
@@ -79,9 +80,7 @@ public:
 		Bind(wxEVT_BUTTON, [=](wxCommandEvent&) { EndModal(0); }, wxID_CANCEL);
 	}
 };
-}
-
-bool UpdateVideoProperties(AssFile *file, const VideoProvider *new_provider, wxWindow *parent) {
+bool update_video_properties(AssFile *file, const AsyncVideoProvider *new_provider, wxWindow *parent) {
 	bool commit_subs = false;
 
 	// When opening dummy video only want to set the script properties if
@@ -156,3 +155,9 @@ bool UpdateVideoProperties(AssFile *file, const VideoProvider *new_provider, wxW
 		return true;
 	}
 }
+}
+
+void UpdateVideoProperties(AssFile *file, const AsyncVideoProvider *new_provider, wxWindow *parent) {
+	if (update_video_properties(file, new_provider, parent))
+		file->Commit(_("change script resolution"), AssFile::COMMIT_SCRIPTINFO);
+}
diff --git a/src/dialog_video_properties.h b/src/dialog_video_properties.h
index 5d48445a4..f2d293e7b 100644
--- a/src/dialog_video_properties.h
+++ b/src/dialog_video_properties.h
@@ -15,9 +15,8 @@
 // Aegisub Project http://www.aegisub.org/
 
 class AssFile;
-class VideoProvider;
+class AsyncVideoProvider;
 class wxWindow;
 
 /// Update the video properties for a newly opened video, possibly prompting the user about what to do
-/// @return Does the file need to be committed?
-bool UpdateVideoProperties(AssFile *file, const VideoProvider *new_provider, wxWindow *parent);
\ No newline at end of file
+void UpdateVideoProperties(AssFile *file, const AsyncVideoProvider *new_provider, wxWindow *parent);
\ No newline at end of file
diff --git a/src/export_framerate.cpp b/src/export_framerate.cpp
index bec332145..fc9c8c484 100644
--- a/src/export_framerate.cpp
+++ b/src/export_framerate.cpp
@@ -27,24 +27,19 @@
 //
 // Aegisub Project http://www.aegisub.org/
 
-/// @file export_framerate.cpp
-/// @brief Transform Framerate export filter
-/// @ingroup export
-///
-
 #include "export_framerate.h"
 
 #include "ass_dialogue.h"
 #include "ass_file.h"
+#include "async_video_provider.h"
 #include "compat.h"
 #include "include/aegisub/context.h"
+#include "project.h"
 #include "utils.h"
-#include "video_context.h"
 
 #include <libaegisub/of_type_adaptor.h>
 
 #include <utility>
-
 #include <wx/button.h>
 #include <wx/checkbox.h>
 #include <wx/panel.h>
@@ -65,18 +60,18 @@ void AssTransformFramerateFilter::ProcessSubs(AssFile *subs, wxWindow *) {
 }
 
 wxWindow *AssTransformFramerateFilter::GetConfigDialogWindow(wxWindow *parent, agi::Context *c) {
-	wxWindow *base = new wxPanel(parent, -1);
-
 	LoadSettings(true, c);
 
+	wxWindow *base = new wxPanel(parent, -1);
+
 	// Input sizer
-	wxSizer *InputSizer = new wxBoxSizer(wxHORIZONTAL);
+	auto InputSizer = new wxBoxSizer(wxHORIZONTAL);
 	wxString initialInput;
-	wxButton *FromVideo = new wxButton(base,-1,_("From &video"));
-	if (Input->IsLoaded()) {
-		initialInput = wxString::Format("%2.3f",Input->FPS());
+	auto FromVideo = new wxButton(base,-1,_("From &video"));
+	if (Input.IsLoaded()) {
+		initialInput = wxString::Format("%2.3f", Input.FPS());
 		FromVideo->Bind(wxEVT_BUTTON, [=](wxCommandEvent&) {
-			InputFramerate->SetValue(wxString::Format("%g", c->videoController->FPS().FPS()));
+			InputFramerate->SetValue(wxString::Format("%g", c->project->Timecodes().FPS()));
 		});
 	}
 	else {
@@ -89,9 +84,9 @@ wxWindow *AssTransformFramerateFilter::GetConfigDialogWindow(wxWindow *parent, a
 	InputSizer->AddStretchSpacer(1);
 
 	// Output sizers
-	wxSizer *OutputSizerTop = new wxBoxSizer(wxHORIZONTAL);
-	wxSizer *OutputSizerBottom = new wxBoxSizer(wxHORIZONTAL);
-	wxSizer *OutputSizer = new wxBoxSizer(wxVERTICAL);
+	auto OutputSizerTop = new wxBoxSizer(wxHORIZONTAL);
+	auto OutputSizerBottom = new wxBoxSizer(wxHORIZONTAL);
+	auto OutputSizer = new wxBoxSizer(wxVERTICAL);
 
 	// Output top line
 	RadioOutputVFR = new wxRadioButton(base,-1,_("V&ariable"),wxDefaultPosition,wxDefaultSize,wxRB_GROUP);
@@ -100,7 +95,7 @@ wxWindow *AssTransformFramerateFilter::GetConfigDialogWindow(wxWindow *parent, a
 	// Output bottom line
 	RadioOutputCFR = new wxRadioButton(base,-1,_("&Constant: "));
 	wxString initialOutput = initialInput;
-	if (!Output->IsVFR()) {
+	if (!Output.IsVFR()) {
 		RadioOutputVFR->Enable(false);
 		RadioOutputCFR->SetValue(true);
 	}
@@ -117,7 +112,7 @@ wxWindow *AssTransformFramerateFilter::GetConfigDialogWindow(wxWindow *parent, a
 	OutputSizer->Add(OutputSizerBottom,0,wxLEFT,5);
 
 	// Main window
-	wxSizer *MainSizer = new wxFlexGridSizer(3,2,5,10);
+	auto MainSizer = new wxFlexGridSizer(3,2,5,10);
 	MainSizer->Add(new wxStaticText(base,-1,_("Input framerate: ")),0,wxEXPAND | wxALIGN_CENTER_VERTICAL,0);
 	MainSizer->Add(InputSizer,0,wxEXPAND,0);
 	MainSizer->Add(new wxStaticText(base,-1,_("Output: ")),0,wxALIGN_CENTER_VERTICAL,0);
@@ -133,29 +128,27 @@ void AssTransformFramerateFilter::LoadSettings(bool is_default, agi::Context *c)
 	this->c = c;
 
 	if (is_default) {
-		Input = &c->videoController->VideoFPS();
-		Output = &c->videoController->FPS();
+		auto provider = c->project->VideoProvider();
+		Output = c->project->Timecodes();
+		Input = provider ? provider->GetFPS() : Output;
 	}
 	else {
 		double temp;
 		InputFramerate->GetValue().ToDouble(&temp);
-		t1 = temp;
-		Input = &t1;
+		Input = temp;
 		if (RadioOutputCFR->GetValue()) {
 			OutputFramerate->GetValue().ToDouble(&temp);
-			t2 = temp;
-			Output = &t2;
+			Output = temp;
 		}
-		else Output = &c->videoController->FPS();
+		else Output = c->project->Timecodes();
 
-		if (Reverse->IsChecked()) {
+		if (Reverse->IsChecked())
 			std::swap(Input, Output);
-		}
 	}
 }
 
 /// Truncate a time to centisecond precision
-int FORCEINLINE trunc_cs(int time) {
+static int trunc_cs(int time) {
 	return (time / 10) * 10;
 }
 
@@ -198,7 +191,7 @@ void AssTransformFramerateFilter::TransformTimeTags(std::string const& name, Ass
 }
 
 void AssTransformFramerateFilter::TransformFrameRate(AssFile *subs) {
-	if (!Input->IsLoaded() || !Output->IsLoaded()) return;
+	if (!Input.IsLoaded() || !Output.IsLoaded()) return;
 	for (auto& curDialogue : subs->Events) {
 		line = &curDialogue;
 		newK = 0;
@@ -217,14 +210,14 @@ void AssTransformFramerateFilter::TransformFrameRate(AssFile *subs) {
 }
 
 int AssTransformFramerateFilter::ConvertTime(int time) {
-	int frame = Output->FrameAtTime(time);
-	int frameStart = Output->TimeAtFrame(frame);
-	int frameEnd = Output->TimeAtFrame(frame + 1);
+	int frame = Output.FrameAtTime(time);
+	int frameStart = Output.TimeAtFrame(frame);
+	int frameEnd = Output.TimeAtFrame(frame + 1);
 	int frameDur = frameEnd - frameStart;
 	double dist = double(time - frameStart) / frameDur;
 
-	int newStart = Input->TimeAtFrame(frame);
-	int newEnd = Input->TimeAtFrame(frame + 1);
+	int newStart = Input.TimeAtFrame(frame);
+	int newEnd = Input.TimeAtFrame(frame + 1);
 	int newDur = newEnd - newStart;
 
 	return newStart + newDur * dist;
diff --git a/src/export_framerate.h b/src/export_framerate.h
index 84c49d9e4..3ed57e445 100644
--- a/src/export_framerate.h
+++ b/src/export_framerate.h
@@ -27,11 +27,6 @@
 //
 // Aegisub Project http://www.aegisub.org/
 
-/// @file export_framerate.h
-/// @see export_framerate.cpp
-/// @ingroup export
-///
-
 #include "ass_export_filter.h"
 
 #include <libaegisub/vfr.h>
@@ -53,10 +48,8 @@ class AssTransformFramerateFilter final : public AssExportFilter {
 	int oldK = 0;
 
 	// Yes, these are backwards. It sort of makes sense if you think about what it's doing.
-	const agi::vfr::Framerate *Input = nullptr;  ///< Destination frame rate
-	const agi::vfr::Framerate *Output = nullptr; ///< Source frame rate
-
-	agi::vfr::Framerate t1,t2;
+	agi::vfr::Framerate Input;  ///< Destination frame rate
+	agi::vfr::Framerate Output; ///< Source frame rate
 
 	wxTextCtrl *InputFramerate; ///< Input frame rate text box
 	wxTextCtrl *OutputFramerate; ///< Output frame rate text box
@@ -85,7 +78,6 @@ class AssTransformFramerateFilter final : public AssExportFilter {
 	///      is in and the beginning of the next frame
 	int ConvertTime(int time);
 public:
-	/// Constructor
 	AssTransformFramerateFilter();
 	void ProcessSubs(AssFile *subs, wxWindow *) override;
 	wxWindow *GetConfigDialogWindow(wxWindow *parent, agi::Context *c) override;
diff --git a/src/frame_main.cpp b/src/frame_main.cpp
index 3cd2cb176..24b55aa37 100644
--- a/src/frame_main.cpp
+++ b/src/frame_main.cpp
@@ -37,9 +37,9 @@
 #include "include/aegisub/menu.h"
 #include "include/aegisub/toolbar.h"
 #include "include/aegisub/hotkey.h"
-#include "include/aegisub/video_provider.h"
 
 #include "ass_file.h"
+#include "async_video_provider.h"
 #include "audio_controller.h"
 #include "audio_box.h"
 #include "base_grid.h"
@@ -51,13 +51,14 @@
 #include "libresrc/libresrc.h"
 #include "main.h"
 #include "options.h"
+#include "project.h"
 #include "subs_controller.h"
 #include "subs_edit_box.h"
 #include "subs_edit_ctrl.h"
 #include "utils.h"
 #include "version.h"
 #include "video_box.h"
-#include "video_context.h"
+#include "video_controller.h"
 #include "video_display.h"
 #include "video_slider.h"
 
@@ -86,91 +87,16 @@ enum {
 #define StartupLog(a) LOG_I("frame_main/init") << a
 #endif
 
-wxDEFINE_EVENT(FILE_LIST_DROPPED, wxThreadEvent);
-
-static void get_files_to_load(wxArrayString const& list, std::string &subs, std::string &audio, std::string &video) {
-	// Keep these lists sorted
-
-	// Video formats
-	const wxString videoList[] = {
-		"asf",
-		"avi",
-		"avs",
-		"d2v",
-		"m2ts",
-		"m4v",
-		"mkv",
-		"mov",
-		"mp4",
-		"mpeg",
-		"mpg",
-		"ogm",
-		"rm",
-		"rmvb",
-		"ts",
-		"webm"
-		"wmv",
-		"y4m",
-		"yuv"
-	};
-
-	// Subtitle formats
-	const wxString subsList[] = {
-		"ass",
-		"srt",
-		"ssa",
-		"sub",
-		"ttxt",
-		"txt"
-	};
-
-	// Audio formats
-	const wxString audioList[] = {
-		"aac",
-		"ac3",
-		"ape",
-		"dts",
-		"flac",
-		"m4a",
-		"mka",
-		"mp3",
-		"ogg",
-		"w64",
-		"wav",
-		"wma"
-	};
-
-	// Scan list
-	for (wxFileName file : list) {
-		if (file.IsRelative()) file.MakeAbsolute();
-		if (!file.FileExists()) continue;
-
-		wxString ext = file.GetExt().Lower();
-
-		if (subs.empty() && std::binary_search(std::begin(subsList), std::end(subsList), ext))
-			subs = from_wx(file.GetFullPath());
-		if (video.empty() && std::binary_search(std::begin(videoList), std::end(videoList), ext))
-			video = from_wx(file.GetFullPath());
-		if (audio.empty() && std::binary_search(std::begin(audioList), std::end(audioList), ext))
-			audio = from_wx(file.GetFullPath());
-	}
-}
-
 /// Handle files drag and dropped onto Aegisub
 class AegisubFileDropTarget final : public wxFileDropTarget {
-	FrameMain *parent;
+	agi::Context *context;
 public:
-	AegisubFileDropTarget(FrameMain *parent) : parent(parent) { }
-	bool OnDropFiles(wxCoord, wxCoord, const wxArrayString& filenames) override {
-		std::string subs, audio, video;
-		get_files_to_load(filenames, subs, audio, video);
-
-		if (subs.empty() && audio.empty() && video.empty())
-			return false;
-
-		auto evt = new wxThreadEvent(FILE_LIST_DROPPED);
-		evt->SetPayload(filenames);
-		parent->QueueEvent(evt);
+	AegisubFileDropTarget(agi::Context *context) : context(context) { }
+	bool OnDropFiles(wxCoord, wxCoord, wxArrayString const& filenames) override {
+		std::vector<agi::fs::path> files;
+		for (wxString const& fn : filenames)
+			files.push_back(from_wx(fn));
+		context->project->LoadList(files);
 		return true;
 	}
 };
@@ -194,9 +120,8 @@ FrameMain::FrameMain()
 	context->ass->AddCommitListener(&FrameMain::UpdateTitle, this);
 	context->subsController->AddFileOpenListener(&FrameMain::OnSubtitlesOpen, this);
 	context->subsController->AddFileSaveListener(&FrameMain::UpdateTitle, this);
-	context->audioController->AddAudioOpenListener(&FrameMain::OnAudioOpen, this);
-	context->audioController->AddAudioCloseListener(&FrameMain::OnAudioClose, this);
-	context->videoController->AddVideoOpenListener(&FrameMain::OnVideoOpen, this);
+	context->project->AddAudioProviderListener(&FrameMain::OnAudioOpen, this);
+	context->project->AddVideoProviderListener(&FrameMain::OnVideoOpen, this);
 
 	StartupLog("Initializing context frames");
 	context->parent = this;
@@ -206,7 +131,9 @@ FrameMain::FrameMain()
 	if (OPT_GET("App/Maximized")->GetBool()) Maximize(true);
 
 	StartupLog("Initialize toolbar");
-	InitToolbar();
+	wxSystemOptions::SetOption("msw.remap", 0);
+	OPT_SUB("App/Show Toolbar", &FrameMain::EnableToolBar, this);
+	EnableToolBar(*OPT_GET("App/Show Toolbar"));
 
 	StartupLog("Initialize menu bar");
 	menu::GetMenuBar("main", this, context.get());
@@ -228,10 +155,7 @@ FrameMain::FrameMain()
 	OPT_SUB("Video/Detached/Enabled", &FrameMain::OnVideoDetach, this);
 
 	StartupLog("Set up drag/drop target");
-	SetDropTarget(new AegisubFileDropTarget(this));
-	Bind(FILE_LIST_DROPPED, [=](wxThreadEvent &evt) {
-		LoadList(evt.GetPayload<wxArrayString>());
-	});
+	SetDropTarget(new AegisubFileDropTarget(context.get()));
 
 	StartupLog("Load default file");
 	context->subsController->Close();
@@ -247,18 +171,12 @@ FrameMain::FrameMain()
 FrameMain::~FrameMain () {
 	wxGetApp().frame = nullptr;
 
-	context->videoController->SetVideo("");
-	context->audioController->CloseAudio();
+	context->project->CloseAudio();
+	context->project->CloseVideo();
 
 	DestroyChildren();
 }
 
-void FrameMain::InitToolbar() {
-	wxSystemOptions::SetOption("msw.remap", 0);
-	OPT_SUB("App/Show Toolbar", &FrameMain::EnableToolBar, this);
-	EnableToolBar(*OPT_GET("App/Show Toolbar"));
-}
-
 void FrameMain::EnableToolBar(agi::OptionValue const& opt) {
 	if (opt.GetBool()) {
 		if (!GetToolBar()) {
@@ -275,7 +193,7 @@ void FrameMain::EnableToolBar(agi::OptionValue const& opt) {
 
 void FrameMain::InitContents() {
 	StartupLog("Create background panel");
-	wxPanel *Panel = new wxPanel(this,-1,wxDefaultPosition,wxDefaultSize,wxTAB_TRAVERSAL | wxCLIP_CHILDREN);
+	auto Panel = new wxPanel(this, -1, wxDefaultPosition, wxDefaultSize, wxTAB_TRAVERSAL | wxCLIP_CHILDREN);
 
 	StartupLog("Create subtitles grid");
 	context->subsGrid = new BaseGrid(Panel, context.get());
@@ -313,10 +231,10 @@ void FrameMain::SetDisplayMode(int video, int audio) {
 	bool sv = false, sa = false;
 
 	if (video == -1) sv = showVideo;
-	else if (video)  sv = context->videoController->IsLoaded() && !context->dialog->Get<DialogDetachedVideo>();
+	else if (video)  sv = context->project->VideoProvider() && !context->dialog->Get<DialogDetachedVideo>();
 
 	if (audio == -1) sa = showAudio;
-	else if (audio)  sa = context->audioController->IsAudioOpen();
+	else if (audio)  sa = !!context->project->AudioProvider();
 
 	// See if anything changed
 	if (sv == showVideo && sa == showAudio) return;
@@ -357,15 +275,14 @@ void FrameMain::UpdateTitle() {
 	if (GetTitle() != newTitle) SetTitle(newTitle);
 }
 
-void FrameMain::OnVideoOpen() {
-	if (!context->videoController->IsLoaded()) {
+void FrameMain::OnVideoOpen(AsyncVideoProvider *provider) {
+	if (!provider) {
 		SetDisplayMode(0, -1);
 		return;
 	}
 
 	Freeze();
-	int vidx = context->videoController->GetWidth(),
-		vidy = context->videoController->GetHeight();
+	int vidx = provider->GetWidth(), vidy = provider->GetHeight();
 
 	// Set zoom level based on video resolution and window size
 	double zoom = context->videoDisplay->GetZoom();
@@ -380,30 +297,12 @@ void FrameMain::OnVideoOpen() {
 	if (OPT_GET("Video/Detached/Enabled")->GetBool() && !context->dialog->Get<DialogDetachedVideo>())
 		cmd::call("video/detach", context.get());
 	Thaw();
-
-	if (!blockAudioLoad && OPT_GET("Video/Open Audio")->GetBool() && context->audioController->GetAudioURL() != context->videoController->GetVideoName()) {
-		try {
-			context->audioController->OpenAudio(context->videoController->GetVideoName());
-		}
-		catch (agi::UserCancelException const&) { }
-		// Opening a video with no audio data isn't an error, so just log
-		// and move on
-		catch (agi::fs::FileSystemError const&) {
-			LOG_D("video/open/audio") << "File " << context->videoController->GetVideoName() << " found by video provider but not audio provider";
-		}
-		catch (agi::AudioDataNotFoundError const& e) {
-			LOG_D("video/open/audio") << "File " << context->videoController->GetVideoName() << " has no audio data: " << e.GetChainedMessage();
-		}
-		catch (agi::AudioOpenError const& err) {
-			wxMessageBox(to_wx(err.GetMessage()), "Error loading audio", wxOK | wxICON_ERROR | wxCENTER);
-		}
-	}
 }
 
 void FrameMain::OnVideoDetach(agi::OptionValue const& opt) {
 	if (opt.GetBool())
 		SetDisplayMode(0, -1);
-	else if (context->videoController->IsLoaded())
+	else if (context->project->VideoProvider())
 		SetDisplayMode(1, -1);
 }
 
@@ -413,41 +312,9 @@ void FrameMain::StatusTimeout(wxString text,int ms) {
 	StatusClear.Start(ms,true);
 }
 
-bool FrameMain::LoadList(wxArrayString list) {
-	std::string audio, video, subs;
-	get_files_to_load(list, subs, audio, video);
-
-	blockVideoLoad = !video.empty();
-	blockAudioLoad = !audio.empty();
-
-	// Load files
-	if (subs.size())
-		context->subsController->Load(subs);
-
-	if (blockVideoLoad) {
-		blockVideoLoad = false;
-		context->videoController->SetVideo(video);
-	}
-
-	if (blockAudioLoad) {
-		blockAudioLoad = false;
-		try {
-			context->audioController->OpenAudio(audio);
-		} catch (agi::UserCancelException const&) { }
-	}
-
-	bool loaded_any = subs.size() || audio.size() || video.size();
-	if (loaded_any)
-		Refresh(false);
-
-	return loaded_any;
-}
-
 BEGIN_EVENT_TABLE(FrameMain, wxFrame)
 	EVT_TIMER(ID_APP_TIMER_STATUSCLEAR, FrameMain::OnStatusClear)
-
 	EVT_CLOSE(FrameMain::OnCloseWindow)
-
 	EVT_CHAR_HOOK(FrameMain::OnKeyDown)
 	EVT_MOUSEWHEEL(FrameMain::OnMouseWheel)
 END_EVENT_TABLE()
@@ -476,95 +343,15 @@ void FrameMain::OnStatusClear(wxTimerEvent &) {
 	SetStatusText("",1);
 }
 
-void FrameMain::OnAudioOpen(AudioProvider *) {
-	SetDisplayMode(-1, 1);
-}
-
-void FrameMain::OnAudioClose() {
-	SetDisplayMode(-1, 0);
+void FrameMain::OnAudioOpen(AudioProvider *provider) {
+	if (provider)
+		SetDisplayMode(-1, 1);
+	else
+		SetDisplayMode(-1, 0);
 }
 
 void FrameMain::OnSubtitlesOpen() {
 	UpdateTitle();
-	auto vc = context->videoController.get();
-
-	/// @todo figure out how to move this to the relevant controllers without
-	///       prompting for each file loaded/unloaded
-
-	// Load stuff from the new script
-	auto video     = config::path->MakeAbsolute(context->ass->GetScriptInfo("Video File"), "?script");
-	auto vfr       = config::path->MakeAbsolute(context->ass->GetScriptInfo("VFR File"), "?script");
-	auto keyframes = config::path->MakeAbsolute(context->ass->GetScriptInfo("Keyframes File"), "?script");
-	auto audio     = config::path->MakeAbsolute(context->ass->GetScriptInfo("Audio URI"), "?script");
-
-	bool videoChanged     = !blockVideoLoad && video != vc->GetVideoName();
-	bool timecodesChanged = vfr != vc->GetTimecodesName();
-	bool keyframesChanged = keyframes != vc->GetKeyFramesName();
-	bool audioChanged     = !blockAudioLoad && audio != context->audioController->GetAudioURL();
-
-	// Check if there is anything to change
-	int autoLoadMode = OPT_GET("App/Auto/Load Linked Files")->GetInt();
-	if (autoLoadMode == 0 || (!videoChanged && !timecodesChanged && !keyframesChanged && !audioChanged)) {
-		SetDisplayMode(1, 1);
-		return;
-	}
-
-	if (autoLoadMode == 2) {
-		if (wxMessageBox(_("Do you want to load/unload the associated files?"), _("(Un)Load files?"), wxYES_NO | wxCENTRE, this) != wxYES) {
-			SetDisplayMode(1, 1);
-			if (vc->IsLoaded() && vc->GetProvider()->GetColorSpace() != context->ass->GetScriptInfo("YCbCr Matrix"))
-				vc->Reload();
-			return;
-		}
-	}
-
-	if (audioChanged)
-		blockAudioLoad = true;
-
-	// Video
-	if (videoChanged) {
-		vc->SetVideo(video);
-		if (vc->IsLoaded()) {
-			vc->JumpToFrame(context->ass->GetUIStateAsInt("Video Position"));
-
-			std::string arString = context->ass->GetUIState("Video Aspect Ratio");
-			if (boost::starts_with(arString, "c")) {
-				double ar = 0.;
-				agi::util::try_parse(arString.substr(1), &ar);
-				vc->SetAspectRatio(ar);
-			}
-			else {
-				int ar = 0;
-				if (agi::util::try_parse(arString, &ar) && ar >= 0 && ar < 4)
-					vc->SetAspectRatio((AspectRatio)ar);
-			}
-
-			double videoZoom = 0.;
-			if (agi::util::try_parse(context->ass->GetUIState("Video Zoom Percent"), &videoZoom))
-				context->videoDisplay->SetZoom(videoZoom);
-		}
-	}
-	else if (vc->IsLoaded() && vc->GetProvider()->GetColorSpace() != context->ass->GetScriptInfo("YCbCr Matrix"))
-		vc->Reload();
-
-	vc->LoadTimecodes(vfr);
-	vc->LoadKeyframes(keyframes);
-
-	// Audio
-	if (audioChanged) {
-		blockAudioLoad = false;
-		try {
-			if (audio.empty())
-				context->audioController->CloseAudio();
-			else
-				context->audioController->OpenAudio(audio);
-		}
-		catch (agi::UserCancelException const&) { }
-		catch (agi::fs::FileSystemError const& err) {
-			wxMessageBox(to_wx(err.GetMessage()), "Error opening audio", wxOK | wxICON_ERROR | wxCENTER, this);
-		}
-	}
-
 	SetDisplayMode(1, 1);
 }
 
diff --git a/src/frame_main.h b/src/frame_main.h
index 2d0c3c890..985b131ce 100644
--- a/src/frame_main.h
+++ b/src/frame_main.h
@@ -27,31 +27,23 @@
 //
 // Aegisub Project http://www.aegisub.org/
 
-/// @file frame_main.h
-/// @see frame_main.cpp
-/// @ingroup main_ui
-///
-
 #include <libaegisub/fs_fwd.h>
 
 #include <memory>
 #include <vector>
-
 #include <wx/frame.h>
 #include <wx/sizer.h>
 #include <wx/timer.h>
 
 class AegisubApp;
-class AegisubFileDropTarget;
+class AsyncVideoProvider;
 class AudioBox;
 class AudioProvider;
 class VideoBox;
-
 namespace agi { struct Context; class OptionValue; }
 
 class FrameMain : public wxFrame {
 	friend class AegisubApp;
-	friend class AegisubFileDropTarget;
 
 	std::unique_ptr<agi::Context> context;
 
@@ -64,13 +56,7 @@ class FrameMain : public wxFrame {
 	bool showVideo = true; ///< Is the video display shown?
 	bool showAudio = true; ///< Is the audio display shown?
 	wxTimer StatusClear;   ///< Status bar timeout timer
-	/// Block video loading; used when both video and subtitles are opened at
-	/// the same time, so that the video associated with the subtitles (if any)
-	/// isn't loaded
-	bool blockVideoLoad = false;
-	bool blockAudioLoad = false;
 
-	void InitToolbar();
 	void InitContents();
 
 	void UpdateTitle();
@@ -81,13 +67,9 @@ class FrameMain : public wxFrame {
 	void OnStatusClear(wxTimerEvent &event);
 	void OnCloseWindow (wxCloseEvent &event);
 
-	// AudioControllerAudioEventListener implementation
 	void OnAudioOpen(AudioProvider *provider);
-	void OnAudioClose();
-
-	void OnVideoOpen();
+	void OnVideoOpen(AsyncVideoProvider *provider);
 	void OnVideoDetach(agi::OptionValue const& opt);
-
 	void OnSubtitlesOpen();
 
 	void EnableToolBar(agi::OptionValue const& opt);
@@ -116,7 +98,5 @@ public:
 	bool IsVideoShown() const { return showVideo; }
 	bool IsAudioShown() const { return showAudio; }
 
-	bool LoadList(wxArrayString list);
-
 	DECLARE_EVENT_TABLE()
 };
diff --git a/src/grid_column.cpp b/src/grid_column.cpp
index f12f42048..e720629ea 100644
--- a/src/grid_column.cpp
+++ b/src/grid_column.cpp
@@ -21,7 +21,7 @@
 #include "compat.h"
 #include "include/aegisub/context.h"
 #include "options.h"
-#include "video_context.h"
+#include "video_controller.h"
 
 #include <libaegisub/character_count.h>
 
diff --git a/src/include/aegisub/audio_provider.h b/src/include/aegisub/audio_provider.h
index b433aabba..6a35976de 100644
--- a/src/include/aegisub/audio_provider.h
+++ b/src/include/aegisub/audio_provider.h
@@ -50,7 +50,6 @@ protected:
 	int sample_rate;
 	int bytes_per_sample;
 	bool float_samples;
-	agi::fs::path filename;
 
 	virtual void FillBuffer(void *buf, int64_t start, int64_t count) const = 0;
 
@@ -62,7 +61,6 @@ public:
 	void GetAudio(void *buf, int64_t start, int64_t count) const;
 	void GetAudioWithVolume(void *buf, int64_t start, int64_t count, double volume) const;
 
-	agi::fs::path GetFilename()       const { return filename; }
 	int64_t       GetNumSamples()     const { return num_samples; }
 	int64_t       GetDecodedSamples() const { return decoded_samples; }
 	int           GetSampleRate()     const { return sample_rate; }
@@ -88,7 +86,6 @@ public:
 		sample_rate = source->GetSampleRate();
 		bytes_per_sample = source->GetBytesPerSample();
 		float_samples = source->AreSamplesFloat();
-		filename = source->GetFilename();
 	}
 };
 
diff --git a/src/include/aegisub/context.h b/src/include/aegisub/context.h
index 23c3b405c..bda867974 100644
--- a/src/include/aegisub/context.h
+++ b/src/include/aegisub/context.h
@@ -23,13 +23,14 @@ class AssDialogue;
 class AudioKaraoke;
 class DialogManager;
 class FrameMain;
+class Project;
 class SearchReplaceEngine;
 class InitialLineState;
 class SelectionController;
 class SubsController;
 class BaseGrid;
 class TextSelectionController;
-class VideoContext;
+class VideoController;
 class VideoDisplay;
 class wxWindow;
 namespace Automation4 { class ScriptManager; }
@@ -42,10 +43,11 @@ struct Context {
 	std::unique_ptr<AssFile> ass;
 	std::unique_ptr<TextSelectionController> textSelectionController;
 	std::unique_ptr<SubsController> subsController;
+	std::unique_ptr<Project> project;
 	std::unique_ptr<Automation4::ScriptManager> local_scripts;
-	std::unique_ptr<VideoContext> videoController;
-	std::unique_ptr<AudioController> audioController;
 	std::unique_ptr<SelectionController> selectionController;
+	std::unique_ptr<VideoController> videoController;
+	std::unique_ptr<AudioController> audioController;
 	std::unique_ptr<InitialLineState> initialLineState;
 	std::unique_ptr<SearchReplaceEngine> search;
 
diff --git a/src/include/aegisub/video_provider.h b/src/include/aegisub/video_provider.h
index c962c1a07..509b7f8d8 100644
--- a/src/include/aegisub/video_provider.h
+++ b/src/include/aegisub/video_provider.h
@@ -44,7 +44,7 @@ struct VideoFrame;
 
 class VideoProvider {
 public:
-	virtual ~VideoProvider() {}
+	virtual ~VideoProvider() = default;
 
 	/// Override this method to actually get frames
 	virtual std::shared_ptr<VideoFrame> GetFrame(int n)=0;
diff --git a/src/main.cpp b/src/main.cpp
index b48113017..246d980c8 100644
--- a/src/main.cpp
+++ b/src/main.cpp
@@ -49,10 +49,9 @@
 #include "include/aegisub/context.h"
 #include "libresrc/libresrc.h"
 #include "options.h"
-#include "subs_controller.h"
+#include "project.h"
 #include "subtitle_format.h"
 #include "subtitles_provider_libass.h"
-#include "video_context.h"
 #include "version.h"
 #include "utils.h"
 
@@ -317,15 +316,14 @@ bool AegisubApp::OnInit() {
 
 		// Get parameter subs
 		StartupLog("Parse command line");
-		wxArrayString subs;
+		std::vector<agi::fs::path> files;
 		for (int i = 1; i < argc; ++i)
-			subs.push_back(argv[i]);
-		if (!subs.empty())
-			frame->LoadList(subs);
+			files.push_back(from_wx(argv[i]));
+		if (!files.empty())
+			frame->context->project->LoadList(files);
 	}
-
 	catch (const char *err) {
-		wxMessageBox(err,"Fatal error while initializing");
+		wxMessageBox(err, "Fatal error while initializing");
 		return false;
 	}
 	catch (wxString const& err) {
@@ -333,10 +331,9 @@ bool AegisubApp::OnInit() {
 		return false;
 	}
 	catch (agi::Exception const& e) {
-		wxMessageBox(to_wx(e.GetMessage()),"Fatal error while initializing");
+		wxMessageBox(to_wx(e.GetMessage()), "Fatal error while initializing");
 		return false;
 	}
-
 #ifndef _DEBUG
 	catch (...) {
 		wxMessageBox("Unhandled exception","Fatal error while initializing");
@@ -470,5 +467,5 @@ int AegisubApp::OnRun() {
 
 void AegisubApp::MacOpenFile(const wxString &filename) {
 	if (frame && !filename.empty())
-		frame->context->subsController->Load(agi::fs::path(filename));
+		frame->context->project->LoadSubtitles(agi::fs::path(filename));
 }
diff --git a/src/project.cpp b/src/project.cpp
new file mode 100644
index 000000000..22e497680
--- /dev/null
+++ b/src/project.cpp
@@ -0,0 +1,443 @@
+// Copyright (c) 2014, Thomas Goyne <plorkyeran@aegisub.org>
+//
+// Permission to use, copy, modify, and distribute this software for any
+// purpose with or without fee is hereby granted, provided that the above
+// copyright notice and this permission notice appear in all copies.
+//
+// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+// WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+// MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+// ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+// WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+// ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+// OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+//
+// Aegisub Project http://www.aegisub.org/
+
+#include "project.h"
+
+#include "ass_file.h"
+#include "async_video_provider.h"
+#include "audio_controller.h"
+#include "charset_detect.h"
+#include "compat.h"
+#include "dialog_progress.h"
+#include "dialog_video_properties.h"
+#include "include/aegisub/audio_provider.h"
+#include "include/aegisub/context.h"
+#include "include/aegisub/video_provider.h"
+#include "mkv_wrap.h"
+#include "options.h"
+#include "subs_controller.h"
+#include "video_controller.h"
+#include "video_display.h"
+
+#include <libaegisub/fs.h>
+#include <libaegisub/keyframe.h>
+#include <libaegisub/log.h>
+#include <libaegisub/make_unique.h>
+#include <libaegisub/path.h>
+#include <libaegisub/util.h>
+
+#include <boost/algorithm/string/case_conv.hpp>
+#include <boost/algorithm/string/predicate.hpp>
+#include <boost/filesystem/operations.hpp>
+#include <wx/msgdlg.h>
+
+Project::Project(agi::Context *c) : context(c) {
+	OPT_SUB("Audio/Cache/Type", &Project::ReloadAudio, this);
+	OPT_SUB("Audio/Provider", &Project::ReloadAudio, this);
+	OPT_SUB("Provider/Audio/FFmpegSource/Decode Error Handling", &Project::ReloadAudio, this);
+	OPT_SUB("Provider/Avisynth/Allow Ancient", &Project::ReloadVideo, this);
+	OPT_SUB("Provider/Avisynth/Memory Max", &Project::ReloadVideo, this);
+	OPT_SUB("Provider/Video/FFmpegSource/Decoding Threads", &Project::ReloadVideo, this);
+	OPT_SUB("Provider/Video/FFmpegSource/Unsafe Seeking", &Project::ReloadVideo, this);
+	OPT_SUB("Subtitle/Provider", &Project::ReloadVideo, this);
+	OPT_SUB("Video/Force BT.601", &Project::ReloadVideo, this);
+	OPT_SUB("Video/Provider", &Project::ReloadVideo, this);
+	c->subsController->AddFileSaveListener(&Project::OnSubtitlesSave, this);
+}
+
+Project::~Project() { }
+
+void Project::OnSubtitlesSave() {
+	context->ass->SetScriptInfo("Audio File",
+		config::path->MakeRelative(audio_file, "?script").generic_string());
+	context->ass->SetScriptInfo("Video File",
+		config::path->MakeRelative(video_file, "?script").generic_string());
+	context->ass->SetScriptInfo("VFR File",
+		config::path->MakeRelative(timecodes_file, "?script").generic_string());
+	context->ass->SetScriptInfo("Keyframes File",
+		config::path->MakeRelative(keyframes_file, "?script").generic_string());
+}
+
+void Project::ReloadAudio() {
+	if (audio_provider)
+		LoadAudio(audio_file);
+}
+
+void Project::ReloadVideo() {
+	if (video_provider)
+		LoadAudio(video_file);
+}
+
+void Project::ShowError(wxString const& message) {
+	wxMessageBox(message, "Error loading file", wxOK | wxICON_ERROR | wxCENTER, context->parent);
+}
+
+void Project::ShowError(std::string const& message) {
+	ShowError(to_wx(message));
+}
+
+void Project::DoLoadSubtitles(agi::fs::path const& path, std::string encoding) {
+	try {
+		if (encoding.empty())
+			encoding = CharSetDetect::GetEncoding(path);
+	}
+	catch (agi::UserCancelException const&) {
+		return;
+	}
+
+	if (encoding != "binary") {
+		// Try loading as timecodes and keyframes first since we can't
+		// distinguish them based on filename alone, and just ignore failures
+		// rather than trying to differentiate between malformed timecodes
+		// files and things that aren't timecodes files at all
+		try { return DoLoadTimecodes(path); } catch (...) { }
+		try { return DoLoadKeyframes(path); } catch (...) { }
+	}
+
+	try {
+		context->subsController->Load(path, encoding);
+	}
+	catch (agi::UserCancelException const&) { return; }
+	catch (agi::fs::FileNotFound const&) {
+		config::mru->Remove("Subtitle", path);
+		return ShowError(path.string() + " not found.");
+	}
+	catch (agi::Exception const& e) {
+		return ShowError(e.GetChainedMessage());
+	}
+	catch (std::exception const& e) {
+		return ShowError(std::string(e.what()));
+	}
+	catch (...) {
+		return ShowError(wxString("Unknown error"));
+	}
+}
+
+void Project::LoadSubtitles(agi::fs::path const& path, std::string encoding) {
+	DoLoadSubtitles(path, encoding);
+	LoadUnloadFiles();
+}
+
+void Project::CloseSubtitles() {
+	context->subsController->Close();
+	config::path->SetToken("?script", "");
+	LoadUnloadFiles();
+}
+
+void Project::LoadUnloadFiles() {
+	auto load_linked = OPT_GET("App/Auto/Load Linked Files")->GetInt();
+	if (!load_linked) return;
+
+	auto audio     = config::path->MakeAbsolute(context->ass->GetScriptInfo("Audio File"), "?script");
+	auto video     = config::path->MakeAbsolute(context->ass->GetScriptInfo("Video File"), "?script");
+	auto timecodes = config::path->MakeAbsolute(context->ass->GetScriptInfo("VFR File"), "?script");
+	auto keyframes = config::path->MakeAbsolute(context->ass->GetScriptInfo("Keyframes File"), "?script");
+
+	if (video == video_file && audio == audio_file && keyframes == keyframes_file && timecodes == timecodes_file)
+		return;
+
+	if (load_linked == 2) {
+		if (wxMessageBox(_("Do you want to load/unload the associated files?"), _("(Un)Load files?"), wxYES_NO | wxCENTRE, context->parent) != wxYES)
+			return;
+	}
+
+	bool loaded_video = false;
+	if (video != video_file) {
+		if (video.empty())
+			CloseVideo();
+		else if ((loaded_video = DoLoadVideo(video))) {
+			auto vc = context->videoController.get();
+			vc->JumpToFrame(context->ass->GetUIStateAsInt("Video Position"));
+
+			std::string arString = context->ass->GetUIState("Video Aspect Ratio");
+			if (boost::starts_with(arString, "c")) {
+				double ar = 0.;
+				agi::util::try_parse(arString.substr(1), &ar);
+				vc->SetAspectRatio(ar);
+			}
+			else {
+				int ar = 0;
+				if (agi::util::try_parse(arString, &ar) && ar >= 0 && ar < 4)
+					vc->SetAspectRatio((AspectRatio)ar);
+			}
+
+			double videoZoom = 0.;
+			if (agi::util::try_parse(context->ass->GetUIState("Video Zoom Percent"), &videoZoom))
+				context->videoDisplay->SetZoom(videoZoom);
+		}
+	}
+
+	if (!timecodes.empty()) LoadTimecodes(timecodes);
+	if (!keyframes.empty()) LoadKeyframes(keyframes);
+
+	if (audio != audio_file) {
+		if (audio.empty())
+			CloseAudio();
+		else
+			DoLoadAudio(audio, false);
+	}
+	else if (loaded_video && OPT_GET("Video/Open Audio")->GetBool() && audio_file != video_file)
+		DoLoadAudio(video, true);
+}
+
+void Project::DoLoadAudio(agi::fs::path const& path, bool quiet) {
+	if (!progress)
+		progress = new DialogProgress(context->parent);
+
+	try {
+		try {
+			audio_provider = AudioProviderFactory::GetProvider(path, progress);
+		}
+		catch (agi::UserCancelException const&) { return; }
+		catch (...) {
+			config::mru->Remove("Audio", path);
+			throw;
+		}
+	}
+	catch (agi::fs::FileNotFound const& e) {
+		return ShowError(_("The audio file was not found: ") + to_wx(e.GetChainedMessage()));
+	}
+	catch (agi::AudioDataNotFoundError const& e) {
+		if (quiet) {
+			LOG_D("video/open/audio") << "File " << video_file << " has no audio data: " << e.GetChainedMessage();
+			return;
+		}
+		else
+			return ShowError(_("None of the available audio providers recognised the selected file as containing audio data.\n\nThe following providers were tried:\n") + to_wx(e.GetChainedMessage()));
+	}
+	catch (agi::AudioProviderOpenError const& e) {
+		return ShowError(_("None of the available audio providers have a codec available to handle the selected file.\n\nThe following providers were tried:\n") + to_wx(e.GetChainedMessage()));
+	}
+	catch (agi::Exception const& e) {
+		return ShowError(e.GetChainedMessage());
+	}
+
+	audio_file = path;
+	config::path->SetToken("?audio", path);
+	config::mru->Add("Audio", path);
+	AnnounceAudioProviderModified(audio_provider.get());
+}
+
+void Project::LoadAudio(agi::fs::path const& path) {
+	DoLoadAudio(path, false);
+}
+
+void Project::CloseAudio() {
+	AnnounceAudioProviderModified(nullptr);
+	audio_provider.reset();
+	audio_file.clear();
+	config::path->SetToken("?audio", "");
+}
+
+bool Project::DoLoadVideo(agi::fs::path const& path) {
+	if (!progress)
+		progress = new DialogProgress(context->parent);
+
+	try {
+		auto old_matrix = context->ass->GetScriptInfo("YCbCr Matrix");
+		video_provider = agi::make_unique<AsyncVideoProvider>(path, old_matrix, context->videoController.get(), progress);
+	}
+	catch (agi::UserCancelException const&) { return false; }
+	catch (agi::fs::FileSystemError const& err) {
+		config::mru->Remove("Video", path);
+		ShowError(to_wx(err.GetMessage()));
+		return false;
+	}
+	catch (VideoProviderError const& err) {
+		ShowError(to_wx(err.GetMessage()));
+		return false;
+	}
+
+	UpdateVideoProperties(context->ass.get(), video_provider.get(), context->parent);
+	video_provider->LoadSubtitles(context->ass.get());
+
+	timecodes = video_provider->GetFPS();
+	keyframes = video_provider->GetKeyFrames();
+	timecodes_file.clear();
+	keyframes_file.clear();
+
+	video_file = path;
+	config::mru->Add("Video", path);
+	config::path->SetToken("?video", path);
+
+	std::string warning = video_provider->GetWarning();
+	if (!warning.empty())
+		wxMessageBox(to_wx(warning), "Warning", wxICON_WARNING | wxOK);
+
+	video_has_subtitles = false;
+	if (agi::fs::HasExtension(path, "mkv"))
+		video_has_subtitles = MatroskaWrapper::HasSubtitles(path);
+
+	AnnounceVideoProviderModified(video_provider.get());
+	AnnounceKeyframesModified(keyframes);
+	AnnounceTimecodesModified(timecodes);
+	return true;
+}
+
+void Project::LoadVideo(agi::fs::path const& path) {
+	if (!DoLoadVideo(path)) return;
+	if (OPT_GET("Video/Open Audio")->GetBool() && audio_file != video_file)
+		DoLoadAudio(video_file, true);
+}
+
+void Project::CloseVideo() {
+	AnnounceVideoProviderModified(nullptr);
+	video_provider.reset();
+	video_file.clear();
+	config::path->SetToken("?video", "");
+	video_has_subtitles = false;
+}
+
+void Project::DoLoadTimecodes(agi::fs::path const& path) {
+	timecodes = agi::vfr::Framerate(path);
+	timecodes_file = path;
+	config::mru->Add("Timecodes", path);
+	AnnounceTimecodesModified(timecodes);
+}
+
+void Project::LoadTimecodes(agi::fs::path const& path) {
+	try {
+		DoLoadTimecodes(path);
+	}
+	catch (agi::fs::FileSystemError const& e) {
+		ShowError(e.GetChainedMessage());
+		config::mru->Remove("Timecodes", path);
+	}
+	catch (agi::vfr::Error const& e) {
+		ShowError("Failed to parse timecodes file: " + e.GetChainedMessage());
+		config::mru->Remove("Timecodes", path);
+	}
+}
+
+void Project::CloseTimecodes() {
+	timecodes = video_provider ? video_provider->GetFPS() : agi::vfr::Framerate{};
+	timecodes_file.clear();
+	AnnounceTimecodesModified(timecodes);
+}
+
+void Project::DoLoadKeyframes(agi::fs::path const& path) {
+	keyframes = agi::keyframe::Load(path);
+	keyframes_file = path;
+	config::mru->Add("Keyframes", path);
+	AnnounceKeyframesModified(keyframes);
+}
+
+void Project::LoadKeyframes(agi::fs::path const& path) {
+	try {
+		DoLoadKeyframes(path);
+	}
+	catch (agi::fs::FileSystemError const& e) {
+		ShowError(e.GetChainedMessage());
+		config::mru->Remove("Keyframes", path);
+	}
+	catch (agi::keyframe::Error const& e) {
+		ShowError("Failed to parse keyframes file: " + e.GetChainedMessage());
+		config::mru->Remove("Keyframes", path);
+	}
+}
+
+void Project::CloseKeyframes() {
+	keyframes = video_provider ? video_provider->GetKeyFrames() : std::vector<int>{};
+	keyframes_file.clear();
+	AnnounceKeyframesModified(keyframes);
+}
+
+void Project::LoadList(std::vector<agi::fs::path> const& files) {
+	// Keep these lists sorted
+
+	// Video formats
+	const char *videoList[] = {
+		".asf",
+		".avi",
+		".avs",
+		".d2v",
+		".m2ts",
+		".m4v",
+		".mkv",
+		".mov",
+		".mp4",
+		".mpeg",
+		".mpg",
+		".ogm",
+		".rm",
+		".rmvb",
+		".ts",
+		".webm"
+		".wmv",
+		".y4m",
+		".yuv"
+	};
+
+	// Subtitle formats
+	const char *subsList[] = {
+		".ass",
+		".srt",
+		".ssa",
+		".sub",
+		".ttxt",
+		".txt"
+	};
+
+	// Audio formats
+	const char *audioList[] = {
+		".aac",
+		".ac3",
+		".ape",
+		".dts",
+		".flac",
+		".m4a",
+		".mka",
+		".mp3",
+		".ogg",
+		".w64",
+		".wav",
+		".wma"
+	};
+
+	auto search = [](const char **begin, const char **end, std::string const& str) {
+		return std::binary_search(begin, end, str.c_str(), [](const char *a, const char *b) {
+			return strcmp(a, b) < 0;
+		});
+	};
+
+	agi::fs::path audio, video, subs;
+	for (auto file : files) {
+		if (file.is_relative()) file = absolute(file);
+		if (!agi::fs::FileExists(file)) continue;
+
+		auto ext = file.extension().string();
+		boost::to_lower(ext);
+
+		if (subs.empty() && search(std::begin(subsList), std::end(subsList), ext))
+			subs = file;
+		if (video.empty() && search(std::begin(videoList), std::end(videoList), ext))
+			video = file;
+		if (audio.empty() && search(std::begin(audioList), std::end(audioList), ext))
+			audio = file;
+	}
+
+	if (!subs.empty())
+		DoLoadSubtitles(subs);
+	if (!video.empty())
+		DoLoadVideo(video);
+	if (!audio.empty())
+		DoLoadAudio(audio, false);
+	else if (OPT_GET("Video/Open Audio")->GetBool() && audio_file != video_file)
+		DoLoadAudio(video_file, true);
+
+	if (!subs.empty())
+		LoadUnloadFiles();
+}
diff --git a/src/project.h b/src/project.h
new file mode 100644
index 000000000..44ad98281
--- /dev/null
+++ b/src/project.h
@@ -0,0 +1,104 @@
+// Copyright (c) 2014, Thomas Goyne <plorkyeran@aegisub.org>
+//
+// Permission to use, copy, modify, and distribute this software for any
+// purpose with or without fee is hereby granted, provided that the above
+// copyright notice and this permission notice appear in all copies.
+//
+// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+// WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+// MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+// ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+// WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+// ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+// OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+//
+// Aegisub Project http://www.aegisub.org/
+
+#include <libaegisub/fs_fwd.h>
+#include <libaegisub/signal.h>
+#include <libaegisub/vfr.h>
+
+#include <boost/filesystem/path.hpp>
+#include <memory>
+#include <vector>
+
+class AsyncVideoProvider;
+class AudioProvider;
+class DialogProgress;
+class wxString;
+namespace agi { struct Context; }
+
+class Project {
+	// Things owned by this
+	std::unique_ptr<AudioProvider> audio_provider;
+	std::unique_ptr<AsyncVideoProvider> video_provider;
+	agi::vfr::Framerate timecodes;
+	std::vector<int> keyframes;
+
+	agi::fs::path audio_file;
+	agi::fs::path video_file;
+	agi::fs::path timecodes_file;
+	agi::fs::path keyframes_file;
+
+	agi::signal::Signal<AudioProvider *> AnnounceAudioProviderModified;
+	agi::signal::Signal<AsyncVideoProvider *> AnnounceVideoProviderModified;
+	agi::signal::Signal<agi::vfr::Framerate const&> AnnounceTimecodesModified;
+	agi::signal::Signal<std::vector<int> const&> AnnounceKeyframesModified;
+
+	bool video_has_subtitles = false;
+
+	DialogProgress *progress = nullptr;
+
+	// Things not
+	agi::Context *context = nullptr;
+
+	void ShowError(wxString const& message);
+	void ShowError(std::string const& message);
+
+	void DoLoadSubtitles(agi::fs::path const& path, std::string encoding="");
+	void DoLoadAudio(agi::fs::path const& path, bool quiet);
+	bool DoLoadVideo(agi::fs::path const& path);
+	void DoLoadTimecodes(agi::fs::path const& path);
+	void DoLoadKeyframes(agi::fs::path const& path);
+
+	void LoadUnloadFiles();
+
+	void OnSubtitlesSave();
+	void ReloadAudio();
+	void ReloadVideo();
+
+public:
+	Project(agi::Context *context);
+	~Project();
+
+	void LoadSubtitles(agi::fs::path const& path, std::string encoding="");
+	void CloseSubtitles();
+	bool CanLoadSubtitlesFromVideo() const { return video_has_subtitles; }
+
+	void LoadAudio(agi::fs::path const& path);
+	void CloseAudio();
+	AudioProvider *AudioProvider() const { return audio_provider.get(); }
+	agi::fs::path const& AudioName() const { return audio_file; }
+
+	void LoadVideo(agi::fs::path const& path);
+	void CloseVideo();
+	AsyncVideoProvider *VideoProvider() const { return video_provider.get(); }
+	agi::fs::path const& VideoName() const { return video_file; }
+
+	void LoadTimecodes(agi::fs::path const& path);
+	void CloseTimecodes();
+	bool CanCloseTimecodes() const { return !timecodes_file.empty(); }
+	agi::vfr::Framerate const& Timecodes() const { return timecodes; }
+
+	void LoadKeyframes(agi::fs::path const& path);
+	void CloseKeyframes();
+	bool CanCloseKeyframes() const { return !keyframes_file.empty(); }
+	std::vector<int> const& Keyframes() const { return keyframes; }
+
+	void LoadList(std::vector<agi::fs::path> const& files);
+
+	DEFINE_SIGNAL_ADDERS(AnnounceAudioProviderModified, AddAudioProviderListener)
+	DEFINE_SIGNAL_ADDERS(AnnounceVideoProviderModified, AddVideoProviderListener)
+	DEFINE_SIGNAL_ADDERS(AnnounceTimecodesModified, AddTimecodesListener)
+	DEFINE_SIGNAL_ADDERS(AnnounceKeyframesModified, AddKeyframesListener)
+};
diff --git a/src/subs_controller.cpp b/src/subs_controller.cpp
index 1f66d92d9..de26b776a 100644
--- a/src/subs_controller.cpp
+++ b/src/subs_controller.cpp
@@ -22,18 +22,16 @@
 #include "ass_info.h"
 #include "ass_style.h"
 #include "ass_style_storage.h"
-#include "charset_detect.h"
 #include "compat.h"
 #include "command/command.h"
 #include "frame_main.h"
 #include "include/aegisub/context.h"
 #include "options.h"
+#include "project.h"
 #include "selection_controller.h"
 #include "subtitle_format.h"
-#include "text_file_reader.h"
 #include "text_selection_controller.h"
 #include "utils.h"
-#include "video_context.h"
 
 #include <libaegisub/fs.h>
 #include <libaegisub/path.h>
@@ -175,61 +173,15 @@ void SubsController::SetSelectionController(SelectionController *selection_contr
 void SubsController::Load(agi::fs::path const& filename, std::string charset) {
 	AssFile temp;
 
-	try {
-		try {
-			if (charset.empty())
-				charset = CharSetDetect::GetEncoding(filename);
-		}
-		catch (agi::UserCancelException const&) {
-			return;
-		}
-
-		// Make sure that file isn't actually a timecode file
-		if (charset != "binary") {
-			try {
-				TextFileReader testSubs(filename, charset);
-				std::string cur = testSubs.ReadLineFromFile();
-				if (boost::starts_with(cur, "# timecode")) {
-					context->videoController->LoadTimecodes(filename);
-					return;
-				}
-			}
-			catch (...) {
-				// if trying to load the file as timecodes fails it's fairly
-				// safe to assume that it is in fact not a timecode file
-			}
-		}
-
-		SubtitleFormat::GetReader(filename, charset)->ReadFile(&temp, filename, context->videoController->FPS(), charset);
+	SubtitleFormat::GetReader(filename, charset)->ReadFile(&temp, filename, context->project->Timecodes(), charset);
 
-		// Make sure the file has at least one style and one dialogue line
-		if (temp.Styles.empty())
-			temp.Styles.push_back(*new AssStyle);
-		if (temp.Events.empty())
-			temp.Events.push_back(*new AssDialogue);
+	// Make sure the file has at least one style and one dialogue line
+	if (temp.Styles.empty())
+		temp.Styles.push_back(*new AssStyle);
+	if (temp.Events.empty())
+		temp.Events.push_back(*new AssDialogue);
 
-		context->ass->swap(temp);
-	}
-	catch (agi::UserCancelException const&) {
-		return;
-	}
-	catch (agi::fs::FileNotFound const&) {
-		wxMessageBox(filename.wstring() + " not found.", "Error", wxOK | wxICON_ERROR | wxCENTER, context->parent);
-		config::mru->Remove("Subtitle", filename);
-		return;
-	}
-	catch (agi::Exception const& err) {
-		wxMessageBox(to_wx(err.GetChainedMessage()), "Error", wxOK | wxICON_ERROR | wxCENTER, context->parent);
-		return;
-	}
-	catch (std::exception const& err) {
-		wxMessageBox(to_wx(err.what()), "Error", wxOK | wxICON_ERROR | wxCENTER, context->parent);
-		return;
-	}
-	catch (...) {
-		wxMessageBox("Unknown error", "Error", wxOK | wxICON_ERROR | wxCENTER, context->parent);
-		return;
-	}
+	context->ass->swap(temp);
 
 	SetFileName(filename);
 
diff --git a/src/subs_controller.h b/src/subs_controller.h
index 843054551..5ef34620f 100644
--- a/src/subs_controller.h
+++ b/src/subs_controller.h
@@ -87,8 +87,8 @@ public:
 
 	/// @brief Load from a file
 	/// @param file File name
-	/// @param charset Character set of file or empty to autodetect
-	void Load(agi::fs::path const& file, std::string charset="");
+	/// @param charset Character set of file
+	void Load(agi::fs::path const& file, std::string charset);
 
 	/// @brief Save to a file
 	/// @param file Path to save to
diff --git a/src/subs_edit_box.cpp b/src/subs_edit_box.cpp
index cc3e1d54f..ff4ab9789 100644
--- a/src/subs_edit_box.cpp
+++ b/src/subs_edit_box.cpp
@@ -45,6 +45,7 @@
 #include "initial_line_state.h"
 #include "libresrc/libresrc.h"
 #include "options.h"
+#include "project.h"
 #include "placeholder_ctrl.h"
 #include "selection_controller.h"
 #include "subs_edit_ctrl.h"
@@ -52,7 +53,6 @@
 #include "timeedit_ctrl.h"
 #include "tooltip_manager.h"
 #include "validators.h"
-#include "video_context.h"
 
 #include <libaegisub/character_count.h>
 #include <libaegisub/util.h>
@@ -228,10 +228,12 @@ SubsEditBox::SubsEditBox(wxWindow *parent, agi::Context *context)
 	OnSize(evt);
 
 	file_changed_slot = c->ass->AddCommitListener(&SubsEditBox::OnCommit, this);
-	connections.push_back(context->videoController->AddTimecodesListener(&SubsEditBox::UpdateFrameTiming, this));
-	connections.push_back(context->selectionController->AddActiveLineListener(&SubsEditBox::OnActiveLineChanged, this));
-	connections.push_back(context->selectionController->AddSelectionListener(&SubsEditBox::OnSelectedSetChanged, this));
-	connections.push_back(context->initialLineState->AddChangeListener(&SubsEditBox::OnLineInitialTextChanged, this));
+	connections = agi::signal::make_vector({
+		context->project->AddTimecodesListener(&SubsEditBox::UpdateFrameTiming, this),
+		context->selectionController->AddActiveLineListener(&SubsEditBox::OnActiveLineChanged, this),
+		context->selectionController->AddSelectionListener(&SubsEditBox::OnSelectedSetChanged, this),
+		context->initialLineState->AddChangeListener(&SubsEditBox::OnLineInitialTextChanged, this),
+	 });
 
 	context->textSelectionController->SetControl(edit_ctrl);
 	edit_ctrl->SetFocus();
@@ -394,14 +396,6 @@ void SubsEditBox::OnActiveLineChanged(AssDialogue *new_line) {
 	commit_id = -1;
 
 	UpdateFields(AssFile::COMMIT_DIAG_FULL, false);
-
-	/// @todo VideoContext should be doing this
-	if (c->videoController->IsLoaded()) {
-		if (OPT_GET("Video/Subtitle Sync")->GetBool()) {
-			c->videoController->Stop();
-			c->videoController->JumpToTime(line->Start);
-		}
-	}
 }
 
 void SubsEditBox::OnSelectedSetChanged() {
@@ -488,8 +482,10 @@ void SubsEditBox::CommitTimes(TimeField field) {
 				break;
 
 			case TIME_DURATION:
-				if (by_frame->GetValue())
-					d->End = c->videoController->TimeAtFrame(c->videoController->FrameAtTime(d->Start, agi::vfr::START) + duration->GetFrame() - 1, agi::vfr::END);
+				if (by_frame->GetValue()) {
+					auto const& fps = c->project->Timecodes();
+					d->End = fps.TimeAtFrame(fps.FrameAtTime(d->Start, agi::vfr::START) + duration->GetFrame() - 1, agi::vfr::END);
+				}
 				else
 					d->End = d->Start + duration->GetTime();
 				initial_times[d].second = d->End;
diff --git a/src/subs_edit_box.h b/src/subs_edit_box.h
index b51141f2c..e8638f524 100644
--- a/src/subs_edit_box.h
+++ b/src/subs_edit_box.h
@@ -33,7 +33,6 @@
 ///
 
 #include <array>
-#include <deque>
 #include <boost/container/map.hpp>
 #include <boost/flyweight/flyweight_fwd.hpp>
 #include <memory>
@@ -74,7 +73,7 @@ class SubsEditBox final : public wxPanel {
 		TIME_DURATION
 	};
 
-	std::deque<agi::signal::Connection> connections;
+	std::vector<agi::signal::Connection> connections;
 
 	/// Currently active dialogue line
 	AssDialogue *line = nullptr;
diff --git a/src/subtitle_format.cpp b/src/subtitle_format.cpp
index c9fb991d5..150d2ce2a 100644
--- a/src/subtitle_format.cpp
+++ b/src/subtitle_format.cpp
@@ -51,7 +51,7 @@
 #include "subtitle_format_transtation.h"
 #include "subtitle_format_ttxt.h"
 #include "subtitle_format_txt.h"
-#include "video_context.h"
+#include "video_controller.h"
 
 #include <libaegisub/fs.h>
 #include <libaegisub/make_unique.h>
diff --git a/src/subtitle_format_microdvd.cpp b/src/subtitle_format_microdvd.cpp
index 4e5bcd291..bb60e1b9b 100644
--- a/src/subtitle_format_microdvd.cpp
+++ b/src/subtitle_format_microdvd.cpp
@@ -40,7 +40,7 @@
 #include "options.h"
 #include "text_file_reader.h"
 #include "text_file_writer.h"
-#include "video_context.h"
+#include "video_controller.h"
 
 #include <libaegisub/fs.h>
 #include <libaegisub/util.h>
diff --git a/src/timeedit_ctrl.cpp b/src/timeedit_ctrl.cpp
index 645037986..1e0eb1051 100644
--- a/src/timeedit_ctrl.cpp
+++ b/src/timeedit_ctrl.cpp
@@ -40,8 +40,8 @@
 #include "compat.h"
 #include "include/aegisub/context.h"
 #include "options.h"
+#include "project.h"
 #include "utils.h"
-#include "video_context.h"
 
 #include <wx/clipbrd.h>
 #include <wx/dataobj.h>
@@ -88,17 +88,17 @@ void TimeEdit::SetTime(AssTime new_time) {
 }
 
 int TimeEdit::GetFrame() const {
-	return c->videoController->FrameAtTime(time, isEnd ? agi::vfr::END : agi::vfr::START);
+	return c->project->Timecodes().FrameAtTime(time, isEnd ? agi::vfr::END : agi::vfr::START);
 }
 
 void TimeEdit::SetFrame(int fn) {
-	SetTime(c->videoController->TimeAtFrame(fn, isEnd ? agi::vfr::END : agi::vfr::START));
+	SetTime(c->project->Timecodes().TimeAtFrame(fn, isEnd ? agi::vfr::END : agi::vfr::START));
 }
 
 void TimeEdit::SetByFrame(bool enableByFrame) {
 	if (enableByFrame == byFrame) return;
 
-	byFrame = enableByFrame && c->videoController->TimecodesLoaded();
+	byFrame = enableByFrame && c->project->Timecodes().IsLoaded();
 	UpdateText();
 }
 
@@ -107,7 +107,7 @@ void TimeEdit::OnModified(wxCommandEvent &event) {
 	if (byFrame) {
 		long temp = 0;
 		GetValue().ToLong(&temp);
-		time = c->videoController->TimeAtFrame(temp, isEnd ? agi::vfr::END : agi::vfr::START);
+		time = c->project->Timecodes().TimeAtFrame(temp, isEnd ? agi::vfr::END : agi::vfr::START);
 	}
 	else if (insert)
 		time = from_wx(GetValue());
@@ -115,7 +115,7 @@ void TimeEdit::OnModified(wxCommandEvent &event) {
 
 void TimeEdit::UpdateText() {
 	if (byFrame)
-		ChangeValue(std::to_wstring(c->videoController->FrameAtTime(time, isEnd ? agi::vfr::END : agi::vfr::START)));
+		ChangeValue(std::to_wstring(c->project->Timecodes().FrameAtTime(time, isEnd ? agi::vfr::END : agi::vfr::START)));
 	else
 		ChangeValue(to_wx(time.GetAssFormated()));
 }
diff --git a/src/utils.h b/src/utils.h
index a9bae56c8..34738f3da 100644
--- a/src/utils.h
+++ b/src/utils.h
@@ -97,15 +97,6 @@ std::string GetClipboard();
 void SetClipboard(std::string const& new_value);
 void SetClipboard(wxBitmap const& new_value);
 
-#ifndef FORCEINLINE
-#ifdef __VISUALC__
-#define FORCEINLINE __forceinline
-#else
-#define FORCEINLINE inline
-// __attribute__((always_inline)) gives me errors on g++ ~amz
-#endif
-#endif
-
 #define countof(array) (sizeof(array) / sizeof(array[0]))
 
 wxString FontFace(std::string opt_prefix);
diff --git a/src/validators.h b/src/validators.h
index 64aacf020..501de5acd 100644
--- a/src/validators.h
+++ b/src/validators.h
@@ -17,7 +17,7 @@
 #include <libaegisub/exception.h>
 
 #include <string>
-
+#include <wx/combobox.h>
 #include <wx/radiobox.h>
 #include <wx/validate.h>
 
diff --git a/src/video_box.cpp b/src/video_box.cpp
index 4dc069e85..24663dcd6 100644
--- a/src/video_box.cpp
+++ b/src/video_box.cpp
@@ -37,8 +37,9 @@
 #include "include/aegisub/toolbar.h"
 #include "libresrc/libresrc.h"
 #include "options.h"
+#include "project.h"
 #include "selection_controller.h"
-#include "video_context.h"
+#include "video_controller.h"
 #include "video_display.h"
 #include "video_slider.h"
 
@@ -56,7 +57,7 @@ VideoBox::VideoBox(wxWindow *parent, bool isDetached, agi::Context *context)
 	auto videoSlider = new VideoSlider(this, context);
 	videoSlider->SetToolTip(_("Seek video"));
 
-	wxToolBar *mainToolbar = toolbar::GetToolbar(this, "video", context, "Video", false);
+	auto mainToolbar = toolbar::GetToolbar(this, "video", context, "Video", false);
 
 	VideoPosition = new wxTextCtrl(this, -1, "", wxDefaultPosition, wxSize(110, 20), wxTE_READONLY);
 	VideoPosition->SetToolTip(_("Current frame time and number"));
@@ -67,29 +68,29 @@ VideoBox::VideoBox(wxWindow *parent, bool isDetached, agi::Context *context)
 	wxArrayString choices;
 	for (int i = 1; i <= 24; ++i)
 		choices.Add(wxString::Format("%g%%", i * 12.5));
-	wxComboBox *zoomBox = new wxComboBox(this, -1, "75%", wxDefaultPosition, wxDefaultSize, choices, wxCB_DROPDOWN | wxTE_PROCESS_ENTER);
+	auto zoomBox = new wxComboBox(this, -1, "75%", wxDefaultPosition, wxDefaultSize, choices, wxCB_DROPDOWN | wxTE_PROCESS_ENTER);
 
-	wxToolBar *visualToolBar = toolbar::GetToolbar(this, "visual_tools", context, "Video", true);
-	wxToolBar *visualSubToolBar = new wxToolBar(this, -1, wxDefaultPosition, wxDefaultSize, wxTB_VERTICAL | wxTB_BOTTOM | wxTB_FLAT);
+	auto visualToolBar = toolbar::GetToolbar(this, "visual_tools", context, "Video", true);
+	auto visualSubToolBar = new wxToolBar(this, -1, wxDefaultPosition, wxDefaultSize, wxTB_VERTICAL | wxTB_BOTTOM | wxTB_FLAT);
 
 	auto videoDisplay = new VideoDisplay(visualSubToolBar, isDetached, zoomBox, this, context);
 	videoDisplay->MoveBeforeInTabOrder(videoSlider);
 
-	wxSizer *toolbarSizer = new wxBoxSizer(wxVERTICAL);
+	auto toolbarSizer = new wxBoxSizer(wxVERTICAL);
 	toolbarSizer->Add(visualToolBar, wxSizerFlags(1));
 	toolbarSizer->Add(visualSubToolBar, wxSizerFlags());
 
-	wxSizer *topSizer = new wxBoxSizer(wxHORIZONTAL);
+	auto topSizer = new wxBoxSizer(wxHORIZONTAL);
 	topSizer->Add(toolbarSizer, 0, wxEXPAND);
 	topSizer->Add(videoDisplay, isDetached, isDetached ? wxEXPAND : 0);
 
-	wxSizer *videoBottomSizer = new wxBoxSizer(wxHORIZONTAL);
+	auto videoBottomSizer = new wxBoxSizer(wxHORIZONTAL);
 	videoBottomSizer->Add(mainToolbar, wxSizerFlags(0).Center());
 	videoBottomSizer->Add(VideoPosition, wxSizerFlags(1).Center().Border(wxLEFT));
 	videoBottomSizer->Add(VideoSubsPos, wxSizerFlags(1).Center().Border(wxLEFT));
 	videoBottomSizer->Add(zoomBox, wxSizerFlags(0).Center().Border(wxLEFT | wxRIGHT));
 
-	wxSizer *VideoSizer = new wxBoxSizer(wxVERTICAL);
+	auto VideoSizer = new wxBoxSizer(wxVERTICAL);
 	VideoSizer->Add(topSizer, 1, wxEXPAND, 0);
 	VideoSizer->Add(new wxStaticLine(this), 0, wxEXPAND, 0);
 	VideoSizer->Add(videoSlider, 0, wxEXPAND, 0);
@@ -98,23 +99,25 @@ VideoBox::VideoBox(wxWindow *parent, bool isDetached, agi::Context *context)
 
 	UpdateTimeBoxes();
 
-	slots.push_back(context->videoController->AddSeekListener(&VideoBox::UpdateTimeBoxes, this));
-	slots.push_back(context->videoController->AddKeyframesListener(&VideoBox::UpdateTimeBoxes, this));
-	slots.push_back(context->videoController->AddTimecodesListener(&VideoBox::UpdateTimeBoxes, this));
-	slots.push_back(context->videoController->AddVideoOpenListener(&VideoBox::UpdateTimeBoxes, this));
-	slots.push_back(context->ass->AddCommitListener(&VideoBox::UpdateTimeBoxes, this));
-	slots.push_back(context->selectionController->AddSelectionListener(&VideoBox::UpdateTimeBoxes, this));
+	connections = agi::signal::make_vector({
+		context->ass->AddCommitListener(&VideoBox::UpdateTimeBoxes, this),
+		context->project->AddKeyframesListener(&VideoBox::UpdateTimeBoxes, this),
+		context->project->AddTimecodesListener(&VideoBox::UpdateTimeBoxes, this),
+		context->project->AddVideoProviderListener(&VideoBox::UpdateTimeBoxes, this),
+		context->selectionController->AddSelectionListener(&VideoBox::UpdateTimeBoxes, this),
+		context->videoController->AddSeekListener(&VideoBox::UpdateTimeBoxes, this),
+	});
 }
 
 void VideoBox::UpdateTimeBoxes() {
-	if (!context->videoController->IsLoaded()) return;
+	if (!context->project->VideoProvider()) return;
 
 	int frame = context->videoController->GetFrameN();
 	int time = context->videoController->TimeAtFrame(frame, agi::vfr::EXACT);
 
 	// Set the text box for frame number and time
 	VideoPosition->SetValue(wxString::Format("%s - %d", AssTime(time).GetAssFormated(true), frame));
-	if (boost::binary_search(context->videoController->GetKeyFrames(), frame)) {
+	if (boost::binary_search(context->project->Keyframes(), frame)) {
 		// Set the background color to indicate this is a keyframe
 		VideoPosition->SetBackgroundColour(to_wx(OPT_GET("Colour/Subtitle Grid/Background/Selection")->GetColor()));
 		VideoPosition->SetForegroundColour(to_wx(OPT_GET("Colour/Subtitle Grid/Selection")->GetColor()));
diff --git a/src/video_box.h b/src/video_box.h
index 9ac4f3e1d..1e00e73ad 100644
--- a/src/video_box.h
+++ b/src/video_box.h
@@ -29,7 +29,7 @@
 
 #include <libaegisub/signal.h>
 
-#include <deque>
+#include <vector>
 #include <wx/panel.h>
 
 namespace agi { struct Context; }
@@ -38,7 +38,7 @@ class wxTextCtrl;
 /// @class VideoBox
 /// @brief The box containing the video display and associated controls
 class VideoBox final : public wxPanel {
-	std::deque<agi::signal::Connection> slots;
+	std::vector<agi::signal::Connection> connections;
 	agi::Context *context;     ///< Project context
 	wxTextCtrl *VideoPosition; ///< Current frame/time
 	wxTextCtrl *VideoSubsPos;  ///< Time relative to the active subtitle line
diff --git a/src/video_context.cpp b/src/video_context.cpp
deleted file mode 100644
index 2d762c892..000000000
--- a/src/video_context.cpp
+++ /dev/null
@@ -1,445 +0,0 @@
-// Copyright (c) 2005-2007, Rodrigo Braz Monteiro
-// All rights reserved.
-//
-// Redistribution and use in source and binary forms, with or without
-// modification, are permitted provided that the following conditions are met:
-//
-//   * Redistributions of source code must retain the above copyright notice,
-//     this list of conditions and the following disclaimer.
-//   * Redistributions in binary form must reproduce the above copyright notice,
-//     this list of conditions and the following disclaimer in the documentation
-//     and/or other materials provided with the distribution.
-//   * Neither the name of the Aegisub Group nor the names of its contributors
-//     may be used to endorse or promote products derived from this software
-//     without specific prior written permission.
-//
-// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
-// AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
-// IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
-// ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE
-// LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
-// CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
-// SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
-// INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
-// CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
-// ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
-// POSSIBILITY OF SUCH DAMAGE.
-//
-// Aegisub Project http://www.aegisub.org/
-
-/// @file video_context.cpp
-/// @brief Keep track of loaded video
-/// @ingroup video
-///
-
-#include "video_context.h"
-
-#include "ass_dialogue.h"
-#include "ass_file.h"
-#include "ass_time.h"
-#include "audio_controller.h"
-#include "compat.h"
-#include "dialog_progress.h"
-#include "dialog_video_properties.h"
-#include "include/aegisub/context.h"
-#include "include/aegisub/video_provider.h"
-#include "mkv_wrap.h"
-#include "options.h"
-#include "selection_controller.h"
-#include "subs_controller.h"
-#include "time_range.h"
-#include "threaded_frame_source.h"
-#include "utils.h"
-#include "video_frame.h"
-
-#include <libaegisub/fs.h>
-#include <libaegisub/keyframe.h>
-#include <libaegisub/path.h>
-#include <libaegisub/make_unique.h>
-
-#include <wx/msgdlg.h>
-
-VideoContext::VideoContext(agi::Context *c)
-: context(c)
-, playback(this)
-, playAudioOnStep(OPT_GET("Audio/Plays When Stepping Video"))
-{
-	context->ass->AddCommitListener(&VideoContext::OnSubtitlesCommit, this);
-	context->subsController->AddFileSaveListener(&VideoContext::OnSubtitlesSave, this);
-
-	Bind(EVT_VIDEO_ERROR, &VideoContext::OnVideoError, this);
-	Bind(EVT_SUBTITLES_ERROR, &VideoContext::OnSubtitlesError, this);
-	Bind(wxEVT_TIMER, &VideoContext::OnPlayTimer, this);
-
-	OPT_SUB("Subtitle/Provider", &VideoContext::Reload, this);
-	OPT_SUB("Video/Provider", &VideoContext::Reload, this);
-
-	// It would be nice to find a way to move these to the individual providers
-	OPT_SUB("Provider/Avisynth/Allow Ancient", &VideoContext::Reload, this);
-	OPT_SUB("Provider/Avisynth/Memory Max", &VideoContext::Reload, this);
-
-	OPT_SUB("Provider/Video/FFmpegSource/Decoding Threads", &VideoContext::Reload, this);
-	OPT_SUB("Provider/Video/FFmpegSource/Unsafe Seeking", &VideoContext::Reload, this);
-	OPT_SUB("Video/Force BT.601", &VideoContext::Reload, this);
-}
-
-VideoContext::~VideoContext () { }
-
-void VideoContext::Reset() {
-	config::path->SetToken("?video", "");
-
-	// Remove video data
-	Stop();
-	frame_n = 0;
-
-	// Clean up video data
-	video_filename.clear();
-	color_matrix.clear();
-
-	// Remove provider
-	provider.reset();
-	video_provider = nullptr;
-
-	keyframes.clear();
-	keyframes_filename.clear();
-	video_fps = agi::vfr::Framerate();
-	KeyframesOpen(keyframes);
-	if (!ovr_fps.IsLoaded()) TimecodesOpen(video_fps);
-}
-
-void VideoContext::SetVideo(const agi::fs::path &filename) {
-	Reset();
-	if (filename.empty()) {
-		VideoOpen();
-		return;
-	}
-
-	bool commit_subs = false;
-	try {
-		if (!progress)
-			progress = new DialogProgress(context->parent);
-		auto old_matrix = context->ass->GetScriptInfo("YCbCr Matrix");
-		provider = agi::make_unique<ThreadedFrameSource>(filename, old_matrix, this, progress);
-		video_provider = provider->GetVideoProvider();
-		video_filename = filename;
-		color_matrix = video_provider->GetColorSpace();
-		keyframes = video_provider->GetKeyFrames();
-		video_fps = video_provider->GetFPS();
-
-		commit_subs = UpdateVideoProperties(context->ass.get(), video_provider, context->parent);
-
-		// Set frame rate
-		if (ovr_fps.IsLoaded()) {
-			int ovr = wxMessageBox(_("You already have timecodes loaded. Would you like to replace them with timecodes from the video file?"),
-			                       _("Replace timecodes?"), wxYES_NO | wxICON_QUESTION);
-			if (ovr == wxYES) {
-				ovr_fps = agi::vfr::Framerate();
-				timecodes_filename.clear();
-			}
-		}
-
-		// Set aspect ratio
-		double dar = video_provider->GetDAR();
-		if (dar > 0)
-			SetAspectRatio(dar);
-
-		// Set filename
-		config::mru->Add("Video", filename);
-		config::path->SetToken("?video", filename);
-
-		// Show warning
-		std::string warning = video_provider->GetWarning();
-		if (!warning.empty())
-			wxMessageBox(to_wx(warning), "Warning", wxICON_WARNING | wxOK);
-
-		has_subtitles = false;
-		if (agi::fs::HasExtension(filename, "mkv"))
-			has_subtitles = MatroskaWrapper::HasSubtitles(filename);
-
-		provider->LoadSubtitles(context->ass.get());
-		VideoOpen();
-		KeyframesOpen(keyframes);
-		TimecodesOpen(FPS());
-	}
-	catch (agi::UserCancelException const&) { }
-	catch (agi::fs::FileSystemError const& err) {
-		config::mru->Remove("Video", filename);
-		wxMessageBox(to_wx(err.GetMessage()), "Error setting video", wxOK | wxICON_ERROR | wxCENTER);
-	}
-	catch (VideoProviderError const& err) {
-		wxMessageBox(to_wx(err.GetMessage()), "Error setting video", wxOK | wxICON_ERROR | wxCENTER);
-	}
-
-	if (commit_subs)
-		context->ass->Commit(_("change script resolution"), AssFile::COMMIT_SCRIPTINFO);
-	else
-		JumpToFrame(0);
-}
-
-void VideoContext::Reload() {
-	if (IsLoaded()) {
-		int frame = frame_n;
-		SetVideo(agi::fs::path(video_filename)); // explicitly copy videoFile since it's cleared in SetVideo
-		JumpToFrame(frame);
-	}
-}
-
-void VideoContext::OnSubtitlesCommit(int type, std::set<const AssDialogue *> const& changed) {
-	if (!IsLoaded()) return;
-
-	if ((type & AssFile::COMMIT_SCRIPTINFO) || type == AssFile::COMMIT_NEW) {
-		auto new_matrix = context->ass->GetScriptInfo("YCbCr Matrix");
-		if (!new_matrix.empty() && new_matrix != color_matrix) {
-			color_matrix = new_matrix;
-			provider->SetColorSpace(new_matrix);
-		}
-	}
-
-	if (changed.empty() || no_amend)
-		provider->LoadSubtitles(context->ass.get());
-	else
-		provider->UpdateSubtitles(context->ass.get(), changed);
-	if (!IsPlaying())
-		GetFrameAsync(frame_n);
-
-	no_amend = false;
-}
-
-void VideoContext::OnSubtitlesSave() {
-	no_amend = true;
-
-	context->ass->SetScriptInfo("VFR File", config::path->MakeRelative(GetTimecodesName(), "?script").generic_string());
-	context->ass->SetScriptInfo("Keyframes File", config::path->MakeRelative(GetKeyFramesName(), "?script").generic_string());
-
-	if (!IsLoaded()) {
-		context->ass->SetScriptInfo("Video File", "");
-		context->ass->SaveUIState("Video Aspect Ratio", "");
-		context->ass->SaveUIState("Video Position", "");
-		return;
-	}
-
-	std::string ar;
-	if (ar_type == AspectRatio::Custom)
-		ar = "c" + std::to_string(ar_value);
-	else
-		ar = std::to_string((int)ar_type);
-
-	context->ass->SetScriptInfo("Video File", config::path->MakeRelative(video_filename, "?script").generic_string());
-	context->ass->SaveUIState("Video Aspect Ratio", ar);
-	context->ass->SaveUIState("Video Position", std::to_string(frame_n));
-}
-
-void VideoContext::JumpToFrame(int n) {
-	if (!IsLoaded()) return;
-
-	bool was_playing = IsPlaying();
-	if (was_playing)
-		Stop();
-
-	frame_n = mid(0, n, GetLength() - 1);
-
-	GetFrameAsync(frame_n);
-	Seek(frame_n);
-
-	if (was_playing)
-		Play();
-}
-
-void VideoContext::JumpToTime(int ms, agi::vfr::Time end) {
-	JumpToFrame(FrameAtTime(ms, end));
-}
-
-void VideoContext::GetFrameAsync(int n) {
-	provider->RequestFrame(n, TimeAtFrame(n));
-}
-
-std::shared_ptr<VideoFrame> VideoContext::GetFrame(int n, bool raw) {
-	return provider->GetFrame(n, TimeAtFrame(n), raw);
-}
-
-int VideoContext::GetWidth() const { return video_provider->GetWidth(); }
-int VideoContext::GetHeight() const { return video_provider->GetHeight(); }
-int VideoContext::GetLength() const { return video_provider->GetFrameCount(); }
-
-void VideoContext::NextFrame() {
-	if (!video_provider || IsPlaying() || frame_n == video_provider->GetFrameCount())
-		return;
-
-	JumpToFrame(frame_n + 1);
-	if (playAudioOnStep->GetBool())
-		context->audioController->PlayRange(TimeRange(TimeAtFrame(frame_n - 1), TimeAtFrame(frame_n)));
-}
-
-void VideoContext::PrevFrame() {
-	if (!video_provider || IsPlaying() || frame_n == 0)
-		return;
-
-	JumpToFrame(frame_n - 1);
-	if (playAudioOnStep->GetBool())
-		context->audioController->PlayRange(TimeRange(TimeAtFrame(frame_n), TimeAtFrame(frame_n + 1)));
-}
-
-void VideoContext::Play() {
-	if (IsPlaying()) {
-		Stop();
-		return;
-	}
-
-	if (!IsLoaded()) return;
-
-	start_ms = TimeAtFrame(frame_n);
-	end_frame = GetLength() - 1;
-
-	context->audioController->PlayToEnd(start_ms);
-
-	playback_start_time = std::chrono::steady_clock::now();
-	playback.Start(10);
-}
-
-void VideoContext::PlayLine() {
-	Stop();
-
-	AssDialogue *curline = context->selectionController->GetActiveLine();
-	if (!curline) return;
-
-	context->audioController->PlayRange(TimeRange(curline->Start, curline->End));
-
-	// Round-trip conversion to convert start to exact
-	int startFrame = FrameAtTime(context->selectionController->GetActiveLine()->Start, agi::vfr::START);
-	start_ms = TimeAtFrame(startFrame);
-	end_frame = FrameAtTime(context->selectionController->GetActiveLine()->End, agi::vfr::END) + 1;
-
-	JumpToFrame(startFrame);
-
-	playback_start_time = std::chrono::steady_clock::now();
-	playback.Start(10);
-}
-
-void VideoContext::Stop() {
-	if (IsPlaying()) {
-		playback.Stop();
-		context->audioController->Stop();
-	}
-}
-
-void VideoContext::OnPlayTimer(wxTimerEvent &) {
-	using namespace std::chrono;
-	int next_frame = FrameAtTime(start_ms + duration_cast<milliseconds>(steady_clock::now() - playback_start_time).count());
-	if (next_frame == frame_n) return;
-
-	if (next_frame >= end_frame)
-		Stop();
-	else {
-		frame_n = next_frame;
-		GetFrameAsync(frame_n);
-		Seek(frame_n);
-	}
-}
-
-double VideoContext::GetARFromType(AspectRatio type) const {
-	switch (type) {
-		case AspectRatio::Default: return (double)GetWidth()/(double)GetHeight();
-		case AspectRatio::Fullscreen: return 4.0/3.0;
-		case AspectRatio::Widescreen: return 16.0/9.0;
-		case AspectRatio::Cinematic: return 2.35;
-	}
-	throw agi::InternalError("Bad AR type", nullptr);
-}
-
-void VideoContext::SetAspectRatio(double value) {
-	ar_type = AspectRatio::Custom;
-	ar_value = mid(.5, value, 5.);
-	ARChange(ar_type, ar_value);
-}
-
-void VideoContext::SetAspectRatio(AspectRatio type) {
-	ar_value = mid(.5, GetARFromType(type), 5.);
-	ar_type = type;
-	ARChange(ar_type, ar_value);
-}
-
-void VideoContext::LoadKeyframes(agi::fs::path const& filename) {
-	if (filename == keyframes_filename || filename.empty()) return;
-	try {
-		keyframes = agi::keyframe::Load(filename);
-		keyframes_filename = filename;
-		KeyframesOpen(keyframes);
-		config::mru->Add("Keyframes", filename);
-	}
-	catch (agi::keyframe::Error const& err) {
-		wxMessageBox(to_wx(err.GetMessage()), "Error opening keyframes file", wxOK | wxICON_ERROR | wxCENTER, context->parent);
-		config::mru->Remove("Keyframes", filename);
-	}
-	catch (agi::fs::FileSystemError const& err) {
-		wxMessageBox(to_wx(err.GetMessage()), "Error opening keyframes file", wxOK | wxICON_ERROR | wxCENTER, context->parent);
-		config::mru->Remove("Keyframes", filename);
-	}
-}
-
-void VideoContext::SaveKeyframes(agi::fs::path const& filename) {
-	agi::keyframe::Save(filename, GetKeyFrames());
-	config::mru->Add("Keyframes", filename);
-}
-
-void VideoContext::CloseKeyframes() {
-	keyframes_filename.clear();
-	if (video_provider)
-		keyframes = video_provider->GetKeyFrames();
-	else
-		keyframes.clear();
-	KeyframesOpen(keyframes);
-}
-
-void VideoContext::LoadTimecodes(agi::fs::path const& filename) {
-	if (filename == timecodes_filename || filename.empty()) return;
-	try {
-		ovr_fps = agi::vfr::Framerate(filename);
-		timecodes_filename = filename;
-		config::mru->Add("Timecodes", filename);
-		OnSubtitlesCommit(0, std::set<const AssDialogue*>());
-		TimecodesOpen(ovr_fps);
-	}
-	catch (agi::fs::FileSystemError const& err) {
-		wxMessageBox(to_wx(err.GetMessage()), "Error opening timecodes file", wxOK | wxICON_ERROR | wxCENTER, context->parent);
-		config::mru->Remove("Timecodes", filename);
-	}
-	catch (const agi::vfr::Error& e) {
-		wxLogError("Timecode file parse error: %s", to_wx(e.GetMessage()));
-		config::mru->Remove("Timecodes", filename);
-	}
-}
-void VideoContext::SaveTimecodes(agi::fs::path const& filename) {
-	try {
-		FPS().Save(filename, IsLoaded() ? GetLength() : -1);
-		config::mru->Add("Timecodes", filename);
-	}
-	catch (agi::fs::FileSystemError const& err) {
-		wxMessageBox(to_wx(err.GetMessage()), "Error saving timecodes", wxOK | wxICON_ERROR | wxCENTER, context->parent);
-	}
-}
-void VideoContext::CloseTimecodes() {
-	ovr_fps = agi::vfr::Framerate();
-	timecodes_filename.clear();
-    OnSubtitlesCommit(0, std::set<const AssDialogue*>());
-	TimecodesOpen(video_fps);
-}
-
-int VideoContext::TimeAtFrame(int frame, agi::vfr::Time type) const {
-	return (ovr_fps.IsLoaded() ? ovr_fps : video_fps).TimeAtFrame(frame, type);
-}
-
-int VideoContext::FrameAtTime(int time, agi::vfr::Time type) const {
-	return (ovr_fps.IsLoaded() ? ovr_fps : video_fps).FrameAtTime(time, type);
-}
-
-void VideoContext::OnVideoError(VideoProviderErrorEvent const& err) {
-	wxLogError(
-		"Failed seeking video. The video file may be corrupt or incomplete.\n"
-		"Error message reported: %s",
-		to_wx(err.GetMessage()));
-}
-void VideoContext::OnSubtitlesError(SubtitlesProviderErrorEvent const& err) {
-	wxLogError(
-		"Failed rendering subtitles. Error message reported: %s",
-		to_wx(err.GetMessage()));
-}
diff --git a/src/video_controller.cpp b/src/video_controller.cpp
new file mode 100644
index 000000000..8bd23079d
--- /dev/null
+++ b/src/video_controller.cpp
@@ -0,0 +1,274 @@
+// Copyright (c) 2005-2007, Rodrigo Braz Monteiro
+// All rights reserved.
+//
+// Redistribution and use in source and binary forms, with or without
+// modification, are permitted provided that the following conditions are met:
+//
+//   * Redistributions of source code must retain the above copyright notice,
+//     this list of conditions and the following disclaimer.
+//   * Redistributions in binary form must reproduce the above copyright notice,
+//     this list of conditions and the following disclaimer in the documentation
+//     and/or other materials provided with the distribution.
+//   * Neither the name of the Aegisub Group nor the names of its contributors
+//     may be used to endorse or promote products derived from this software
+//     without specific prior written permission.
+//
+// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+// AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+// IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+// ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE
+// LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+// CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+// SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+// INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+// CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+// ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+// POSSIBILITY OF SUCH DAMAGE.
+//
+// Aegisub Project http://www.aegisub.org/
+
+#include "video_controller.h"
+
+#include "ass_dialogue.h"
+#include "ass_file.h"
+#include "ass_time.h"
+#include "audio_controller.h"
+#include "compat.h"
+#include "dialog_progress.h"
+#include "dialog_video_properties.h"
+#include "include/aegisub/context.h"
+#include "include/aegisub/video_provider.h"
+#include "mkv_wrap.h"
+#include "options.h"
+#include "project.h"
+#include "selection_controller.h"
+#include "subs_controller.h"
+#include "time_range.h"
+#include "async_video_provider.h"
+#include "utils.h"
+#include "video_frame.h"
+
+#include <libaegisub/fs.h>
+#include <libaegisub/path.h>
+#include <libaegisub/make_unique.h>
+
+#include <wx/msgdlg.h>
+
+VideoController::VideoController(agi::Context *c)
+: context(c)
+, playAudioOnStep(OPT_GET("Audio/Plays When Stepping Video"))
+, connections(agi::signal::make_vector({
+	context->ass->AddCommitListener(&VideoController::OnSubtitlesCommit, this),
+	context->project->AddVideoProviderListener(&VideoController::OnNewVideoProvider, this),
+	context->selectionController->AddActiveLineListener(&VideoController::OnActiveLineChanged, this),
+	context->subsController->AddFileSaveListener(&VideoController::OnSubtitlesSave, this),
+}))
+{
+	Bind(EVT_VIDEO_ERROR, &VideoController::OnVideoError, this);
+	Bind(EVT_SUBTITLES_ERROR, &VideoController::OnSubtitlesError, this);
+	playback.Bind(wxEVT_TIMER, &VideoController::OnPlayTimer, this);
+}
+
+void VideoController::OnNewVideoProvider(AsyncVideoProvider *new_provider) {
+	Stop();
+	frame_n = 0;
+
+	provider = new_provider;
+	if (!provider) {
+		color_matrix.clear();
+		return;
+	}
+
+	color_matrix = provider->GetColorSpace();
+	double dar = provider->GetDAR();
+	if (dar > 0)
+		SetAspectRatio(dar);
+
+	JumpToFrame(0);
+}
+
+void VideoController::OnSubtitlesCommit(int type, std::set<const AssDialogue *> const& changed) {
+	if (!provider) return;
+
+	if ((type & AssFile::COMMIT_SCRIPTINFO) || type == AssFile::COMMIT_NEW) {
+		auto new_matrix = context->ass->GetScriptInfo("YCbCr Matrix");
+		if (!new_matrix.empty() && new_matrix != color_matrix) {
+			color_matrix = new_matrix;
+			provider->SetColorSpace(new_matrix);
+		}
+	}
+
+	if (changed.empty() || no_amend)
+		provider->LoadSubtitles(context->ass.get());
+	else
+		provider->UpdateSubtitles(context->ass.get(), changed);
+	if (!IsPlaying())
+		provider->GetFrame(frame_n, TimeAtFrame(frame_n));
+
+	no_amend = false;
+}
+
+void VideoController::OnSubtitlesSave() {
+	no_amend = true;
+
+	if (!provider) {
+		context->ass->SaveUIState("Video Aspect Ratio", "");
+		context->ass->SaveUIState("Video Position", "");
+		return;
+	}
+
+	std::string ar;
+	if (ar_type == AspectRatio::Custom)
+		ar = "c" + std::to_string(ar_value);
+	else
+		ar = std::to_string((int)ar_type);
+
+	context->ass->SaveUIState("Video Aspect Ratio", ar);
+	context->ass->SaveUIState("Video Position", std::to_string(frame_n));
+}
+
+void VideoController::OnActiveLineChanged(AssDialogue *line) {
+	if (line && provider && OPT_GET("Video/Subtitle Sync")->GetBool())
+		JumpToTime(line->Start);
+}
+
+void VideoController::RequestFrame() {
+	provider->RequestFrame(frame_n, TimeAtFrame(frame_n));
+}
+
+void VideoController::JumpToFrame(int n) {
+	if (!provider) return;
+
+	bool was_playing = IsPlaying();
+	if (was_playing)
+		Stop();
+
+	frame_n = mid(0, n, provider->GetFrameCount() - 1);
+	RequestFrame();
+	Seek(frame_n);
+
+	if (was_playing)
+		Play();
+}
+
+void VideoController::JumpToTime(int ms, agi::vfr::Time end) {
+	JumpToFrame(FrameAtTime(ms, end));
+}
+
+void VideoController::NextFrame() {
+	if (!provider || IsPlaying() || frame_n == provider->GetFrameCount())
+		return;
+
+	JumpToFrame(frame_n + 1);
+	if (playAudioOnStep->GetBool())
+		context->audioController->PlayRange(TimeRange(TimeAtFrame(frame_n - 1), TimeAtFrame(frame_n)));
+}
+
+void VideoController::PrevFrame() {
+	if (!provider || IsPlaying() || frame_n == 0)
+		return;
+
+	JumpToFrame(frame_n - 1);
+	if (playAudioOnStep->GetBool())
+		context->audioController->PlayRange(TimeRange(TimeAtFrame(frame_n), TimeAtFrame(frame_n + 1)));
+}
+
+void VideoController::Play() {
+	if (IsPlaying()) {
+		Stop();
+		return;
+	}
+
+	if (!provider) return;
+
+	start_ms = TimeAtFrame(frame_n);
+	end_frame = provider->GetFrameCount() - 1;
+
+	context->audioController->PlayToEnd(start_ms);
+
+	playback_start_time = std::chrono::steady_clock::now();
+	playback.Start(10);
+}
+
+void VideoController::PlayLine() {
+	Stop();
+
+	AssDialogue *curline = context->selectionController->GetActiveLine();
+	if (!curline) return;
+
+	context->audioController->PlayRange(TimeRange(curline->Start, curline->End));
+
+	// Round-trip conversion to convert start to exact
+	int startFrame = FrameAtTime(context->selectionController->GetActiveLine()->Start, agi::vfr::START);
+	start_ms = TimeAtFrame(startFrame);
+	end_frame = FrameAtTime(context->selectionController->GetActiveLine()->End, agi::vfr::END) + 1;
+
+	JumpToFrame(startFrame);
+
+	playback_start_time = std::chrono::steady_clock::now();
+	playback.Start(10);
+}
+
+void VideoController::Stop() {
+	if (IsPlaying()) {
+		playback.Stop();
+		context->audioController->Stop();
+	}
+}
+
+void VideoController::OnPlayTimer(wxTimerEvent &) {
+	using namespace std::chrono;
+	int next_frame = FrameAtTime(start_ms + duration_cast<milliseconds>(steady_clock::now() - playback_start_time).count());
+	if (next_frame == frame_n) return;
+
+	if (next_frame >= end_frame)
+		Stop();
+	else {
+		frame_n = next_frame;
+		RequestFrame();
+		Seek(frame_n);
+	}
+}
+
+double VideoController::GetARFromType(AspectRatio type) const {
+	switch (type) {
+		case AspectRatio::Default:    return (double)provider->GetWidth()/provider->GetHeight();
+		case AspectRatio::Fullscreen: return 4.0/3.0;
+		case AspectRatio::Widescreen: return 16.0/9.0;
+		case AspectRatio::Cinematic:  return 2.35;
+	}
+	throw agi::InternalError("Bad AR type", nullptr);
+}
+
+void VideoController::SetAspectRatio(double value) {
+	ar_type = AspectRatio::Custom;
+	ar_value = mid(.5, value, 5.);
+	ARChange(ar_type, ar_value);
+}
+
+void VideoController::SetAspectRatio(AspectRatio type) {
+	ar_value = mid(.5, GetARFromType(type), 5.);
+	ar_type = type;
+	ARChange(ar_type, ar_value);
+}
+
+int VideoController::TimeAtFrame(int frame, agi::vfr::Time type) const {
+	return context->project->Timecodes().TimeAtFrame(frame, type);
+}
+
+int VideoController::FrameAtTime(int time, agi::vfr::Time type) const {
+	return context->project->Timecodes().FrameAtTime(time, type);
+}
+
+void VideoController::OnVideoError(VideoProviderErrorEvent const& err) {
+	wxLogError(
+		"Failed seeking video. The video file may be corrupt or incomplete.\n"
+		"Error message reported: %s",
+		to_wx(err.GetMessage()));
+}
+
+void VideoController::OnSubtitlesError(SubtitlesProviderErrorEvent const& err) {
+	wxLogError(
+		"Failed rendering subtitles. Error message reported: %s",
+		to_wx(err.GetMessage()));
+}
diff --git a/src/video_context.h b/src/video_controller.h
similarity index 56%
rename from src/video_context.h
rename to src/video_controller.h
index 603d1b98e..bc232eda5 100644
--- a/src/video_context.h
+++ b/src/video_controller.h
@@ -27,28 +27,17 @@
 //
 // Aegisub Project http://www.aegisub.org/
 
-/// @file video_context.h
-/// @see video_context.cpp
-/// @ingroup video
-///
-
-#include <libaegisub/fs_fwd.h>
 #include <libaegisub/signal.h>
 #include <libaegisub/vfr.h>
 
-#include <boost/filesystem/path.hpp>
 #include <chrono>
-#include <ctime>
-#include <list>
 #include <memory>
 #include <set>
 
 #include <wx/timer.h>
 
 class AssDialogue;
-class DialogProgress;
-class ThreadedFrameSource;
-class VideoProvider;
+class AsyncVideoProvider;
 struct SubtitlesProviderErrorEvent;
 struct VideoFrame;
 struct VideoProviderErrorEvent;
@@ -66,47 +55,22 @@ enum class AspectRatio {
 	Custom
 };
 
-/// @class VideoContext
-/// @brief Manage a bunch of things vaguely related to video playback
-///
-/// VideoContext's core responsibility is opening and playing videos. Along
-/// with that, it also manages video timecodes and keyframes, and some
-/// video-related UI properties
-class VideoContext final : public wxEvtHandler {
+/// Manage stuff related to video playback
+class VideoController final : public wxEvtHandler {
 	/// Current frame number changed (new frame number)
 	agi::signal::Signal<int> Seek;
-	/// A new video was opened
-	agi::signal::Signal<> VideoOpen;
-	/// New keyframes opened (new keyframe data)
-	agi::signal::Signal<std::vector<int> const&> KeyframesOpen;
-	/// New timecodes opened (new timecode data)
-	agi::signal::Signal<agi::vfr::Framerate const&> TimecodesOpen;
 	/// Aspect ratio was changed (type, value)
 	agi::signal::Signal<AspectRatio, double> ARChange;
 
 	agi::Context *context;
 
-	DialogProgress *progress = nullptr;
-
 	/// The video provider owned by the threaded frame source, or nullptr if no
 	/// video is open
-	VideoProvider *video_provider = nullptr;
-
-	/// Asynchronous provider of video frames
-	std::unique_ptr<ThreadedFrameSource> provider;
-
-	/// Filename of currently open video
-	agi::fs::path video_filename;
+	AsyncVideoProvider *provider = nullptr;
 
 	/// Last seen script color matrix
 	std::string color_matrix;
 
-	/// List of frame numbers which are keyframes
-	std::vector<int> keyframes;
-
-	/// File name of the currently open keyframes or empty if keyframes are not overridden
-	agi::fs::path keyframes_filename;
-
 	/// Playback timer used to periodically check if we should go to the next
 	/// frame while playing video
 	wxTimer playback;
@@ -132,16 +96,11 @@ class VideoContext final : public wxEvtHandler {
 	/// The current AR type
 	AspectRatio ar_type = AspectRatio::Default;
 
-	/// Does the currently loaded video file have subtitles muxed into it?
-	bool has_subtitles = false;
-
-	/// Filename of the currently loaded timecodes file, or empty if timecodes
-	/// have not been overridden
-	agi::fs::path timecodes_filename;
-
 	/// Cached option for audio playing when frame stepping
 	const agi::OptionValue* playAudioOnStep;
 
+	std::vector<agi::signal::Connection> connections;
+
 	/// Amending the frame source's copy of the subtitle file requires that it
 	/// be kept in perfect sync. Saving the file can add lines to the file
 	/// without a commit, breaking this sync, so force a non-amend after each
@@ -150,58 +109,22 @@ class VideoContext final : public wxEvtHandler {
 
 	void OnPlayTimer(wxTimerEvent &event);
 
-	/// The timecodes from the video file
-	agi::vfr::Framerate video_fps;
-	/// External timecode which have been loaded, if any
-	agi::vfr::Framerate ovr_fps;
-
 	void OnVideoError(VideoProviderErrorEvent const& err);
 	void OnSubtitlesError(SubtitlesProviderErrorEvent const& err);
 
 	void OnSubtitlesCommit(int type, std::set<const AssDialogue *> const& changed);
 	void OnSubtitlesSave();
+	void OnNewVideoProvider(AsyncVideoProvider *provider);
+	void OnActiveLineChanged(AssDialogue *line);
 
-	/// Close the video, keyframes and timecodes
-	void Reset();
+	void RequestFrame();
 
 public:
-	VideoContext(agi::Context *context);
-	~VideoContext();
-
-	/// @brief Get the video provider used for the currently open video
-	VideoProvider *GetProvider() const { return video_provider; }
-
-	/// Synchronously get a video frame
-	/// @param n Frame number to get
-	/// @param raw If true, subtitles are not rendered on the frame
-	/// @return The requested frame
-	std::shared_ptr<VideoFrame> GetFrame(int n, bool raw = false);
-
-	/// Asynchronously get a video frame, triggering a EVT_FRAME_READY event when it's ready
-	/// @param n Frame number to get
-	void GetFrameAsync(int n);
-
-	/// Is there a video loaded?
-	bool IsLoaded() const { return !!video_provider; }
-
-	/// Get the file name of the currently open video, if any
-	agi::fs::path GetVideoName() const { return video_filename; }
+	VideoController(agi::Context *context);
 
 	/// Is the video currently playing?
 	bool IsPlaying() const { return playback.IsRunning(); }
 
-	/// Does the video file loaded have muxed subtitles that we can load?
-	bool HasSubtitles() const { return has_subtitles; }
-
-	/// Get the width of the currently open video
-	int GetWidth() const;
-
-	/// Get the height of the currently open video
-	int GetHeight() const;
-
-	/// Get the length in frames of the currently open video
-	int GetLength() const;
-
 	/// Get the current frame number
 	int GetFrameN() const { return frame_n; }
 
@@ -221,12 +144,6 @@ public:
 	/// Get the current aspect ratio of the video
 	double GetAspectRatioValue() const { return ar_value; }
 
-	/// @brief Open a new video
-	/// @param filename Video to open, or empty to close the current video
-	void SetVideo(const agi::fs::path &filename);
-	/// @brief Close and reopen the current video
-	void Reload();
-
 	/// @brief Jump to the beginning of a frame
 	/// @param n Frame number to jump to
 	void JumpToFrame(int n);
@@ -247,29 +164,8 @@ public:
 	void Stop();
 
 	DEFINE_SIGNAL_ADDERS(Seek, AddSeekListener)
-	DEFINE_SIGNAL_ADDERS(VideoOpen, AddVideoOpenListener)
-	DEFINE_SIGNAL_ADDERS(KeyframesOpen, AddKeyframesListener)
-	DEFINE_SIGNAL_ADDERS(TimecodesOpen, AddTimecodesListener)
 	DEFINE_SIGNAL_ADDERS(ARChange, AddARChangeListener)
 
-	const std::vector<int>& GetKeyFrames() const { return keyframes; };
-	agi::fs::path GetKeyFramesName() const { return keyframes_filename; }
-	void LoadKeyframes(agi::fs::path const& filename);
-	void SaveKeyframes(agi::fs::path const& filename);
-	void CloseKeyframes();
-	bool OverKeyFramesLoaded() const { return !keyframes_filename.empty(); }
-	bool KeyFramesLoaded() const { return !keyframes.empty(); }
-
-	agi::fs::path GetTimecodesName() const { return timecodes_filename; }
-	void LoadTimecodes(agi::fs::path const& filename);
-	void SaveTimecodes(agi::fs::path const& filename);
-	void CloseTimecodes();
-	bool OverTimecodesLoaded() const { return ovr_fps.IsLoaded(); }
-	bool TimecodesLoaded() const { return video_fps.IsLoaded() || ovr_fps.IsLoaded(); };
-
-	const agi::vfr::Framerate& FPS() const { return ovr_fps.IsLoaded() ? ovr_fps : video_fps; }
-	const agi::vfr::Framerate& VideoFPS() const { return video_fps; }
-
 	int TimeAtFrame(int frame, agi::vfr::Time type = agi::vfr::EXACT) const;
 	int FrameAtTime(int time, agi::vfr::Time type = agi::vfr::EXACT) const;
 };
diff --git a/src/video_display.cpp b/src/video_display.cpp
index 3a955a10a..179edbfa5 100644
--- a/src/video_display.cpp
+++ b/src/video_display.cpp
@@ -35,26 +35,26 @@
 #include "video_display.h"
 
 #include "ass_file.h"
+#include "async_video_provider.h"
 #include "command/command.h"
 #include "compat.h"
 #include "include/aegisub/context.h"
 #include "include/aegisub/hotkey.h"
 #include "include/aegisub/menu.h"
 #include "options.h"
+#include "project.h"
 #include "retina_helper.h"
 #include "spline_curve.h"
 #include "subs_controller.h"
-#include "threaded_frame_source.h"
 #include "utils.h"
 #include "video_out_gl.h"
-#include "video_context.h"
+#include "video_controller.h"
 #include "video_frame.h"
 #include "visual_tool.h"
 
 #include <libaegisub/make_unique.h>
 
 #include <algorithm>
-
 #include <wx/combobox.h>
 #include <wx/dataobj.h>
 #include <wx/dcclient.h>
@@ -85,17 +85,12 @@ public:
 
 #define E(cmd) cmd; if (GLenum err = glGetError()) throw OpenGlException(#cmd, err)
 
-VideoDisplay::VideoDisplay(
-	wxToolBar *visualSubToolBar,
-	bool freeSize,
-	wxComboBox *zoomBox,
-	wxWindow* parent,
-	agi::Context *c)
+VideoDisplay::VideoDisplay(wxToolBar *toolbar, bool freeSize, wxComboBox *zoomBox, wxWindow *parent, agi::Context *c)
 : wxGLCanvas(parent, -1, attribList)
 , autohideTools(OPT_GET("Tool/Visual/Autohide"))
 , con(c)
 , zoomValue(OPT_GET("Video/Default Zoom")->GetInt() * .125 + .125)
-, toolBar(visualSubToolBar)
+, toolBar(toolbar)
 , zoomBox(zoomBox)
 , freeSize(freeSize)
 , retina_helper(agi::make_unique<RetinaHelper>(this))
@@ -111,10 +106,11 @@ VideoDisplay::VideoDisplay(
 	zoomBox->Bind(wxEVT_TEXT_ENTER, &VideoDisplay::SetZoomFromBoxText, this);
 
 	con->videoController->Bind(EVT_FRAME_READY, &VideoDisplay::UploadFrameData, this);
-	slots.push_back(con->videoController->AddVideoOpenListener(&VideoDisplay::UpdateSize, this));
-	slots.push_back(con->videoController->AddARChangeListener(&VideoDisplay::UpdateSize, this));
-
-	slots.push_back(con->subsController->AddFileSaveListener(&VideoDisplay::OnSubtitlesSave, this));
+	connections = agi::signal::make_vector({
+		con->project->AddVideoProviderListener(&VideoDisplay::UpdateSize, this),
+		con->videoController->AddARChangeListener(&VideoDisplay::UpdateSize, this),
+		con->subsController->AddFileSaveListener(&VideoDisplay::OnSubtitlesSave, this),
+	});
 
 	Bind(wxEVT_PAINT, std::bind(&VideoDisplay::Render, this));
 	Bind(wxEVT_SIZE, &VideoDisplay::OnSizeEvent, this);
@@ -132,8 +128,7 @@ VideoDisplay::VideoDisplay(
 
 	c->videoDisplay = this;
 
-	if (con->videoController->IsLoaded())
-		con->videoController->JumpToFrame(con->videoController->GetFrameN());
+	con->videoController->JumpToFrame(con->videoController->GetFrameN());
 
 	SetLayoutDirection(wxLayout_LeftToRight);
 }
@@ -164,7 +159,7 @@ void VideoDisplay::UploadFrameData(FrameReadyEvent &evt) {
 }
 
 void VideoDisplay::Render() try {
-	if (!con->videoController->IsLoaded() || !InitContext() || (!videoOut && !pending_frame))
+	if (!con->project->VideoProvider() || !InitContext() || (!videoOut && !pending_frame))
 		return;
 
 	if (!videoOut)
@@ -185,7 +180,7 @@ void VideoDisplay::Render() try {
 			"programs and updating your video card drivers may fix this.\n"
 			"Error message reported: %s",
 			err.GetMessage());
-		con->videoController->SetVideo("");
+		con->project->CloseVideo();
 		return;
 	}
 	catch (const VideoOutRenderException& err) {
@@ -235,7 +230,7 @@ catch (const agi::Exception &err) {
 		"An error occurred trying to render the video frame on the screen.\n"
 		"Error message reported: %s",
 		err.GetChainedMessage());
-	con->videoController->SetVideo("");
+	con->project->CloseVideo();
 }
 
 void VideoDisplay::DrawOverscanMask(float horizontal_percent, float vertical_percent) const {
@@ -277,7 +272,8 @@ void VideoDisplay::DrawOverscanMask(float horizontal_percent, float vertical_per
 }
 
 void VideoDisplay::PositionVideo() {
-	if (!con->videoController->IsLoaded() || !IsShownOnScreen()) return;
+	auto provider = con->project->VideoProvider();
+	if (!provider || !IsShownOnScreen()) return;
 
 	viewport_left = 0;
 	viewport_bottom = GetClientSize().GetHeight() * scale_factor - videoSize.GetHeight();
@@ -286,8 +282,8 @@ void VideoDisplay::PositionVideo() {
 	viewport_height = videoSize.GetHeight();
 
 	if (freeSize) {
-		int vidW = con->videoController->GetWidth();
-		int vidH = con->videoController->GetHeight();
+		int vidW = provider->GetWidth();
+		int vidH = provider->GetHeight();
 
 		AspectRatio arType = con->videoController->GetAspectRatioType();
 		double displayAr = double(viewport_width) / viewport_height;
@@ -315,9 +311,10 @@ void VideoDisplay::PositionVideo() {
 }
 
 void VideoDisplay::UpdateSize() {
-	if (!con->videoController->IsLoaded() || !IsShownOnScreen()) return;
+	auto provider = con->project->VideoProvider();
+	if (!provider || !IsShownOnScreen()) return;
 
-	videoSize.Set(con->videoController->GetWidth(), con->videoController->GetHeight());
+	videoSize.Set(provider->GetWidth(), provider->GetHeight());
 	videoSize *= zoomValue;
 	if (con->videoController->GetAspectRatioType() != AspectRatio::Default)
 		videoSize.SetWidth(videoSize.GetHeight() * con->videoController->GetAspectRatioValue());
@@ -346,7 +343,7 @@ void VideoDisplay::OnSizeEvent(wxSizeEvent &event) {
 	if (freeSize) {
 		videoSize = GetClientSize() * scale_factor;
 		PositionVideo();
-		zoomValue = double(viewport_height) / con->videoController->GetHeight();
+		zoomValue = double(viewport_height) / con->project->VideoProvider()->GetHeight();
 		zoomBox->ChangeValue(wxString::Format("%g%%", zoomValue * 100.));
 	}
 	else {
diff --git a/src/video_display.h b/src/video_display.h
index 04b30444c..65837b727 100644
--- a/src/video_display.h
+++ b/src/video_display.h
@@ -36,14 +36,14 @@
 
 #include "vector2d.h"
 
-#include <deque>
 #include <memory>
 #include <typeinfo>
+#include <vector>
 #include <wx/glcanvas.h>
 
 // Prototypes
 class RetinaHelper;
-class VideoContext;
+class VideoController;
 class VideoOutGL;
 class VisualToolBase;
 class wxComboBox;
@@ -59,7 +59,7 @@ namespace agi {
 
 class VideoDisplay final : public wxGLCanvas {
 	/// Signals the display is connected to
-	std::deque<agi::signal::Connection> slots;
+	std::vector<agi::signal::Connection> connections;
 
 	const agi::OptionValue* autohideTools;
 
diff --git a/src/video_provider_ffmpegsource.cpp b/src/video_provider_ffmpegsource.cpp
index 9254c273e..ea86e1a15 100644
--- a/src/video_provider_ffmpegsource.cpp
+++ b/src/video_provider_ffmpegsource.cpp
@@ -39,7 +39,7 @@
 #include "compat.h"
 #include "options.h"
 #include "utils.h"
-#include "video_context.h"
+#include "video_controller.h"
 #include "video_frame.h"
 
 #include <libaegisub/fs.h>
diff --git a/src/video_slider.cpp b/src/video_slider.cpp
index 1490a838d..1c5cd1ca3 100644
--- a/src/video_slider.cpp
+++ b/src/video_slider.cpp
@@ -34,13 +34,15 @@
 
 #include "video_slider.h"
 
+#include "async_video_provider.h"
 #include "base_grid.h"
 #include "command/command.h"
 #include "include/aegisub/context.h"
 #include "include/aegisub/hotkey.h"
 #include "options.h"
+#include "project.h"
 #include "utils.h"
-#include "video_context.h"
+#include "video_controller.h"
 
 #include <wx/dcbuffer.h>
 #include <wx/settings.h>
@@ -48,19 +50,19 @@
 VideoSlider::VideoSlider (wxWindow* parent, agi::Context *c)
 : wxWindow(parent, -1, wxDefaultPosition, wxDefaultSize, wxWANTS_CHARS | wxFULL_REPAINT_ON_RESIZE)
 , c(c)
+, connections(agi::signal::make_vector({
+	OPT_SUB("Video/Slider/Show Keyframes", [=] { Refresh(false); }),
+	c->videoController->AddSeekListener(&VideoSlider::SetValue, this),
+	c->project->AddVideoProviderListener(&VideoSlider::VideoOpened, this),
+	c->project->AddKeyframesListener(&VideoSlider::KeyframesChanged, this),
+}))
 {
 	SetClientSize(20,25);
 	SetMinSize(wxSize(20, 25));
 	SetBackgroundStyle(wxBG_STYLE_PAINT);
 
-	slots.push_back(OPT_SUB("Video/Slider/Show Keyframes", [=] { Refresh(false); }));
-	slots.push_back(c->videoController->AddSeekListener(&VideoSlider::SetValue, this));
-	slots.push_back(c->videoController->AddVideoOpenListener(&VideoSlider::VideoOpened, this));
-	slots.push_back(c->videoController->AddKeyframesListener(&VideoSlider::KeyframesChanged, this));
-
 	c->videoSlider = this;
-
-	VideoOpened();
+	VideoOpened(c->project->VideoProvider());
 }
 
 void VideoSlider::SetValue(int value) {
@@ -69,10 +71,9 @@ void VideoSlider::SetValue(int value) {
 	Refresh(false);
 }
 
-void VideoSlider::VideoOpened() {
-	if (c->videoController->IsLoaded()) {
-		max = c->videoController->GetLength() - 1;
-		keyframes = c->videoController->GetKeyFrames();
+void VideoSlider::VideoOpened(AsyncVideoProvider *provider) {
+	if (provider) {
+		max = provider->GetFrameCount() - 1;
 		Refresh(false);
 	}
 }
diff --git a/src/video_slider.h b/src/video_slider.h
index 8ae03025c..0c0546d52 100644
--- a/src/video_slider.h
+++ b/src/video_slider.h
@@ -27,27 +27,22 @@
 //
 // Aegisub Project http://www.aegisub.org/
 
-/// @file video_slider.h
-/// @see video_slider.cpp
-/// @ingroup custom_control
-///
+#include <libaegisub/signal.h>
 
 #include <vector>
-
 #include <wx/window.h>
 
-#include <libaegisub/signal.h>
-
 namespace agi { struct Context; }
 
-class VideoContext;
+class VideoController;
+class AsyncVideoProvider;
 
 /// @class VideoSlider
 /// @brief Slider for displaying and adjusting the video position
 class VideoSlider: public wxWindow {
 	agi::Context *c; ///< Associated project context
 	std::vector<int> keyframes; ///< Currently loaded keyframes
-	std::vector<agi::signal::Connection> slots;
+	std::vector<agi::signal::Connection> connections;
 
 	int val = 0; ///< Current frame number
 	int max = 1; ///< Last frame number
@@ -60,7 +55,7 @@ class VideoSlider: public wxWindow {
 	void SetValue(int value);
 
 	/// Video open event handler
-	void VideoOpened();
+	void VideoOpened(AsyncVideoProvider *new_provider);
 	/// Keyframe open even handler
 	void KeyframesChanged(std::vector<int> const& newKeyframes);
 
diff --git a/src/visual_tool.cpp b/src/visual_tool.cpp
index 2a6f8edd8..6559441ac 100644
--- a/src/visual_tool.cpp
+++ b/src/visual_tool.cpp
@@ -28,7 +28,7 @@
 #include "options.h"
 #include "selection_controller.h"
 #include "utils.h"
-#include "video_context.h"
+#include "video_controller.h"
 #include "video_display.h"
 #include "visual_feature.h"
 #include "visual_tool_clip.h"
diff --git a/src/visual_tool.h b/src/visual_tool.h
index b4696df4f..c1d071b6a 100644
--- a/src/visual_tool.h
+++ b/src/visual_tool.h
@@ -26,7 +26,6 @@
 #include <libaegisub/owning_intrusive_list.h>
 #include <libaegisub/signal.h>
 
-#include <deque>
 #include <set>
 #include <wx/event.h>
 
@@ -79,7 +78,7 @@ class VisualToolBase {
 	virtual void DoRefresh() { }
 
 protected:
-	std::deque<agi::signal::Connection> connections;
+	std::vector<agi::signal::Connection> connections;
 
 	OpenGLWrapper gl;
 
diff --git a/src/visual_tool_drag.cpp b/src/visual_tool_drag.cpp
index 83bd58899..0e6d21306 100644
--- a/src/visual_tool_drag.cpp
+++ b/src/visual_tool_drag.cpp
@@ -27,7 +27,7 @@
 #include "options.h"
 #include "selection_controller.h"
 #include "utils.h"
-#include "video_context.h"
+#include "video_controller.h"
 #include "video_display.h"
 
 #include <libaegisub/make_unique.h>
@@ -79,7 +79,7 @@ void VisualToolDrag::UpdateToggleButtons() {
 
 void VisualToolDrag::OnSubTool(wxCommandEvent &) {
 	// Toggle \move <-> \pos
-	VideoContext *vc = c->videoController.get();
+	VideoController *vc = c->videoController.get();
 	for (auto line : selection) {
 		Vector2D p1, p2;
 		int t1, t2;
-- 
GitLab