diff options
| -rw-r--r-- | cscript | 1 | ||||
| -rw-r--r-- | platform/osx/make_dmg.sh | 1 | ||||
| -rw-r--r-- | platform/windows/wscript | 2 | ||||
| -rw-r--r-- | src/lib/config.cc | 8 | ||||
| -rw-r--r-- | src/lib/config.h | 18 | ||||
| -rw-r--r-- | src/lib/http_server.cc | 255 | ||||
| -rw-r--r-- | src/lib/http_server.h | 91 | ||||
| -rw-r--r-- | src/lib/wscript | 1 | ||||
| -rw-r--r-- | src/tools/dcpomatic_player.cc | 56 | ||||
| -rw-r--r-- | src/wx/player_config_dialog.cc | 37 | ||||
| -rw-r--r-- | web/index.html | 90 | ||||
| -rw-r--r-- | web/wscript | 5 | ||||
| -rw-r--r-- | wscript | 1 |
13 files changed, 564 insertions, 2 deletions
@@ -721,6 +721,7 @@ def package_windows(target): shutil.copyfile('build/platform/windows/installer.%s.nsi' % identifier, 'build/platform/windows/installer2.%s.nsi' % identifier) target.command('sed -i "s~%%resources%%~%s/platform/windows~g" build/platform/windows/installer2.%s.nsi' % (os.getcwd(), identifier)) target.command('sed -i "s~%%graphics%%~%s/graphics~g" build/platform/windows/installer2.%s.nsi' % (os.getcwd(), identifier)) + target.command('sed -i "s~%%web%%~%s/web~g" build/platform/windows/installer2.%s.nsi' % (os.getcwd(), identifier)) target.command('sed -i "s~%%static_deps%%~%s~g" build/platform/windows/installer2.%s.nsi' % (target.windows_prefix, identifier)) target.command('sed -i "s~%%cdist_deps%%~%s~g" build/platform/windows/installer2.%s.nsi' % (target.directory, identifier)) target.command('sed -i "s~%%mingw%%~%s~g" build/platform/windows/installer2.%s.nsi' % (target.environment_prefix, identifier)) diff --git a/platform/osx/make_dmg.sh b/platform/osx/make_dmg.sh index dd42ebe32..da7c38812 100644 --- a/platform/osx/make_dmg.sh +++ b/platform/osx/make_dmg.sh @@ -272,6 +272,7 @@ function copy_resources { cp $source/graphics/link*.png "$dest" cp $source/graphics/add*.png "$dest" cp $source/graphics/pause*.png "$dest" + cp -r $source/web "$dest" cp -r $prefix/share/libdcp/xsd "$dest" cp -r $prefix/share/libdcp/tags "$dest" cp -r $prefix/share/libdcp/ratings "$dest" diff --git a/platform/windows/wscript b/platform/windows/wscript index 2c53a1422..f64923b2c 100644 --- a/platform/windows/wscript +++ b/platform/windows/wscript @@ -368,6 +368,8 @@ File "%graphics%/add_black.png" File "%graphics%/add_white.png" File "%graphics%/pause_black.png" File "%graphics%/pause_white.png" +SetOutPath "$INSTDIR\\web" +File "%web%/index.html" SetOutPath "$INSTDIR\\xsd" File "%cdist_deps%/share/libdcp/xsd/DCDMSubtitle-2010.xsd" File "%cdist_deps%/share/libdcp/xsd/DCDMSubtitle-2014.xsd" diff --git a/src/lib/config.cc b/src/lib/config.cc index 1ca73c400..180ce5c55 100644 --- a/src/lib/config.cc +++ b/src/lib/config.cc @@ -214,6 +214,8 @@ Config::set_defaults () _last_release_notes_version = boost::none; _allow_smpte_bv20 = false; _isdcf_name_part_length = 14; + _enable_player_http_server = false; + _player_http_server_port = 8080; _allowed_dcp_frame_rates.clear (); _allowed_dcp_frame_rates.push_back (24); @@ -651,6 +653,8 @@ try _allow_smpte_bv20 = f.optional_bool_child("AllowSMPTEBv20").get_value_or(false); _isdcf_name_part_length = f.optional_number_child<int>("ISDCFNamePartLength").get_value_or(14); + _enable_player_http_server = f.optional_bool_child("EnablePlayerHTTPServer").get_value_or(false); + _player_http_server_port = f.optional_number_child<int>("PlayerHTTPServerPort").get_value_or(8080); #ifdef DCPOMATIC_GROK if (auto grok = f.optional_node_child("Grok")) { @@ -1122,6 +1126,10 @@ Config::write_config () const cxml::add_text_child(root, "AllowSMPTEBv20", _allow_smpte_bv20 ? "1" : "0"); /* [XML] ISDCFNamePartLength Maximum length of the "name" part of an ISDCF name, which should be 14 according to the standard */ cxml::add_text_child(root, "ISDCFNamePartLength", raw_convert<string>(_isdcf_name_part_length)); + /* [XML] EnablePlayerHTTPServer 1 to enable a HTTP server to control the player, otherwise 0 */ + cxml::add_text_child(root, "EnablePlayerHTTPServer", _enable_player_http_server ? "1" : "0"); + /* [XML] PlayerHTTPServerPort Port to use for player HTTP server (if enabled) */ + cxml::add_text_child(root, "PlayerHTTPServerPort", raw_convert<string>(_player_http_server_port)); #ifdef DCPOMATIC_GROK if (_grok) { diff --git a/src/lib/config.h b/src/lib/config.h index 397af2222..1f13381b7 100644 --- a/src/lib/config.h +++ b/src/lib/config.h @@ -670,6 +670,14 @@ public: return _isdcf_name_part_length; } + bool enable_player_http_server() const { + return _enable_player_http_server; + } + + int player_http_server_port() const { + return _player_http_server_port; + } + /* SET (mostly) */ void set_master_encoding_threads (int n) { @@ -1209,6 +1217,14 @@ public: maybe_set(_isdcf_name_part_length, length, ISDCF_NAME_PART_LENGTH); } + void set_enable_player_http_server(bool enable) { + maybe_set(_enable_player_http_server, enable); + } + + void set_player_http_server_port(int port) { + maybe_set(_player_http_server_port, port); + } + void changed (Property p = OTHER); boost::signals2::signal<void (Property)> Changed; @@ -1447,6 +1463,8 @@ private: DefaultAddFileLocation _default_add_file_location; bool _allow_smpte_bv20; int _isdcf_name_part_length; + bool _enable_player_http_server; + int _player_http_server_port; #ifdef DCPOMATIC_GROK boost::optional<Grok> _grok; diff --git a/src/lib/http_server.cc b/src/lib/http_server.cc new file mode 100644 index 000000000..0ee62756a --- /dev/null +++ b/src/lib/http_server.cc @@ -0,0 +1,255 @@ +/* + Copyright (C) 2024 Carl Hetherington <cth@carlh.net> + + This file is part of DCP-o-matic. + + DCP-o-matic is free software; you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation; either version 2 of the License, or + (at your option) any later version. + + DCP-o-matic is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with DCP-o-matic. If not, see <http://www.gnu.org/licenses/>. + +*/ + + +#include "cross.h" +#include "dcpomatic_log.h" +#include "dcpomatic_socket.h" +#include "http_server.h" +#include "util.h" +#include "variant.h" +#include <boost/algorithm/string.hpp> +#include <stdexcept> + + +using std::make_pair; +using std::runtime_error; +using std::shared_ptr; +using std::string; +using std::vector; + + +Response Response::ERROR_404 = { 404, "<html><head><title>Error 404</title></head><body><h1>Error 404</h1></body></html>"}; + + +HTTPServer::HTTPServer(int port, int timeout) + : Server(port, timeout) +{ + +} + + + +Response::Response(int code) + : _code(code) +{ + +} + + +Response::Response(int code, string payload) + : _code(code) + , _payload(payload) +{ + +} + + +void +Response::add_header(string key, string value) +{ + _headers.push_back(make_pair(key, value)); +} + + +void +Response::send(shared_ptr<Socket> socket) +{ + socket->write(String::compose("HTTP/1.1 %1 OK\r\n", _code)); + switch (_type) { + case Type::HTML: + socket->write("Content-Type: text/html; charset=utf-8\r\n"); + break; + case Type::JSON: + socket->write("Content-Type: text/json; charset=utf-8\r\n"); + break; + } + socket->write(String::compose("Content-Length: %1\r\n", _payload.length())); + for (auto const& header: _headers) { + socket->write(String::compose("%1: %2\r\n", header.first, header.second)); + } + socket->write("\r\n"); + socket->write(_payload); +} + + +Response +HTTPServer::get(string const& url) +{ + if (url == "/") { + return Response(200, String::compose(dcp::file_to_string(resources_path() / "web" / "index.html"), variant::dcpomatic_player())); + } else if (url == "/api/v1/status") { + auto json = string{"{ "}; + { + boost::mutex::scoped_lock lm(_mutex); + json += String::compose("\"playing\": %1, ", _playing ? "true" : "false"); + json += String::compose("\"position\": \"%1\", ", seconds_to_hms(_position.seconds())); + json += String::compose("\"dcp_name\": \"%1\"", _dcp_name); + } + json += " }"; + auto response = Response(200, json); + response.set_type(Response::Type::JSON); + return response; + } else { + LOG_HTTP("404 %1", url); + return Response::ERROR_404; + } +} + + +Response +HTTPServer::post(string const& url) +{ + if (url == "/api/v1/play") { + emit(boost::bind(boost::ref(Play))); + auto response = Response(303); + response.add_header("Location", "/"); + return response; + } else if (url == "/api/v1/stop") { + emit(boost::bind(boost::ref(Stop))); + auto response = Response(303); + response.add_header("Location", "/"); + return response; + } else { + return Response::ERROR_404; + } +} + + +Response +HTTPServer::request(vector<string> const& request) +{ + vector<string> parts; + boost::split(parts, request[0], boost::is_any_of(" ")); + if (parts.size() != 3) { + return Response::ERROR_404; + } + + try { + if (parts[0] == "GET") { + LOG_HTTP("GET %1", parts[1]); + return get(parts[1]); + } else if (parts[0] == "POST") { + LOG_HTTP("POST %1", parts[1]); + return post(parts[1]); + } + } catch (std::exception& e) { + LOG_ERROR("Error while handling HTTP request: %1", e.what()); + } catch (...) { + LOG_ERROR_NC("Unknown exception while handling HTTP request"); + } + + LOG_HTTP("404 %1", parts[0]); + return Response::ERROR_404; +} + + +void +HTTPServer::handle(shared_ptr<Socket> socket) +{ + class Reader + { + public: + void read_block(boost::system::error_code const& ec, uint8_t* data, std::size_t size) + { + if (ec.value() != boost::system::errc::success) { + _close = true; + _error_code = ec; + return; + } + + for (std::size_t i = 0; i < size; ++i) { + if (_line.length() >= 1024) { + _close = true; + return; + } + _line += data[i]; + if (_line.length() >= 2 && _line.substr(_line.length() - 2) == "\r\n") { + if (_line.length() == 2) { + _got_request = true; + return; + } else if (_request.size() > 64) { + _close = true; + return; + } else if (_line.size() >= 2) { + _line = _line.substr(0, _line.length() - 2); + } + LOG_HTTP("Receive: %1", _line); + _request.push_back(_line); + _line = ""; + } + } + } + + + bool got_request() const { + return _got_request; + } + + bool close() const { + return _close; + } + + boost::system::error_code error_code() const { + return _error_code; + } + + vector<std::string> const& get() const { + return _request; + } + + private: + std::string _line; + vector<std::string> _request; + bool _got_request = false; + bool _close = false; + boost::system::error_code _error_code; + }; + + while (true) { + + Reader reader; + + vector<uint8_t> buffer(2048); + socket->socket().async_read_some( + boost::asio::buffer(buffer.data(), buffer.size()), + [&reader, &buffer, socket](boost::system::error_code const& ec, std::size_t bytes_transferred) { + socket->set_deadline_from_now(1); + reader.read_block(ec, buffer.data(), bytes_transferred); + }); + + while (!reader.got_request() && !reader.close() && socket->is_open()) { + socket->run(); + } + + if (reader.got_request() && !reader.close()) { + try { + auto response = request(reader.get()); + response.send(socket); + } catch (runtime_error& e) { + LOG_ERROR_NC(e.what()); + } + } + + if (reader.close()) { + break; + } + } +} diff --git a/src/lib/http_server.h b/src/lib/http_server.h new file mode 100644 index 000000000..f0ee50ef5 --- /dev/null +++ b/src/lib/http_server.h @@ -0,0 +1,91 @@ +/* + Copyright (C) 2024 Carl Hetherington <cth@carlh.net> + + This file is part of DCP-o-matic. + + DCP-o-matic is free software; you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation; either version 2 of the License, or + (at your option) any later version. + + DCP-o-matic is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with DCP-o-matic. If not, see <http://www.gnu.org/licenses/>. + +*/ + + +#include "dcpomatic_time.h" +#include "server.h" +#include "signaller.h" +#include <boost/signals2.hpp> + + +class Response +{ +public: + Response(int code); + Response(int code, std::string payload); + + enum class Type { + HTML, + JSON + }; + + void add_header(std::string key, std::string value); + void set_type(Type type) { + _type = type; + } + + void send(std::shared_ptr<Socket> socket); + + static Response ERROR_404; + +private: + int _code; + + Type _type = Type::HTML; + std::string _payload; + std::vector<std::pair<std::string, std::string>> _headers; +}; + + +class HTTPServer : public Server, public Signaller +{ +public: + explicit HTTPServer(int port, int timeout = 30); + + boost::signals2::signal<void ()> Play; + boost::signals2::signal<void ()> Stop; + + void set_playing(bool playing) { + boost::mutex::scoped_lock lm(_mutex); + _playing = playing; + } + + void set_position(dcpomatic::DCPTime position) { + boost::mutex::scoped_lock lm(_mutex); + _position = position; + } + + void set_dcp_name(std::string name) { + boost::mutex::scoped_lock lm(_mutex); + _dcp_name = name; + } + +private: + void handle(std::shared_ptr<Socket> socket) override; + Response request(std::vector<std::string> const& request); + Response get(std::string const& url); + Response post(std::string const& url); + + boost::mutex _mutex; + bool _playing = false; + dcpomatic::DCPTime _position; + std::string _dcp_name; +}; + diff --git a/src/lib/wscript b/src/lib/wscript index 1c53538a1..dfe3ce487 100644 --- a/src/lib/wscript +++ b/src/lib/wscript @@ -127,6 +127,7 @@ sources = """ frame_rate_change.cc guess_crop.cc hints.cc + http_server.cc id.cc internet.cc image.cc diff --git a/src/tools/dcpomatic_player.cc b/src/tools/dcpomatic_player.cc index f6e052b41..55cd00027 100644 --- a/src/tools/dcpomatic_player.cc +++ b/src/tools/dcpomatic_player.cc @@ -51,6 +51,7 @@ #include "lib/file_log.h" #include "lib/film.h" #include "lib/font_config.h" +#include "lib/http_server.h" #include "lib/image.h" #include "lib/image_jpeg.h" #include "lib/image_png.h" @@ -312,12 +313,14 @@ public: _stress.LoadDCP.connect (boost::bind(&DOMFrame::load_dcp, this, _1)); setup_internal_player_server(); + setup_http_server(); SetDropTarget(new DCPDropTarget(this)); } ~DOMFrame () { + stop_http_server(); /* It's important that this is stopped before our frame starts destroying its children, * otherwise UI elements that it depends on will disappear from under it. */ @@ -539,6 +542,25 @@ public: _stress.load_script (path); } + void idle() + { + if (_http_server) { + struct timeval now; + gettimeofday(&now, 0); + auto time_since_last_update = (now.tv_sec + now.tv_usec / 1e6) - (_last_http_server_update.tv_sec + _last_http_server_update.tv_usec / 1e6); + if (time_since_last_update > 0.25) { + _http_server->set_playing(_viewer.playing()); + if (auto dcp = _viewer.dcp()) { + _http_server->set_dcp_name(dcp->name()); + } else { + _http_server->set_dcp_name(""); + } + _http_server->set_position(_viewer.position()); + _last_http_server_update = now; + } + } + } + private: void examine_content () @@ -1040,6 +1062,34 @@ private: } update_from_config (prop); + + setup_http_server(); + } + + void stop_http_server() + { + if (_http_server) { + _http_server->stop(); + _http_server_thread.join(); + _http_server.reset(); + } + } + + void setup_http_server() + { + stop_http_server(); + + auto config = Config::instance(); + try { + if (config->enable_player_http_server()) { + _http_server.reset(new HTTPServer(config->player_http_server_port())); + _http_server->Play.connect(boost::bind(&FilmViewer::start, &_viewer)); + _http_server->Stop.connect(boost::bind(&FilmViewer::stop, &_viewer)); + _http_server_thread = boost::thread(boost::bind(&HTTPServer::run, _http_server.get())); + } + } catch (std::exception& e) { + LOG_DEBUG_PLAYER("Failed to start player HTTP server (%1)", e.what()); + } } void setup_internal_player_server() @@ -1172,6 +1222,9 @@ private: PlayerStressTester _stress; /** KDMs that have been loaded, so that we can pass them to the verifier */ std::vector<boost::filesystem::path> _kdms; + boost::thread _http_server_thread; + std::unique_ptr<HTTPServer> _http_server; + struct timeval _last_http_server_update = { 0, 0 }; }; static const wxCmdLineEntryDesc command_line_description[] = { @@ -1355,6 +1408,9 @@ private: void idle () { signal_manager->ui_idle (); + if (_frame) { + _frame->idle(); + } } void config_failed_to_load () diff --git a/src/wx/player_config_dialog.cc b/src/wx/player_config_dialog.cc index d7bba4dcc..b2a02be24 100644 --- a/src/wx/player_config_dialog.cc +++ b/src/wx/player_config_dialog.cc @@ -86,13 +86,19 @@ public: private: void setup () override { - wxGridBagSizer* table = new wxGridBagSizer (DCPOMATIC_SIZER_X_GAP, DCPOMATIC_SIZER_Y_GAP); + auto table = new wxGridBagSizer (DCPOMATIC_SIZER_X_GAP, DCPOMATIC_SIZER_Y_GAP); _panel->GetSizer()->Add (table, 1, wxALL | wxEXPAND, _border); int r = 0; add_language_controls (table, r); add_update_controls (table, r); + _enable_http_server = new CheckBox(_panel, _("Enable HTTP control interface on port")); + table->Add(_enable_http_server, wxGBPosition(r, 0)); + _http_server_port = new wxSpinCtrl(_panel); + table->Add(_http_server_port, wxGBPosition(r, 1)); + ++r; + add_label_to_sizer (table, _panel, _("Start player as"), true, wxGBPosition(r, 0)); _player_mode = new wxChoice (_panel, wxID_ANY); _player_mode->Append (_("window")); @@ -115,7 +121,7 @@ private: table->Add (_video_display_mode, wxGBPosition(r, 1)); ++r; - wxStaticText* restart = add_label_to_sizer(table, _panel, variant::wx::insert_dcpomatic_player(_("(restart %s to change display mode)")), false, wxGBPosition(r, 0)); + auto restart = add_label_to_sizer(table, _panel, variant::wx::insert_dcpomatic_player(_("(restart %s to change display mode)")), false, wxGBPosition(r, 0)); wxFont font = restart->GetFont(); font.SetStyle (wxFONTSTYLE_ITALIC); font.SetPointSize (font.GetPointSize() - 1); @@ -136,6 +142,11 @@ private: _video_display_mode->Bind (wxEVT_CHOICE, bind(&PlayerGeneralPage::video_display_mode_changed, this)); _respect_kdm->bind(&PlayerGeneralPage::respect_kdm_changed, this); _debug_log_file->Bind (wxEVT_FILEPICKER_CHANGED, bind(&PlayerGeneralPage::debug_log_file_changed, this)); + _enable_http_server->bind(&PlayerGeneralPage::enable_http_server_changed, this); + _http_server_port->SetRange(1, 32767); + _http_server_port->Bind(wxEVT_SPINCTRL, boost::bind(&PlayerGeneralPage::http_server_port_changed, this)); + + setup_sensitivity(); } void config_changed () override @@ -170,6 +181,11 @@ private: if (config->player_debug_log_file()) { checked_set (_debug_log_file, *config->player_debug_log_file()); } + + checked_set(_enable_http_server, config->enable_player_http_server()); + checked_set(_http_server_port, config->player_http_server_port()); + + setup_sensitivity(); } private: @@ -214,11 +230,28 @@ private: } } + void enable_http_server_changed() + { + Config::instance()->set_enable_player_http_server(_enable_http_server->get()); + } + + void http_server_port_changed() + { + Config::instance()->set_player_http_server_port(_http_server_port->GetValue()); + } + + void setup_sensitivity() + { + _http_server_port->Enable(_enable_http_server->get()); + } + wxChoice* _player_mode; wxChoice* _image_display; wxChoice* _video_display_mode; CheckBox* _respect_kdm; FilePickerCtrl* _debug_log_file; + CheckBox* _enable_http_server; + wxSpinCtrl* _http_server_port; }; diff --git a/web/index.html b/web/index.html new file mode 100644 index 000000000..0eea15aab --- /dev/null +++ b/web/index.html @@ -0,0 +1,90 @@ +<!DOCTYPE html> +<html> +<script> + setInterval(function() { + status = fetch("/api/v1/status").then(response => { + response.json().then(data => { + if (data.playing) { + document.getElementById('playing').innerHTML = "Playing"; + } else { + document.getElementById('playing').innerHTML = "Stopped"; + } + document.getElementById('dcp_name').innerHTML = data.dcp_name; + document.getElementById('position').innerHTML = data.position; + }); + }); + }, 250); + function play() { + fetch("/api/v1/play", { method: "POST" }); + } + function stop() { + fetch("/api/v1/stop", { method: "POST" }); + } +</script> +<style> +button { + border: 1px solid rgba(27, 31, 35, 0.15); + border-radius: 6px; + color: #24292E; + display: inline-block; + line-height: 20px; + padding: 6px 16px; + vertical-align: middle; + white-space: nowrap; + word-wrap: break-word; +} + +button:hover { + background-color: #F3F4F6; + text-decoration: none; + transition-duration: 0.1s; +} + +button:active { + background-color: #EDEFF2; + box-shadow: rgba(225, 228, 232, 0.2) 0 1px 0 inset; + transition: none 0s; +} + +button:focus { + outline: 1px transparent; +} + +button:before { + display: none; +} + +table { + border-collapse: collapse; + margin: 25px 0; + font-size: 0.9em; + box-shadow: 0 0 20px rgba(0, 0, 0, 0.15); + border: 2px solid black; +} + +tr { + text-align: left; + border: 1px solid black; +} + +td { + padding: 4px 16px; + text-align: left; + border: 1px solid black; +} + + +</style> + <head> + <title>%1</title> + </head> + <body> + <button name="play" value="play" onclick="play()">Play</button> + <button name="Stop" value="Stop" onclick="stop()">Stop</button> + <table> + <tr><td>DCP</td><td><p id="dcp_name"></td></tr> + <tr><td>State</td><td><p id="playing"></td></tr> + <tr><td>Position</td><td><p id="position"></td></tr> + </table> + </body> +</html> diff --git a/web/wscript b/web/wscript new file mode 100644 index 000000000..d9ebc76ab --- /dev/null +++ b/web/wscript @@ -0,0 +1,5 @@ + +def build(bld): + for file in ('index.html',): + bld.install_files('${PREFIX}/share/dcpomatic2/web', file) + @@ -706,6 +706,7 @@ def build(bld): bld.recurse('src') bld.recurse('graphics') + bld.recurse('web') if not bld.env.DISABLE_TESTS: bld.recurse('test') |
