diff --git a/include/xstudio/atoms.hpp b/include/xstudio/atoms.hpp index a172d1c17..b3aa32325 100644 --- a/include/xstudio/atoms.hpp +++ b/include/xstudio/atoms.hpp @@ -605,6 +605,7 @@ CAF_BEGIN_TYPE_ID_BLOCK(xstudio_session_atoms, FIRST_CUSTOM_ID + (200 * 4)) CAF_ADD_ATOM(xstudio_session_atoms, xstudio::bookmark, default_category_atom) CAF_ADD_ATOM(xstudio_session_atoms, xstudio::bookmark, get_annotation_atom) CAF_ADD_ATOM(xstudio_session_atoms, xstudio::bookmark, annotation_data_atom) + CAF_ADD_ATOM(xstudio_session_atoms, xstudio::bookmark, laser_stroke_atom) CAF_ADD_ATOM(xstudio_session_atoms, xstudio::bookmark, get_bookmark_atom) CAF_ADD_ATOM(xstudio_session_atoms, xstudio::bookmark, get_bookmarks_atom) CAF_ADD_ATOM(xstudio_session_atoms, xstudio::bookmark, remove_bookmark_atom) diff --git a/include/xstudio/media/media_actor.hpp b/include/xstudio/media/media_actor.hpp index c3275bcb3..38b14631c 100644 --- a/include/xstudio/media/media_actor.hpp +++ b/include/xstudio/media/media_actor.hpp @@ -97,19 +97,25 @@ class MediaSourceActor : public caf::event_based_actor { const caf::uri &_uri, const utility::FrameList &frame_list, const utility::FrameRate &rate = utility::FrameRate(timebase::k_flicks_24fps), - const utility::Uuid &uuid = utility::Uuid()); + const utility::Uuid &uuid = utility::Uuid(), + const utility::JsonStore &initial_json = + utility::JsonStore(R"({"colour_pipeline": {}})"_json)); MediaSourceActor( caf::actor_config &cfg, const std::string &name, const std::string &reader, const utility::MediaReference &media_reference, - const utility::Uuid &uuid = utility::Uuid()); + const utility::Uuid &uuid = utility::Uuid(), + const utility::JsonStore &initial_json = + utility::JsonStore(R"({"colour_pipeline": {}})"_json)); MediaSourceActor( caf::actor_config &cfg, const std::string &name = "Unnamed", const caf::uri &_uri = caf::uri(), const utility::FrameRate &rate = utility::FrameRate(timebase::k_flicks_24fps), - const utility::Uuid &uuid = utility::Uuid()); + const utility::Uuid &uuid = utility::Uuid(), + const utility::JsonStore &initial_json = + utility::JsonStore(R"({"colour_pipeline": {}})"_json)); MediaSourceActor(caf::actor_config &cfg, const utility::JsonStore &jsn); ~MediaSourceActor() override = default; diff --git a/include/xstudio/media_reader/audio_buffer.hpp b/include/xstudio/media_reader/audio_buffer.hpp index b3e481201..fe318c858 100644 --- a/include/xstudio/media_reader/audio_buffer.hpp +++ b/include/xstudio/media_reader/audio_buffer.hpp @@ -58,7 +58,7 @@ class AudioBuffer : public Buffer { }*/ void stretch_samples(const uint64_t new_num_samples); - + void set_new_sample_rate(const uint64_t new_sample_rate, const timebase::flicks &exact_duration); diff --git a/include/xstudio/media_reader/cacheing_media_reader_actor.hpp b/include/xstudio/media_reader/cacheing_media_reader_actor.hpp index 04b90558d..36548a1e1 100644 --- a/include/xstudio/media_reader/cacheing_media_reader_actor.hpp +++ b/include/xstudio/media_reader/cacheing_media_reader_actor.hpp @@ -57,7 +57,7 @@ class CachingMediaReaderActor : public caf::event_based_actor { bool urgent_worker_busy_ = {false}; caf::actor pick_precache_worker() { - auto r = precache_workers_[next_precache_worker_]; + auto r = precache_workers_[next_precache_worker_]; next_precache_worker_ = (next_precache_worker_ + 1) % precache_workers_.size(); return r; } diff --git a/include/xstudio/media_reader/frame_request_queue.hpp b/include/xstudio/media_reader/frame_request_queue.hpp index fcdca7591..4576eaaea 100644 --- a/include/xstudio/media_reader/frame_request_queue.hpp +++ b/include/xstudio/media_reader/frame_request_queue.hpp @@ -83,8 +83,9 @@ class FrameRequestQueue { * @brief Get the next ordered frame request * */ - std::optional - pop_request(const std::map &exclude_playheads, const size_t max_num_inflight_requests); + std::optional pop_request( + const std::map &exclude_playheads, + const size_t max_num_inflight_requests); /** * @brief Add a request to the queue diff --git a/include/xstudio/media_reader/media_reader.hpp b/include/xstudio/media_reader/media_reader.hpp index 12c642d87..0941dfc69 100644 --- a/include/xstudio/media_reader/media_reader.hpp +++ b/include/xstudio/media_reader/media_reader.hpp @@ -142,7 +142,8 @@ namespace media_reader { // this allows the cacheing m,edia reader actor to find out if // it can spawn multiple readers to read a given source in parallel or not. // For example, EXR benefits from multiple readers while ffmpeg does not - // because motion compressed video formats are not well served by multiple readers. + // because motion compressed video formats are not well served by multiple + // readers. return media_reader_.prefer_sequential_access(); }, @@ -208,19 +209,20 @@ namespace media_reader { [=](media_reader::get_thumbnail_atom, media::AVFrameID mptr, const size_t thumb_size) -> result { - try { if (mptr.stream_id() == "auto video") { - // special case where we don't have a full Media object but - // need a thumbnail (FileBrowser plugin, for example). We + // special case where we don't have a full Media object but + // need a thumbnail (FileBrowser plugin, for example). We // must pick the middle frame for the stream and the first // video stream in the set. - const auto stream_details = media_reader_.detail(mptr.uri()).streams_; + const auto stream_details = + media_reader_.detail(mptr.uri()).streams_; for (const auto &sd : stream_details) { if (sd.media_type_ == media::MediaType::MT_IMAGE) { - mptr = media::AVFrameID(mptr.uri(), - sd.duration_.frames()/2, + mptr = media::AVFrameID( + mptr.uri(), + sd.duration_.frames() / 2, mptr.first_frame(), mptr.frame_status(), 0, diff --git a/include/xstudio/module/attribute_role_data.hpp b/include/xstudio/module/attribute_role_data.hpp index 8cdffc798..7ef30e1f9 100644 --- a/include/xstudio/module/attribute_role_data.hpp +++ b/include/xstudio/module/attribute_role_data.hpp @@ -241,7 +241,8 @@ class AttributeData { rt = true; } else { std::stringstream msg; - msg << "Attribute role data is type " << data_.type().name() << " doesn't match incoming data."; + msg << "Attribute role data is type " << data_.type().name() + << " doesn't match incoming data."; throw std::runtime_error(msg.str().c_str()); } return rt; diff --git a/include/xstudio/ui/qml/thumbnail_provider_ui.hpp b/include/xstudio/ui/qml/thumbnail_provider_ui.hpp index 889937ba3..47fef8bb1 100644 --- a/include/xstudio/ui/qml/thumbnail_provider_ui.hpp +++ b/include/xstudio/ui/qml/thumbnail_provider_ui.hpp @@ -84,7 +84,6 @@ class ThumbnailResponse : public QQuickImageResponse { auto [i, e] = watcher_.result(); if (not e.isEmpty()) { - qDebug() << e; error_ = "Thumbnail does not exist 2."; bad_thumbs_.insert(id_, QDateTime::currentDateTime()); } else { diff --git a/include/xstudio/utility/helpers.hpp b/include/xstudio/utility/helpers.hpp index e0fbfada2..5b987bad1 100644 --- a/include/xstudio/utility/helpers.hpp +++ b/include/xstudio/utility/helpers.hpp @@ -317,7 +317,8 @@ void print_on_exit( // std::string filemanager_show_uris(const std::vector &uris); -caf::uri posix_path_to_uri(const std::string &path, const bool abspath = false, const bool remap = true); +caf::uri +posix_path_to_uri(const std::string &path, const bool abspath = false, const bool remap = true); caf::uri parse_cli_posix_path(const std::string &path, FrameList &frame_list, const bool scan = false); @@ -355,7 +356,7 @@ std::string uri_decode(const std::string &eString); // this is WRONG on purpose, as caf::uri are buggy. // the path component needs to be escaped, even when it's a file:: std::string uri_encode(const std::string &s); -std::string uri_to_posix_path(const caf::uri &uri, const bool remap=true); +std::string uri_to_posix_path(const caf::uri &uri, const bool remap = true); // can only get signature for posix urls.. inline std::array get_signature(const caf::uri &uri) { @@ -673,9 +674,9 @@ void add_remap_file_path(const std::string &from, const std::string &to); // ] void setup_filepath_remap_regex(const utility::JsonStore &); - // MD5 hash of a byte buffer. Returns the 16-byte digest. - // Uses OpenSSL EVP API on OpenSSL >= 1.1.0 and falls back to the legacy - // MD5_* API on older versions. - std::array md5_hash(const void *data, std::size_t size); +// MD5 hash of a byte buffer. Returns the 16-byte digest. +// Uses OpenSSL EVP API on OpenSSL >= 1.1.0 and falls back to the legacy +// MD5_* API on older versions. +std::array md5_hash(const void *data, std::size_t size); } // namespace xstudio::utility \ No newline at end of file diff --git a/include/xstudio/utility/path_remapper.hpp b/include/xstudio/utility/path_remapper.hpp index 0e340b660..9689408ba 100644 --- a/include/xstudio/utility/path_remapper.hpp +++ b/include/xstudio/utility/path_remapper.hpp @@ -14,7 +14,7 @@ namespace xstudio::utility { class PathRemapper { public: - PathRemapper() = default; + PathRemapper() = default; virtual ~PathRemapper() = default; std::string forwards(const std::string &path); @@ -25,15 +25,15 @@ class PathRemapper { void add_path_mapping(const std::string &from, const std::string &to); private: - std::string remap(const std::string &path, const bool forwards); + std::string remap(const std::string &path, const bool forwards); - std::mutex mutex_; + std::mutex mutex_; - std::vector> forward_regex_; - std::vector> backward_regex_; + std::vector> forward_regex_; + std::vector> backward_regex_; - std::map forward_map_; - std::map backward_map_; + std::map forward_map_; + std::map backward_map_; }; -} \ No newline at end of file +} // namespace xstudio::utility \ No newline at end of file diff --git a/include/xstudio/utility/string_helpers.hpp b/include/xstudio/utility/string_helpers.hpp index 249f37055..813ff333b 100644 --- a/include/xstudio/utility/string_helpers.hpp +++ b/include/xstudio/utility/string_helpers.hpp @@ -155,28 +155,34 @@ std::string make_hex_string( } template -inline std::string join_as_string(const std::vector &items, const std::string &separator) { +inline std::string join_as_string( + const std::vector &items, const std::string &separator, const bool skip_empty = false) { std::string result; - if (!items.empty()) { - result = to_string(items[0]); - if (items.size() > 1) { - for (size_t i = 1; i < items.size(); i++) - result += separator + to_string(items[i]); - } + + for (const auto &i : items) { + const auto i_as_string = to_string(i); + if (skip_empty and i_as_string.empty()) + continue; + if (not result.empty()) + result += separator; + result += i_as_string; } return result; } -inline std::string -join_as_string(const std::vector &items, const std::string &separator) { +inline std::string join_as_string( + const std::vector &items, + const std::string &separator, + const bool skip_empty = false) { std::string result; - if (!items.empty()) { - result = items[0]; - if (items.size() > 1) { - for (size_t i = 1; i < items.size(); i++) - result += separator + items[i]; - } + + for (const auto &i : items) { + if (skip_empty and i.empty()) + continue; + if (not result.empty()) + result += separator; + result += i; } return result; diff --git a/python/CMakeLists.txt b/python/CMakeLists.txt index ef4e1e41e..f7cdfa3da 100644 --- a/python/CMakeLists.txt +++ b/python/CMakeLists.txt @@ -67,7 +67,7 @@ else() else() add_custom_command(OUTPUT ${OUTPUT}/lib/${PYTHONVP}/site-packages/xstudio/api COMMAND ${Python_EXECUTABLE} - ARGS setup.py install --old-and-unmanageable --prefix=${OUTPUT} + ARGS -m pip install . --prefix=${OUTPUT} DEPENDS __pybind_xstudio ${CMAKE_CURRENT_SOURCE_DIR}/setup.py.in) endif() diff --git a/python/src/xstudio/api/module.py b/python/src/xstudio/api/module.py index acfe13426..01d1f2c4e 100644 --- a/python/src/xstudio/api/module.py +++ b/python/src/xstudio/api/module.py @@ -207,6 +207,7 @@ def __init__( self.menu_item_ids = [] self.hotkey_callbacks = {} self._uuid = None + self.message_handler_id = None for attr_uuid in attr_uuids: attr_wrapper = ModuleAttribute( @@ -221,10 +222,15 @@ def setup_message_handler(self): # this call gets the event group for Module attribute change events remote_event_group = self.connection.request_receive(self.remote, get_event_group_atom(), True)[0] - self.connection.link.add_message_callback( + self.message_handler_id = self.connection.link.add_message_callback( remote_event_group, self.message_handler ) + def cleanup_message_handler(self): + if self.message_handler_id is not None: + self.connection.link.remove_message_callback(self.message_handler_id) + self.message_handler_id = None + def connect_to_ui(self): """Call this method to 'activate' the plugin and forward live data about the attributes to the UI layer. QML code that builds widgets and so-on diff --git a/python/src/xstudio/api/session/playhead/playhead_selection.py b/python/src/xstudio/api/session/playhead/playhead_selection.py index 46e08cc70..b8cdf393a 100644 --- a/python/src/xstudio/api/session/playhead/playhead_selection.py +++ b/python/src/xstudio/api/session/playhead/playhead_selection.py @@ -1,5 +1,5 @@ # SPDX-License-Identifier: Apache-2.0 -from xstudio.core import monitored_atom, Uuid, MT_IMAGE, MT_AUDIO +from xstudio.core import monitored_atom, Uuid, MT_IMAGE, MT_AUDIO, VectorUuid from xstudio.core import move_atom, remove_atom, actor, select_all_media_atom from xstudio.core import select_media_atom, get_selected_sources_atom @@ -67,9 +67,7 @@ def set_selection(self, media_uuids): Returns: success(bool): Selection set. """ - self.connection.request_receive(self.remote, select_media_atom()) - for media_uuid in media_uuids: - self.connection.request_receive(self.remote, select_media_atom(), media_uuid) + self.connection.request_receive(self.remote, select_media_atom(), VectorUuid(media_uuids)) return True def insert(self, media, before=Uuid(), media_type=MT_IMAGE): diff --git a/python/src/xstudio/api/session/playlist/playlist.py b/python/src/xstudio/api/session/playlist/playlist.py index ec40d4625..6793482bb 100644 --- a/python/src/xstudio/api/session/playlist/playlist.py +++ b/python/src/xstudio/api/session/playlist/playlist.py @@ -115,7 +115,7 @@ def add_media_with_audio(self, image_path, audio_path, audio_offset=0): return media - def add_media(self, path, frame_list=None): + def add_media(self, path, frame_list=None, before_uuid=Uuid()): """Add media from path. Args: @@ -125,16 +125,16 @@ def add_media(self, path, frame_list=None): media(Media): Media. """ if isinstance(path, URI): - result = self.connection.request_receive(self.remote, add_media_atom(), path, False, Uuid())[0] + result = self.connection.request_receive(self.remote, add_media_atom(), path, False, before_uuid)[0] else: ppp = parse_posix_path(path) name = Path(path).name.split(".", 1)[0] # this is to match what happens internally with a URI above if not str(ppp[1]) and frame_list is None: - result = self.connection.request_receive(self.remote, add_media_atom(), name, ppp[0], Uuid())[0] + result = self.connection.request_receive(self.remote, add_media_atom(), path, ppp[0], before_uuid)[0] else: result = self.connection.request_receive( - self.remote, add_media_atom(), name, ppp[0], ppp[1] if frame_list is None else frame_list, Uuid() + self.remote, add_media_atom(), path, ppp[0], ppp[1] if frame_list is None else frame_list, before_uuid )[0] return Media(self.connection, result.actor, result.uuid) diff --git a/python/src/xstudio/api/session/session.py b/python/src/xstudio/api/session/session.py index 8d4f48032..08eb33b91 100644 --- a/python/src/xstudio/api/session/session.py +++ b/python/src/xstudio/api/session/session.py @@ -102,6 +102,8 @@ def viewed_container(self): """ result = self.connection.request_receive(self.remote, viewport_active_media_container_atom())[0] + if not result.actor: + return None c = Container(self.connection, result.actor) if c.type == "Timeline": @@ -232,7 +234,7 @@ def playlists(self): def create_hidden_playlist(self, name="Hidden Playlist"): """Create hidden playlist. This playlist can be used to put transient - media on screen (e.g. preview mode of File System Browser plugin). The + media on screen (e.g. preview mode of File Browser plugin). The playlist is not added to the session and is not visible in the playlists interface and is not serialised into the session file. diff --git a/python/src/xstudio/plugin/plugin_base.py b/python/src/xstudio/plugin/plugin_base.py index 982fa5086..ab19daabd 100644 --- a/python/src/xstudio/plugin/plugin_base.py +++ b/python/src/xstudio/plugin/plugin_base.py @@ -5,7 +5,7 @@ from xstudio.api.auxiliary.helpers import get_event_group from xstudio.core import spawn_plugin_base_atom, viewport_playhead_atom from xstudio.core import get_global_playhead_events_atom, show_message_box_atom -from xstudio.core import plugin_events_group_atom, get_event_group_atom +from xstudio.core import plugin_events_group_atom, get_event_group_atom, event_atom import sys import os import traceback @@ -53,6 +53,8 @@ def __init__( self.playhead_subscriptions = [] self.user_attr_handler_ = None + self.__image_source_attr_id = None + self.__current_playhead = None def add_attribute( self, @@ -206,39 +208,71 @@ def get_plugin( raise RuntimeError("No plugin found with name \"{}\"".format(plugin_name)) - def subscribe_to_playhead_events(self, playhead, callback_method, auto_cancel=True): - """Set-up a subscription to the events of an xstudio playhead. Can - automatically cancel previous subscription(s) + def __playhead_attribute_changed(self, attr, role): + try: + if attr.uuid == self.__image_source_attr_id: + self.on_screen_media_changed(self.__current_playhead.on_screen_media) + except: + pass + self.playhead_attribute_changed(attr, role) - Args: - plugin(actor): The playhead that we want to watch - callback_method(Callable): The function which will be called - with playhead events. Must take a single argument (a tuple of the event - data) + def playhead_attribute_changed(self, attribute_name, attribute_value): + """Re-implement this method in your plugin to handle changes to the + current global playhead attributes. This method will be called when + the current global playhead changes its attributes (e.g. current frame, + play mode, source etc.). See 'subscribe_to_playhead_events' for more + details. - Returns: - uuid (callback id): A uuid for the subscription. Pass to - unsubscribe_from_event_group to cancel an event subscription + Args: + attribute_name(str): The name of the changed attribute + attribute_value: The new value of the changed attribute """ - event_group = self.connection.request_receive( - playhead, - get_event_group_atom())[0] + pass - if not event_group: - raise Exception("Actor has no event group.") + def on_screen_media_changed(self, media): + """Re-implement this method in your plugin to handle changes to the + current on-screen media. This method will be called when the primary + on-screen image source changes. See 'subscribe_to_playhead_events' for more + details. - if auto_cancel and self.playhead_subscriptions: - for sub in self.playhead_subscriptions: - self.unsubscribe_from_event_group(sub) - self.playhead_subscriptions = [] - - subscription_id = self.connection.link.add_message_callback( - event_group, callback_method - ) - - self.playhead_subscriptions.append(subscription_id) + Args: + media(Media): The new on-screen media + """ + pass + + def __connect_to_playhead(self, playhead_remote): + if self.__current_playhead: + self.__current_playhead.cleanup_message_handler() + if playhead_remote: + self.__current_playhead = Playhead(self.connection, playhead_remote) + self.__image_source_attr_id = self.__current_playhead.get_attribute("Source").uuid + self.__current_playhead.attribute_changed = self.__playhead_attribute_changed + + def __playhead_event_handler(self, event_args): + # watch for the event that tells us when the global (main playhead) has changed. + if (len(event_args) == 3 and + isinstance(event_args[0], event_atom) and + isinstance(event_args[1], viewport_playhead_atom)): + self.__connect_to_playhead(event_args[2]) + + def subscribe_to_playhead_events(self): + """Set-up a subscription to the events of the current global playhead. + Changes to playhead attributes (like current frame, play mode, source etc.) + will passed to the playhead_attribute_changed method. Re-implement this + method in your plugin to handle playhead events. In addition, when the + (primary) on-screen image source changes, the 'on_screen_media_changed' + method will be called. Re-implement this method in your plugin to + handle on-screen media changes. + """ + self.subscribe_to_global_playhead_events(self.__playhead_event_handler) + gphev = self.connection.request_receive( + self.connection.remote(), + get_global_playhead_events_atom())[0] + current_playhead = self.connection.request_receive( + gphev, + viewport_playhead_atom())[0] + self.__connect_to_playhead(current_playhead) - return subscription_id def subscribe_to_plugin_events(self, plugin, callback_method): """Set-up a subscription to the events of an xstudio plugin. diff --git a/share/preference/plugin_annotations.json b/share/preference/plugin_annotations.json index 8bcfc7905..6f256d415 100644 --- a/share/preference/plugin_annotations.json +++ b/share/preference/plugin_annotations.json @@ -103,6 +103,16 @@ }, "datatype": "json", "context": ["QML_UI"] + }, + "keep_laser_active": { + "path": "/plugin/annotations/keep_laser_active", + "default_value": false, + "description": "When using the annotations tool, keep the laser tool active after the toolbar is hidden. This may be useful if your are working in full-screen mode to preset and you want to make laser strokes but not have the annotations toolbar visible.", + "value": false, + "datatype": "bool", + "display_name": "Laser Pen Active when Toolbar Hidden", + "context": ["APPLICATION"], + "category": "Viewport" } } } diff --git a/src/colour_pipeline/src/colour_operation.cpp b/src/colour_pipeline/src/colour_operation.cpp index 089b409b1..a18714e4c 100644 --- a/src/colour_pipeline/src/colour_operation.cpp +++ b/src/colour_pipeline/src/colour_operation.cpp @@ -115,7 +115,7 @@ void ColourOpPlugin::onscreen_source_colour_metadata_merge( .then( [=](utility::JsonStore params) { params.merge(additional_colour_params); - if(params.is_null()) + if (params.is_null()) spdlog::error("BAD"); mail(colour_pipeline::set_colour_pipe_params_atom_v, params) .request(on_screen_source_.actor(), infinite) diff --git a/src/http_client/src/http_client_actor.cpp b/src/http_client/src/http_client_actor.cpp index f03d020a0..524a82f86 100644 --- a/src/http_client/src/http_client_actor.cpp +++ b/src/http_client/src/http_client_actor.cpp @@ -78,10 +78,10 @@ HTTPWorker::HTTPWorker( const std::string &body, const std::string &content_type) -> result { - #ifdef LOG_CALLS - spdlog::stopwatch sw; - spdlog::info("http_delete_atom {}", path); - #endif +#ifdef LOG_CALLS + spdlog::stopwatch sw; + spdlog::info("http_delete_atom {}", path); +#endif try { httplib::Client cli(scheme_host_port.c_str()); @@ -99,9 +99,9 @@ HTTPWorker::HTTPWorker( if (res.error() != httplib::Error::Success) return make_error(hce::rest_error, get_error_string(res.error())); - #ifdef LOG_CALLS - spdlog::info("http_delete_atom {} {:.3f}", path, sw); - #endif +#ifdef LOG_CALLS + spdlog::info("http_delete_atom {} {:.3f}", path, sw); +#endif if (res) return *res; @@ -141,10 +141,10 @@ HTTPWorker::HTTPWorker( const httplib::Headers &headers, const httplib::Params ¶ms) -> result { - #ifdef LOG_CALLS - spdlog::stopwatch sw; - spdlog::info("http_get_atom {}", path); - #endif +#ifdef LOG_CALLS + spdlog::stopwatch sw; + spdlog::info("http_get_atom {}", path); +#endif try { httplib::Client cli(scheme_host_port.c_str()); @@ -171,9 +171,9 @@ HTTPWorker::HTTPWorker( return make_error(hce::rest_error, error); } - #ifdef LOG_CALLS - spdlog::info("http_get_atom {} {:.3f}", path, sw); - #endif +#ifdef LOG_CALLS + spdlog::info("http_get_atom {} {:.3f}", path, sw); +#endif if (result) return *result; @@ -215,10 +215,10 @@ HTTPWorker::HTTPWorker( const std::string &body, const std::string &content_type) -> result { - #ifdef LOG_CALLS - spdlog::stopwatch sw; - spdlog::info("http_post_atom {}", path); - #endif +#ifdef LOG_CALLS + spdlog::stopwatch sw; + spdlog::info("http_post_atom {}", path); +#endif try { httplib::Client cli(scheme_host_port.c_str()); @@ -236,9 +236,9 @@ HTTPWorker::HTTPWorker( if (res.error() != httplib::Error::Success) return make_error(hce::rest_error, get_error_string(res.error())); - #ifdef LOG_CALLS - spdlog::info("http_post_atom {} {:.3f}", path, sw); - #endif +#ifdef LOG_CALLS + spdlog::info("http_post_atom {} {:.3f}", path, sw); +#endif if (res) return *res; @@ -281,10 +281,10 @@ HTTPWorker::HTTPWorker( const std::string &body, const std::string &content_type) -> result { - #ifdef LOG_CALLS - spdlog::stopwatch sw; - spdlog::info("http_put_atom {}", path); - #endif +#ifdef LOG_CALLS + spdlog::stopwatch sw; + spdlog::info("http_put_atom {}", path); +#endif try { httplib::Client cli(scheme_host_port.c_str()); @@ -308,9 +308,9 @@ HTTPWorker::HTTPWorker( if (res.error() != httplib::Error::Success) return make_error(hce::rest_error, get_error_string(res.error())); - #ifdef LOG_CALLS - spdlog::info("http_put_atom {} {:.3f}", path, sw); - #endif +#ifdef LOG_CALLS + spdlog::info("http_put_atom {} {:.3f}", path, sw); +#endif if (res) return *res; diff --git a/src/media/src/media_source_actor.cpp b/src/media/src/media_source_actor.cpp index f1db2e17c..f0cf41782 100644 --- a/src/media/src/media_source_actor.cpp +++ b/src/media/src/media_source_actor.cpp @@ -50,7 +50,9 @@ MediaSourceActor::MediaSourceActor(caf::actor_config &cfg, const JsonStore &jsn) : caf::event_based_actor(cfg), base_(static_cast(jsn["base"])), parent_() { if (not jsn.count("store") or jsn["store"].is_null()) { json_store_ = spawn( - utility::Uuid::generate(), utility::JsonStore(R"({"colour_pipeline": {}})"_json), std::chrono::milliseconds(50)); + utility::Uuid::generate(), + utility::JsonStore(R"({"colour_pipeline": {}})"_json), + std::chrono::milliseconds(50)); } else { json_store_ = spawn( utility::Uuid::generate(), @@ -99,13 +101,14 @@ MediaSourceActor::MediaSourceActor( const caf::uri &_uri, const FrameList &frame_list, const utility::FrameRate &rate, - const utility::Uuid &uuid) + const utility::Uuid &uuid, + const utility::JsonStore &initial_json) : caf::event_based_actor(cfg), base_(name, _uri, frame_list), parent_() { if (not uuid.is_null()) base_.set_uuid(uuid); json_store_ = spawn( - utility::Uuid::generate(), utility::JsonStore(R"({"colour_pipeline": {}})"_json), std::chrono::milliseconds(50)); + utility::Uuid::generate(), initial_json, std::chrono::milliseconds(50)); link_to(json_store_); join_event_group(this, json_store_); @@ -122,12 +125,13 @@ MediaSourceActor::MediaSourceActor( const std::string &name, const caf::uri &_uri, const utility::FrameRate &rate, - const utility::Uuid &uuid) + const utility::Uuid &uuid, + const utility::JsonStore &initial_json) : caf::event_based_actor(cfg), base_(name, _uri), parent_() { if (not uuid.is_null()) base_.set_uuid(uuid); json_store_ = spawn( - utility::Uuid::generate(), utility::JsonStore(R"({"colour_pipeline": {}})"_json), std::chrono::milliseconds(50)); + utility::Uuid::generate(), initial_json, std::chrono::milliseconds(50)); link_to(json_store_); join_event_group(this, json_store_); @@ -143,13 +147,15 @@ MediaSourceActor::MediaSourceActor( const std::string &name, const std::string &reader, const utility::MediaReference &media_reference, - const utility::Uuid &uuid) + const utility::Uuid &uuid, + const utility::JsonStore &initial_json) + : caf::event_based_actor(cfg), base_(name, media_reference), parent_() { if (not uuid.is_null()) base_.set_uuid(uuid); base_.set_reader(reader); json_store_ = spawn( - utility::Uuid::generate(), utility::JsonStore(R"({"colour_pipeline": {}})"_json), std::chrono::milliseconds(50)); + utility::Uuid::generate(), initial_json, std::chrono::milliseconds(50)); link_to(json_store_); join_event_group(this, json_store_); @@ -749,7 +755,6 @@ caf::message_handler MediaSourceActor::message_handler() { }, - [=](media_reference_atom, MediaReference mr, bool force_change_signal) -> result { auto rp = make_response_promise(); diff --git a/src/media_hook/src/media_hook_actor.cpp b/src/media_hook/src/media_hook_actor.cpp index 5de2b91f6..169d5e099 100644 --- a/src/media_hook/src/media_hook_actor.cpp +++ b/src/media_hook/src/media_hook_actor.cpp @@ -186,8 +186,11 @@ MediaHookWorkerActor::MediaHookWorkerActor(caf::actor_config &cfg) anon_mail(json_store::merge_json_atom_v, c) .send(media_source); if (c.count("colour_pipeline")) { - if(c["colour_pipeline"].is_null()) - spdlog::error("{} {}", __PRETTY_FUNCTION__, c["colour_pipeline"].dump(2)); + if (c["colour_pipeline"].is_null()) + spdlog::error( + "{} {}", + __PRETTY_FUNCTION__, + c["colour_pipeline"].dump(2)); anon_mail( colour_pipeline:: set_colour_pipe_params_atom_v, diff --git a/src/media_reader/src/cacheing_media_reader_actor.cpp b/src/media_reader/src/cacheing_media_reader_actor.cpp index f2b4be03f..de2c2305b 100644 --- a/src/media_reader/src/cacheing_media_reader_actor.cpp +++ b/src/media_reader/src/cacheing_media_reader_actor.cpp @@ -102,7 +102,8 @@ CachingMediaReaderActor::CachingMediaReaderActor( int worker_count = 1; try { - worker_count = preference_value(js, "/core/media_reader/read_threads_per_source"); + worker_count = + preference_value(js, "/core/media_reader/read_threads_per_source"); } catch (const std::exception &e) { spdlog::warn("Failed to get read_threads_per_source preference: {}", e.what()); } @@ -110,22 +111,28 @@ CachingMediaReaderActor::CachingMediaReaderActor( auto pm = system().registry().template get(plugin_manager_registry); scoped_actor sys{system()}; - precache_workers_.push_back(request_receive( - *sys, pm, plugin_manager::spawn_plugin_atom_v, media_reader_plugin_uuid, js)); + precache_workers_.push_back( + request_receive( + *sys, pm, plugin_manager::spawn_plugin_atom_v, media_reader_plugin_uuid, js)); link_to(precache_workers_.back()); // here's a crucial optimisation. For some media readers, like EXR, we - // can get a big speed increase by spawning multiple reader actors to + // can get a big speed increase by spawning multiple reader actors to // read different frames in parallel. For others, like ffmpeg, it's much // more efficient to use a single reader and request frames sequentially // because common motion based video codecs like h264 are designed to // decode frames in a stream, of course. - const bool prefers_sequential_reads = request_receive( - *sys, precache_workers_.back(), utility::detail_atom_v); + const bool prefers_sequential_reads = + request_receive(*sys, precache_workers_.back(), utility::detail_atom_v); if (!prefers_sequential_reads) { for (int i = 1; i < worker_count; ++i) { - precache_workers_.push_back(request_receive( - *sys, pm, plugin_manager::spawn_plugin_atom_v, media_reader_plugin_uuid, js)); + precache_workers_.push_back( + request_receive( + *sys, + pm, + plugin_manager::spawn_plugin_atom_v, + media_reader_plugin_uuid, + js)); link_to(precache_workers_.back()); } } diff --git a/src/media_reader/src/media_reader.cpp b/src/media_reader/src/media_reader.cpp index 909784825..d7322d35f 100644 --- a/src/media_reader/src/media_reader.cpp +++ b/src/media_reader/src/media_reader.cpp @@ -175,8 +175,7 @@ template void typed_resample(AudioBuffer &in, const size_t out_samp in.set_buf_data(new_buffer); } -void AudioBuffer::stretch_samples( - const uint64_t num_samples) { +void AudioBuffer::stretch_samples(const uint64_t num_samples) { if (sample_format() == audio::SampleFormat::UINT8) { typed_resample(*this, num_samples); @@ -193,7 +192,6 @@ void AudioBuffer::stretch_samples( } num_samples_ = num_samples; - } void AudioBuffer::set_new_sample_rate( diff --git a/src/media_reader/src/media_reader_actor.cpp b/src/media_reader/src/media_reader_actor.cpp index ad9fec579..903d54a02 100644 --- a/src/media_reader/src/media_reader_actor.cpp +++ b/src/media_reader/src/media_reader_actor.cpp @@ -155,7 +155,8 @@ GlobalMediaReaderActor::GlobalMediaReaderActor( max_source_count_ = preference_value(js, "/core/media_reader/max_source_count"); max_source_age_ = preference_value(js, "/core/media_reader/max_source_age"); - max_num_inflight_requests_ = preference_value(js, "/core/media_reader/read_threads_per_source"); + max_num_inflight_requests_ = + preference_value(js, "/core/media_reader/read_threads_per_source"); } catch (...) { } @@ -658,12 +659,11 @@ GlobalMediaReaderActor::GlobalMediaReaderActor( .send(this); }, - [=](do_precache_work_atom) { - + [=](do_precache_work_atom) { // do_precache returns false if we have hit our limit on the number of // in-flight image read requests. - while(do_precache()) {} - + while (do_precache()) { + } }, [=](utility::uuid_atom) -> Uuid { return uuid_; }); @@ -917,7 +917,7 @@ bool GlobalMediaReaderActor::do_precache() { "Failed preserve buffer {} {}", to_string(mptr->key()), to_string(err)); }); return true; -} +} void GlobalMediaReaderActor::keep_cache_hot( const media::MediaKey &new_entry, diff --git a/src/module/src/module.cpp b/src/module/src/module.cpp index ec1cf470e..ba3ad42e6 100644 --- a/src/module/src/module.cpp +++ b/src/module/src/module.cpp @@ -642,7 +642,7 @@ caf::message_handler Module::message_handler() { } catch (std::exception &e) { - std::cerr << e.what() << "\n"; + std::cerr << e.what() << "\n"; return caf::make_error(xstudio_error::error, e.what()); } }, diff --git a/src/playhead/src/playhead_actor.cpp b/src/playhead/src/playhead_actor.cpp index fd3964db3..75eaf4962 100644 --- a/src/playhead/src/playhead_actor.cpp +++ b/src/playhead/src/playhead_actor.cpp @@ -853,11 +853,12 @@ void PlayheadActor::init() { auto rp = make_response_promise(); // Fill the provided buffer with audio samples, starting at the playhead timeline // timepoint 'tp'. Used by VideoRenderPlugin, for example. - // 'frame_for_frame' means we get audio samples for the frame at the + // 'frame_for_frame' means we get audio samples for the frame at the // current playhead position and then re-sample to match the number - // of samples in 'buffer_to_fill'. + // of samples in 'buffer_to_fill'. if (audio_playhead_) { - rp.delegate(audio_playhead_, audio_buffer_atom_v, tp, buffer_to_fill, frame_for_frame); + rp.delegate( + audio_playhead_, audio_buffer_atom_v, tp, buffer_to_fill, frame_for_frame); } else { rp.deliver(buffer_to_fill); } @@ -1006,7 +1007,7 @@ void PlayheadActor::init() { return next_step_timepoint; }, - [=](step_atom, const int step_frames) -> result { + [=](step_atom, const int step_frames) -> result { // we get the key playhead to work out what step_frames are // in terms of flicks, and adjust our position diff --git a/src/playhead/src/playhead_global_events_actor.cpp b/src/playhead/src/playhead_global_events_actor.cpp index 39188b265..a411bcbcc 100644 --- a/src/playhead/src/playhead_global_events_actor.cpp +++ b/src/playhead/src/playhead_global_events_actor.cpp @@ -175,7 +175,8 @@ void PlayheadGlobalEventsActor::init() { // playhead - i.e. the playhead that is being viewed by non-quickview // viewports. SessionModel::setCurrentPlayheadFromPlaylist does this for example. for (auto &p : viewports_) { - anon_mail(utility::event_atom_v, ui::viewport::viewport_playhead_atom_v, playhead) + anon_mail( + utility::event_atom_v, ui::viewport::viewport_playhead_atom_v, playhead) .send(p.second.viewport); } global_active_playhead_ = playhead; diff --git a/src/playhead/src/sub_playhead.cpp b/src/playhead/src/sub_playhead.cpp index a832e1b12..02b651e5f 100644 --- a/src/playhead/src/sub_playhead.cpp +++ b/src/playhead/src/sub_playhead.cpp @@ -779,12 +779,12 @@ void SubPlayhead::init() { // side. 'copy_audio_sample' then takes care of copying the samples from // each of those 3 buffers from the reader into buffer_to_fill - auto rp = make_response_promise(); + auto rp = make_response_promise(); if (frame_for_frame) { // this is here to support the 'Re-speed' option in the video - // renderer where the media rate != render FPS + // renderer where the media rate != render FPS // In this case a source video frame may be 1/24 seconds, but the // output video frame may be 1/60 seconds, say. We therefore need @@ -799,14 +799,15 @@ void SubPlayhead::init() { // make an audio buffer that matches the current frame duration // and fill it with samples - auto intermediate = - media_reader::AudioBufPtr(new media_reader::AudioBuffer()); + auto intermediate = media_reader::AudioBufPtr(new media_reader::AudioBuffer()); intermediate->allocate( - buffer_to_fill->sample_rate(), // match sample rate to the one requested - 2, // num channels - int64_t(timebase::to_seconds(frame_period)*double(buffer_to_fill->sample_rate())), // samples - audio::SampleFormat::INT16 // format + buffer_to_fill->sample_rate(), // match sample rate to the one requested + 2, // num channels + int64_t( + timebase::to_seconds(frame_period) * + double(buffer_to_fill->sample_rate())), // samples + audio::SampleFormat::INT16 // format ); mail(audio_buffer_atom_v, frame_pts, intermediate, false) .request(caf::actor_cast(this), std::chrono::seconds(20)) @@ -814,7 +815,8 @@ void SubPlayhead::init() { [=](AudioBufPtr audio_buffer) mutable { audio_buffer->stretch_samples(buffer_to_fill->num_samples()); - // now we stretch the audio samples from 'audio_buffer' into 'buffer_to_fill' + // now we stretch the audio samples from 'audio_buffer' into + // 'buffer_to_fill' rp.deliver(audio_buffer); }, [=](const error &err) mutable { @@ -834,7 +836,7 @@ void SubPlayhead::init() { rp.deliver(buffer_to_fill); }; - // we fetch 2 audio frames either side of current frame to + // we fetch 2 audio frames either side of current frame to // ensure we have all the audio required to fill 'buffer_to_fill' // whose duration may not align with the frame period of the current video frame for (int i = -2; i <= 2; ++i) { diff --git a/src/plugin/conform/dneg/conform_shotbrowser/src/conform_shotbrowser.cpp b/src/plugin/conform/dneg/conform_shotbrowser/src/conform_shotbrowser.cpp index c0addac1d..1d7f13ed4 100644 --- a/src/plugin/conform/dneg/conform_shotbrowser/src/conform_shotbrowser.cpp +++ b/src/plugin/conform/dneg/conform_shotbrowser/src/conform_shotbrowser.cpp @@ -788,7 +788,8 @@ template class ShotbrowserConformActor : public caf::event_based_ac // from check clip metadata (FEAT ANIM) if (not is_valid and not found_project.empty()) { auto cm = i.prop(); // DNEG_MEDIA_STALK_DNUUID ? - if (cm.contains("media_stalk_dnuuid")) { + if (cm.contains("media_stalk_dnuuid") or + cm.contains("DNEG_MEDIA_STALK_DNUUID")) { project = found_project; cut_start = i.trimmed_frame_start().frames(); diff --git a/src/plugin/data_source/dneg/ivy/python/CMakeLists.txt b/src/plugin/data_source/dneg/ivy/python/CMakeLists.txt index 543974367..f0fb72310 100644 --- a/src/plugin/data_source/dneg/ivy/python/CMakeLists.txt +++ b/src/plugin/data_source/dneg/ivy/python/CMakeLists.txt @@ -11,18 +11,26 @@ set(PYTHONVP "python${Python_VERSION_MAJOR}.${Python_VERSION_MINOR}") configure_file(${SETUP_PY_IN} ${SETUP_PY}) +if(WIN32) +add_custom_command(OUTPUT ${OUTPUT}/lib/${PYTHONVP}/site-packages/xstudio_pipequery + COMMAND ${Python_EXECUTABLE} + ARGS -m pip install . + DEPENDS ${DEPS} ${CMAKE_CURRENT_SOURCE_DIR}/setup.py.in) +else() add_custom_command(OUTPUT ${OUTPUT}/lib/${PYTHONVP}/site-packages/xstudio_pipequery COMMAND ${Python_EXECUTABLE} ARGS setup.py install --old-and-unmanageable --prefix=${OUTPUT} DEPENDS ${DEPS} ${CMAKE_CURRENT_SOURCE_DIR}/setup.py.in) +endif() + add_custom_target(pipequery_python_module ALL DEPENDS __pybind_xstudio ${OUTPUT}/lib/${PYTHONVP}/site-packages/xstudio_pipequery) if(WIN32) - install(DIRECTORY ${OUTPUT}/lib/site-packages/xstudio_pipequery - DESTINATION bin/python3/Lib/site-packages) + #install(DIRECTORY ${OUTPUT}/lib/${PYTHONVP}/site-packages/xstudio_pipequery + # DESTINATION bin/python3/Lib/site-packages) else() if(INSTALL_PYTHON_MODULE) install(DIRECTORY ${OUTPUT}/lib/${PYTHONVP}/site-packages/xstudio_pipequery diff --git a/src/plugin/data_source/dneg/ivy/src/data_source_ivy.cpp b/src/plugin/data_source/dneg/ivy/src/data_source_ivy.cpp index 73805df8c..992822426 100644 --- a/src/plugin/data_source/dneg/ivy/src/data_source_ivy.cpp +++ b/src/plugin/data_source/dneg/ivy/src/data_source_ivy.cpp @@ -53,24 +53,24 @@ const auto GetVersionIvyUuid = // } namespace { - bool server_path_or_local(const caf::uri &uri) { - // this is unmapped. - auto result = true; - const auto path = to_string(uri); - const static auto hostname = get_host_name(); - const static auto hostname_re = std::regex(R"(^file:.+?/hosts/([^/]+)/.+$)"); - std::cmatch m; - - if (std::regex_match(path.c_str(), m, hostname_re)) { - if(m[1] != hostname) { - result = false; - spdlog::warn("Leaf skipped, unsafe path {}", path); - } +bool server_path_or_local(const caf::uri &uri) { + // this is unmapped. + auto result = true; + const auto path = to_string(uri); + const static auto hostname = get_host_name(); + const static auto hostname_re = std::regex(R"(^file:.+?/hosts/([^/]+)/.+$)"); + std::cmatch m; + + if (std::regex_match(path.c_str(), m, hostname_re)) { + if (m[1] != hostname) { + result = false; + spdlog::warn("Leaf skipped, unsafe path {}", path); } - - return result; } + + return result; } +} // namespace class IvyMediaWorker : public caf::event_based_actor { public: @@ -309,7 +309,7 @@ template caf::message_handler IvyDataSourceActor::message_handle } } catch (const std::exception &err) { spdlog::warn( - "{} Invalid drop data {}", __PRETTY_FUNCTION__, err.what()); + "{} {} {}", __PRETTY_FUNCTION__, err.what(), response.body); rp.deliver(make_error(xstudio_error::error, err.what())); } }, @@ -622,8 +622,12 @@ void IvyMediaWorker::add_media_source( auto name = jsn.at("name").get(); if (jsn.at("version").at("kind").at("name") == "Audio") { - auto label = std::string(); - auto type = std::string(); + auto label = std::string(); + auto type = std::string(); + auto actor = std::string(); + auto character = std::string(); + auto language = std::string(); + for (const auto &nt : jsn.at("version").at("name_tags")) { if (nt.at("name") == "label") { label = nt.at("value"); @@ -631,10 +635,18 @@ void IvyMediaWorker::add_media_source( } else if (nt.at("name") == "type") { type = nt.at("value"); name = type; + } else if (nt.at("name") == "actor") { + actor = nt.at("value"); + } else if (nt.at("name") == "character") { + character = nt.at("value"); + } else if (nt.at("name") == "language") { + language = nt.at("value"); } } - if (not label.empty() and not type.empty()) { + if (not actor.empty() or not character.empty() or not language.empty()) { + name = join_as_string({type, actor, character, language, label}, "-", true); + } else if (not label.empty() and not type.empty()) { name = label + "-" + type; } else if ( use_stalk_name_for_audio_sources && jsn.at("version").contains("name") && @@ -1013,7 +1025,7 @@ void IvyDataSourceActor::get_version( rp.deliver(jsn); } } catch (const std::exception &err) { - spdlog::warn("{} {}", __PRETTY_FUNCTION__, err.what()); + spdlog::warn("{} {} {}", __PRETTY_FUNCTION__, err.what(), response.body); rp.deliver(make_error(xstudio_error::error, err.what())); } }, @@ -1573,7 +1585,7 @@ void IvyDataSourceActor::pipequery( auto jsn = nlohmann::json::parse(response.body); rp.deliver(JsonStore(jsn)); } catch (const std::exception &err) { - spdlog::warn("{} {}", __PRETTY_FUNCTION__, err.what()); + spdlog::warn("{} {} {}", __PRETTY_FUNCTION__, err.what(), response.body); rp.deliver(make_error(xstudio_error::error, err.what())); } }, diff --git a/src/plugin/data_source/dneg/shotbrowser/python/CMakeLists.txt b/src/plugin/data_source/dneg/shotbrowser/python/CMakeLists.txt index 15367aca6..710ac36b4 100644 --- a/src/plugin/data_source/dneg/shotbrowser/python/CMakeLists.txt +++ b/src/plugin/data_source/dneg/shotbrowser/python/CMakeLists.txt @@ -11,16 +11,23 @@ set(PYTHONVP "python${Python_VERSION_MAJOR}.${Python_VERSION_MINOR}") configure_file(${SETUP_PY_IN} ${SETUP_PY}) +if (WIN32) +add_custom_command(OUTPUT ${OUTPUT}/lib/${PYTHONVP}/site-packages/xstudio_shotbrowser + COMMAND ${Python_EXECUTABLE} + ARGS -m pip install . + DEPENDS ${DEPS} ${CMAKE_CURRENT_SOURCE_DIR}/setup.py.in) +else() add_custom_command(OUTPUT ${OUTPUT}/lib/${PYTHONVP}/site-packages/xstudio_shotbrowser COMMAND ${Python_EXECUTABLE} ARGS setup.py install --old-and-unmanageable --prefix=${OUTPUT} DEPENDS ${DEPS} ${CMAKE_CURRENT_SOURCE_DIR}/setup.py.in) +endif() add_custom_target(shotbrowser_python_module ALL DEPENDS __pybind_xstudio ${OUTPUT}/lib/${PYTHONVP}/site-packages/xstudio_shotbrowser) if(WIN32) - install(DIRECTORY ${OUTPUT}/lib/site-packages/xstudio_shotbrowser - DESTINATION bin/python3/Lib/site-packages) + #install(DIRECTORY ${OUTPUT}/lib/site-packages/xstudio_shotbrowser + # DESTINATION bin/python3/Lib/site-packages) else() if(INSTALL_PYTHON_MODULE) install(DIRECTORY ${OUTPUT}/lib/${PYTHONVP}/site-packages/xstudio_shotbrowser diff --git a/src/plugin/data_source/dneg/shotbrowser/src/definitions.hpp b/src/plugin/data_source/dneg/shotbrowser/src/definitions.hpp index 3f30c2cc4..5e2988f57 100644 --- a/src/plugin/data_source/dneg/shotbrowser/src/definitions.hpp +++ b/src/plugin/data_source/dneg/shotbrowser/src/definitions.hpp @@ -179,6 +179,7 @@ const auto VersionFields = std::vector( // "notes", "project", "sg_client_filename", + "sg_client_version", "sg_client_send_stage", "sg_comp_in", "sg_comp_out", @@ -210,7 +211,7 @@ const auto VersionFields = std::vector( "sg_submit_dailies_chn", "sg_submit_dailies_mtl", "sg_submit_dailies_mum", - // "sg_submit_dailies_van", + "sg_submit_dailies_syd", "sg_transfix_map", "sg_twig_name", "sg_twig_type", diff --git a/src/plugin/data_source/dneg/shotbrowser/src/get_actions.cpp b/src/plugin/data_source/dneg/shotbrowser/src/get_actions.cpp index 2a82ef0b7..bd2cb85df 100644 --- a/src/plugin/data_source/dneg/shotbrowser/src/get_actions.cpp +++ b/src/plugin/data_source/dneg/shotbrowser/src/get_actions.cpp @@ -74,12 +74,12 @@ void ShotBrowser::find_shot( if (shot_cache_.count(shot_id)) { rp.deliver(shot_cache_.at(shot_id)); } else if (auto cache = engine().get_cache(QueryEngine::cache_name("shot", project_id)); - cache) { + cache) { // find shot in cache for (const auto &i : *cache) { const auto &id = i.at("id"); - if(not shot_cache_.count(id)) + if (not shot_cache_.count(id)) shot_cache_[id] = i; if (id == shot_id) { @@ -1628,11 +1628,12 @@ void ShotBrowser::get_data_shot( auto cache_key = QueryEngine::cache_name(type, project_id); // inject metadata for dnshottags.. - // auto shot_tag_key = QueryEngine::cache_name("ShotManifestTag", + // auto shot_tag_key = + // QueryEngine::cache_name("ShotManifestTag", // i.at(shot_id_ptr)); engine().set_cache(shot_tag_key, // i.at(value_ptr)); for (auto it = total.begin(); it != total.end(); ++it) { - const auto &id = it->at("id"); + const auto &id = it->at("id"); auto value = engine().get_cache( QueryEngine::cache_name("ShotManifestTag", id)); @@ -1641,7 +1642,7 @@ void ShotBrowser::get_data_shot( (*it)["attributes"]["sg_custom_entity29"] = *value; // add to shot cache.. - if(not shot_cache_.count(id)) + if (not shot_cache_.count(id)) shot_cache_[id] = *it; } diff --git a/src/plugin/data_source/dneg/shotbrowser/src/preset_model_ui.cpp b/src/plugin/data_source/dneg/shotbrowser/src/preset_model_ui.cpp index 881ee5ba7..3c63f58c5 100644 --- a/src/plugin/data_source/dneg/shotbrowser/src/preset_model_ui.cpp +++ b/src/plugin/data_source/dneg/shotbrowser/src/preset_model_ui.cpp @@ -1123,6 +1123,8 @@ QObject *ShotBrowserPresetModel::termModel( if ((term == "Author" or term == "Recipient")) term = "User"; + else if (term == "Has Note Type") + term = "Note Type"; else if (term == "Disable Global") term += "-" + StdFromQString(entity); diff --git a/src/plugin/data_source/dneg/shotbrowser/src/qml/ShotBrowser.1/dialogs/XsSBPublishNotesDialog.qml b/src/plugin/data_source/dneg/shotbrowser/src/qml/ShotBrowser.1/dialogs/XsSBPublishNotesDialog.qml index 63a28291f..894d799a8 100644 --- a/src/plugin/data_source/dneg/shotbrowser/src/qml/ShotBrowser.1/dialogs/XsSBPublishNotesDialog.qml +++ b/src/plugin/data_source/dneg/shotbrowser/src/qml/ShotBrowser.1/dialogs/XsSBPublishNotesDialog.qml @@ -15,6 +15,7 @@ import xstudio.qml.helpers 1.0 XsWindow{ title: "Publish "+notesType+" Notes" + id: pubDlg property bool isPlaylistNotes: true property string notesType: isPlaylistNotes? "Playlist": "Selected Media" property string message: "No notes to publish." @@ -42,6 +43,13 @@ XsWindow{ property int notesCount: (payload_obj ? payload_obj["payload"].length : 0) + onVisibleChanged: { + // we need this to fill out the OCIO View combo box with the options + // for the current playhead + studio.setupSnapshotViewport("") + } + + onProjectIdChanged: ShotBrowserEngine.cacheProject(projectId) onNotesCountChanged:{ @@ -584,6 +592,32 @@ XsWindow{ } } + RowLayout { + + Layout.preferredHeight: itemHeight + Layout.topMargin: itemHeight + + XsLabel { + text: "OCIO Display:" + Layout.alignment: Qt.AlignVCenter | Qt.AlignRight + Layout.leftMargin: 46 + } + + XsAttrComboBox { + Layout.preferredWidth: 120 + Layout.preferredHeight: XsStyleSheet.widgetStdHeight + attr_title: "Display" + attr_model_name: "snapshot_viewport_toolbar" + } + + XsInfoButton { + Layout.preferredHeight: XsStyleSheet.widgetStdHeight + Layout.preferredWidth: XsStyleSheet.widgetStdHeight + tooltipText: "If your notes have annotations (draw-overs) a screenshot will be generated and attached to the ShotGrid note. This option allows you to set the OCIO display space that is used for the generated image(s). For example, 'Eizo' space might be preferred as it generally lifts the dark areas. 'sRGB' is a default choice if you're not sure." + maxWidth: pubDlg.width*0.7 + } + } + Item{ Layout.fillWidth: true Layout.fillHeight: true diff --git a/src/plugin/data_source/dneg/shotbrowser/src/qml/ShotBrowser.1/shot_history/ShotHistory.qml b/src/plugin/data_source/dneg/shotbrowser/src/qml/ShotBrowser.1/shot_history/ShotHistory.qml index 26ea7a54b..1721e9ef4 100644 --- a/src/plugin/data_source/dneg/shotbrowser/src/qml/ShotBrowser.1/shot_history/ShotHistory.qml +++ b/src/plugin/data_source/dneg/shotbrowser/src/qml/ShotBrowser.1/shot_history/ShotHistory.qml @@ -181,9 +181,10 @@ Item{ // Hold properties that we want to persist between sessions. id: prefs property string scope: "" + property bool compactMode: false XsStoredPanelProperties { - propertyNames: ["scope"] + propertyNames: ["scope","compactMode"] onPropertiesInitialised: { prefs.initialised = true setIndexFromPreference() @@ -284,6 +285,8 @@ Item{ ShotHistoryTitleDiv{id: titleDiv titleButtonHeight: (XsStyleSheet.widgetStdHeight + 4) + compactMode: prefs.compactMode + onCompactModeChanged: prefs.compactMode = compactMode Layout.fillWidth: true Layout.minimumHeight: titleButtonHeight*2 Layout.maximumHeight: titleButtonHeight*2 @@ -301,6 +304,7 @@ Item{ anchors.topMargin: 2 anchors.rightMargin: rightSpacing ? 0 : 2 anchors.bottomMargin: 2 + compactMode: prefs.compactMode } } diff --git a/src/plugin/data_source/dneg/shotbrowser/src/qml/ShotBrowser.1/shot_history/ShotHistoryListDelegate.qml b/src/plugin/data_source/dneg/shotbrowser/src/qml/ShotBrowser.1/shot_history/ShotHistoryListDelegate.qml index 7ea687cb9..264e45bd3 100644 --- a/src/plugin/data_source/dneg/shotbrowser/src/qml/ShotBrowser.1/shot_history/ShotHistoryListDelegate.qml +++ b/src/plugin/data_source/dneg/shotbrowser/src/qml/ShotBrowser.1/shot_history/ShotHistoryListDelegate.qml @@ -38,6 +38,7 @@ Rectangle{ id: frame required property string authorRole required property string thumbRole required property string clientFilenameRole + required property string clientVersionRole required property string projectRole required property int idRole required property string entityRole @@ -61,7 +62,10 @@ Rectangle{ id: frame property bool isPlaylist: false - property bool isHovered: mArea.containsMouse || versionArrowBtn.hovered || sec1.playerMA.containsMouse + property bool compactMode: false + + + property bool isHovered: mArea.containsMouse || versionArrowBtn.hovered || sec1.playerMA.containsMouse || iconState.containsMouse signal playMovie(path: var) @@ -162,12 +166,105 @@ Rectangle{ id: frame spacing: itemSpacing - Rectangle{ id: shotTitle + RowLayout{ + Layout.fillWidth: true + Layout.minimumHeight: XsStyleSheet.widgetStdHeight + spacing: itemSpacing + visible: compactMode + + ShotHistoryTextRow{ + Layout.fillWidth: true + Layout.fillHeight: true + + textDiv.leftPadding: panelPadding + textDiv.horizontalAlignment: Text.AlignLeft + textDiv.font.pixelSize: XsStyleSheet.fontSize * 1.1 + textDiv.font.bold: true + textDiv.elide: Text.ElideMiddle + text: nameRole + } + + ShotHistoryTextRow{ + Layout.minimumWidth: XsStyleSheet.widgetStdHeight * 2 + Layout.fillHeight: true + visible: clientVersionRole > 0 + + textDiv.leftPadding: panelPadding + textDiv.horizontalAlignment: Text.AlignLeft + textDiv.font.pixelSize: XsStyleSheet.fontSize * 1.1 + textDiv.font.bold: true + textDiv.opacity: 0.5 + textDiv.elide: Text.ElideMiddle + text: "v"+ (""+clientVersionRole).padStart(4, "0") + } + + MouseArea { + id: iconState + Layout.minimumWidth: XsStyleSheet.widgetStdHeight * 3 + Layout.fillHeight: true + hoverEnabled: true + + Row { + spacing: itemSpacing + anchors.fill: parent + + XsPrimaryButton{ + property bool hasNotes: noteCountRole <= 0 ? false : true + text: "N" + y:1 + width: (parent.width/3)-itemSpacing + height: parent.height-itemSpacing + font.pixelSize: textSize*1.2 + font.weight: hasNotes? Font.Bold:Font.Medium + isUnClickable: true + isActiveViaIndicator: false + textDiv.color: hasNotes? XsStyleSheet.primaryTextColor : XsStyleSheet.hintColor + enabled: false + bgDiv.opacity: enabled? 1.0 : 0.5 + isActive: hasNotes + } + XsPrimaryButton{ + property bool hasDailies: submittedToDailiesRole === undefined ? false :true + text: "D" //dalies + y:1 + width: (parent.width/3)-itemSpacing + height: parent.height-itemSpacing + font.pixelSize: textSize*1.2 + font.weight: hasDailies? Font.Bold:Font.Medium + isUnClickable: true + isActiveViaIndicator: false + textDiv.color: hasDailies? XsStyleSheet.primaryTextColor : XsStyleSheet.hintColor + enabled: false + bgDiv.opacity: enabled? 1.0 : 0.5 + isActive: hasDailies + } + XsPrimaryButton{ + property bool hasClient: dateSubmittedToClientRole === undefined ? false : true + text: "C" //client + width: (parent.width/3)-itemSpacing + y:1 + height: parent.height-itemSpacing + font.pixelSize: textSize*1.2 + font.weight: hasClient? Font.Bold:Font.Medium + isUnClickable: true + isActiveViaIndicator: false + textDiv.color: hasClient? XsStyleSheet.primaryTextColor : XsStyleSheet.hintColor + enabled: false + bgDiv.opacity: enabled? 1.0 : 0.5 + isActive: hasClient + } + } + } + } + + Rectangle{ Layout.fillWidth: true Layout.fillHeight: true Layout.preferredHeight: XsStyleSheet.widgetStdHeight color: XsStyleSheet.widgetBgNormalColor + visible: !compactMode + XsText{ anchors.fill: parent horizontalAlignment: Text.AlignLeft @@ -177,6 +274,7 @@ Rectangle{ id: frame leftPadding: panelPadding } XsText{ + anchors.fill: parent horizontalAlignment: Text.AlignRight text: clientFilenameRole ? clientFilenameRole : "" @@ -192,22 +290,26 @@ Rectangle{ id: frame Layout.preferredHeight: (XsStyleSheet.widgetStdHeight * rowCount) + (spacing * (rowCount-1)) spacing: itemSpacing x: spacing + visible: !compactMode property int rowCount: 3 - ShotHistorySection1{ id: sec1 + ShotHistorySection1{ + id: sec1 Layout.minimumWidth: 154 Layout.preferredWidth: 154 Layout.fillHeight: true } - ShotHistorySection2{ id: sec2 + ShotHistorySection2{ + id: sec2 Layout.fillWidth: true Layout.minimumWidth: 115 Layout.fillHeight: true } - ShotHistorySection3{ id: sec3 + ShotHistorySection3{ + id: sec3 Layout.minimumWidth: 157 Layout.preferredWidth: 157 Layout.fillHeight: true diff --git a/src/plugin/data_source/dneg/shotbrowser/src/qml/ShotBrowser.1/shot_history/ShotHistoryListDiv.qml b/src/plugin/data_source/dneg/shotbrowser/src/qml/ShotBrowser.1/shot_history/ShotHistoryListDiv.qml index 8606b74eb..321c6e457 100644 --- a/src/plugin/data_source/dneg/shotbrowser/src/qml/ShotBrowser.1/shot_history/ShotHistoryListDiv.qml +++ b/src/plugin/data_source/dneg/shotbrowser/src/qml/ShotBrowser.1/shot_history/ShotHistoryListDiv.qml @@ -6,11 +6,12 @@ import QtQuick.Controls.Basic import xStudio 1.0 import ShotBrowser 1.0 -XsListView { +XsListView { id: list spacing: panelPadding property int rightSpacing: list.height < list.contentHeight ? 12 : 0 Behavior on rightSpacing {NumberAnimation {duration: 150}} + property bool compactMode: false property alias delegateModel: chooserModel @@ -72,10 +73,11 @@ XsListView { model: dataModel delegate: ShotHistoryListDelegate{ width: list.width - rightSpacing - height: XsStyleSheet.widgetStdHeight * 4 + height: compactMode ? XsStyleSheet.widgetStdHeight+2 : (XsStyleSheet.widgetStdHeight + 1) * 4 delegateModel: chooserModel popupMenu: resultPopup onPlayMovie: (path) => list.playMovie(path) + compactMode: list.compactMode } } diff --git a/src/plugin/data_source/dneg/shotbrowser/src/qml/ShotBrowser.1/shot_history/ShotHistoryTitleDiv.qml b/src/plugin/data_source/dneg/shotbrowser/src/qml/ShotBrowser.1/shot_history/ShotHistoryTitleDiv.qml index cefcc87d7..d2841eefe 100644 --- a/src/plugin/data_source/dneg/shotbrowser/src/qml/ShotBrowser.1/shot_history/ShotHistoryTitleDiv.qml +++ b/src/plugin/data_source/dneg/shotbrowser/src/qml/ShotBrowser.1/shot_history/ShotHistoryTitleDiv.qml @@ -11,6 +11,7 @@ RowLayout {id: titleDiv property int titleButtonCount: 4 property real titleButtonSpacing: 1 property real titleButtonHeight: XsStyleSheet.widgetStdHeight+4 + property bool compactMode: true XsPrimaryButton{ id: updateScopeBtn Layout.preferredWidth: 40 @@ -96,6 +97,31 @@ RowLayout {id: titleDiv } } + XsPrimaryButton{ + Layout.leftMargin: 2 + Layout.preferredWidth: XsStyleSheet.primaryButtonStdWidth + Layout.maximumWidth: XsStyleSheet.primaryButtonStdWidth + Layout.fillHeight: true + + imgSrc: "qrc:/icons/list_alt2.svg" + toolTip: "Compact Mode" + isActive: compactMode + onClicked: compactMode = !compactMode + } + + XsPrimaryButton{ + Layout.leftMargin: 2 + Layout.rightMargin: 2 + Layout.preferredWidth: XsStyleSheet.primaryButtonStdWidth + Layout.maximumWidth: XsStyleSheet.primaryButtonStdWidth + Layout.fillHeight: true + + imgSrc: "qrc:/icons/imagesmode.svg" + toolTip: "Not Compact Mode" + isActive: !compactMode + onClicked: compactMode = !compactMode + } + XsComboBoxEditable{ id: filterSentTo Layout.fillHeight: true Layout.minimumWidth: titleButtonHeight * 2 diff --git a/src/plugin/data_source/dneg/shotbrowser/src/query_engine.cpp b/src/plugin/data_source/dneg/shotbrowser/src/query_engine.cpp index e07e9e672..fe528ae84 100644 --- a/src/plugin/data_source/dneg/shotbrowser/src/query_engine.cpp +++ b/src/plugin/data_source/dneg/shotbrowser/src/query_engine.cpp @@ -1667,6 +1667,11 @@ void QueryEngine::add_version_term_to_filter( qry->push_back(Text("sg_latest").is("Yes")); else throw XStudioError("Invalid query term " + term + " " + value); + } else if (term == "Has Note Type") { + if (negated) + qry->push_back(Text("notes.Note.sg_note_type").is_not(value)); + else + qry->push_back(Text("notes.Note.sg_note_type").is(value)); } else if (term == "Is Hero") { if (value == "False") qry->push_back(Checkbox("sg_is_hero").is(false)); @@ -1775,7 +1780,7 @@ void QueryEngine::add_version_term_to_filter( DateTime("sg_submit_dailies").is_not_null(), DateTime("sg_submit_dailies_chn").is_not_null(), DateTime("sg_submit_dailies_mtl").is_not_null(), - // DateTime("sg_submit_dailies_van").is_not_null(), + DateTime("sg_submit_dailies_syd").is_not_null(), DateTime("sg_submit_dailies_mum").is_not_null())); } else if (value == "Any") { qry->push_back( @@ -1784,7 +1789,7 @@ void QueryEngine::add_version_term_to_filter( DateTime("sg_submit_dailies").is_not_null(), DateTime("sg_submit_dailies_chn").is_not_null(), DateTime("sg_submit_dailies_mtl").is_not_null(), - // DateTime("sg_submit_dailies_van").is_not_null(), + DateTime("sg_submit_dailies_syd").is_not_null(), DateTime("sg_submit_dailies_mum").is_not_null())); } } else if (term == "Sent To Client") { @@ -1801,7 +1806,7 @@ void QueryEngine::add_version_term_to_filter( DateTime("sg_submit_dailies").is_null(), DateTime("sg_submit_dailies_chn").is_null(), DateTime("sg_submit_dailies_mtl").is_null(), - // DateTime("sg_submit_dailies_van").is_null(), + DateTime("sg_submit_dailies_syd").is_null(), DateTime("sg_submit_dailies_mum").is_null())); else if (value == "True") qry->push_back( @@ -1809,7 +1814,7 @@ void QueryEngine::add_version_term_to_filter( DateTime("sg_submit_dailies").is_not_null(), DateTime("sg_submit_dailies_chn").is_not_null(), DateTime("sg_submit_dailies_mtl").is_not_null(), - // DateTime("sg_submit_dailies_van").is_not_null(), + DateTime("sg_submit_dailies_syd").is_not_null(), DateTime("sg_submit_dailies_mum").is_not_null())); else throw XStudioError("Invalid query term " + term + " " + value); diff --git a/src/plugin/data_source/dneg/shotbrowser/src/query_engine.hpp b/src/plugin/data_source/dneg/shotbrowser/src/query_engine.hpp index cffbce496..79e2f6ec7 100644 --- a/src/plugin/data_source/dneg/shotbrowser/src/query_engine.hpp +++ b/src/plugin/data_source/dneg/shotbrowser/src/query_engine.hpp @@ -162,6 +162,7 @@ const auto ValidTerms = R"_({ "Exclude Shot Status", "Filter", "Flag Media", + "Has Note Type", "Has Notes", "Id", "Include Deleted", @@ -346,6 +347,7 @@ const auto TermProperties = R"_({ "Has Attachments": { "negated": null, "livelink": null }, "Has Contents": { "negated": null, "livelink": null }, "Has Notes": { "negated": null, "livelink": null }, + "Has Note Type": { "negated": false, "livelink": null }, "Id": { "negated": false, "livelink": false }, "Include Deleted": { "negated": null, "livelink": null }, "Is Hero": { "negated": null, "livelink": null }, diff --git a/src/plugin/data_source/dneg/shotbrowser/src/result_model_ui.cpp b/src/plugin/data_source/dneg/shotbrowser/src/result_model_ui.cpp index 2ff2cac49..d59f421c2 100644 --- a/src/plugin/data_source/dneg/shotbrowser/src/result_model_ui.cpp +++ b/src/plugin/data_source/dneg/shotbrowser/src/result_model_ui.cpp @@ -26,6 +26,7 @@ ShotBrowserResultModel::ShotBrowserResultModel(QObject *parent) : JSONTreeModel( "authorRole", "clientFilenameRole", "clientNoteRole", + "clientVersionRole", // "clientStagesRole", // "clientStagesCurrentRole", "contentRole", @@ -321,10 +322,10 @@ QVariant ShotBrowserResultModel::data(const QModelIndex &index, int role) const result = QDateTime::fromString( QStringFromStd(j.at("attributes").at("sg_submit_dailies_mtl")), Qt::ISODate); - // else if (not j.at("attributes").at("sg_submit_dailies_van").is_null()) - // result = QDateTime::fromString( - // QStringFromStd(j.at("attributes").at("sg_submit_dailies_van")), - // Qt::ISODate); + else if (not j.at("attributes").at("sg_submit_dailies_syd").is_null()) + result = QDateTime::fromString( + QStringFromStd(j.at("attributes").at("sg_submit_dailies_syd")), + Qt::ISODate); else if (not j.at("attributes").at("sg_submit_dailies_mum").is_null()) result = QDateTime::fromString( QStringFromStd(j.at("attributes").at("sg_submit_dailies_mum")), @@ -435,6 +436,10 @@ QVariant ShotBrowserResultModel::data(const QModelIndex &index, int role) const result = QString::fromStdString(j.at("attributes").value("sg_client_filename", "")); break; + case Roles::clientVersionRole: + result = j.at("attributes").value("sg_client_version", 0); + break; + case Roles::textFilterRole: { auto tmp = QStringList(); auto troles = std::vector( diff --git a/src/plugin/data_source/dneg/shotbrowser/src/result_model_ui.hpp b/src/plugin/data_source/dneg/shotbrowser/src/result_model_ui.hpp index 04bc479c5..3f2bcbc0b 100644 --- a/src/plugin/data_source/dneg/shotbrowser/src/result_model_ui.hpp +++ b/src/plugin/data_source/dneg/shotbrowser/src/result_model_ui.hpp @@ -61,6 +61,7 @@ class ShotBrowserResultModel : public JSONTreeModel { authorRole, clientFilenameRole, clientNoteRole, + clientVersionRole, // clientStagesRole, // clientStagesCurrentRole, contentRole, diff --git a/src/plugin/data_source/dneg/shotbrowser/src/shotbrowser_engine_ui.cpp b/src/plugin/data_source/dneg/shotbrowser/src/shotbrowser_engine_ui.cpp index 96d7c512b..70523c24d 100644 --- a/src/plugin/data_source/dneg/shotbrowser/src/shotbrowser_engine_ui.cpp +++ b/src/plugin/data_source/dneg/shotbrowser/src/shotbrowser_engine_ui.cpp @@ -608,27 +608,35 @@ void ShotBrowserEngine::populateCaches() { } QFuture ShotBrowserEngine::remapCachePathFuture(const QPersistentModelIndex &index) { - //get source actor. - auto source_actor = actorFromString( system(), StdFromQString(index.data(SessionModel::Roles::actorRole).toString())); + // get source actor. + auto source_actor = actorFromString( + system(), StdFromQString(index.data(SessionModel::Roles::actorRole).toString())); auto source_uuid = UuidFromQUuid(index.data(SessionModel::Roles::actorUuidRole).toUuid()); - auto media_actor = actorFromString( system(), StdFromQString(index.parent().data(SessionModel::Roles::actorRole).toString())); + auto media_actor = actorFromString( + system(), + StdFromQString(index.parent().data(SessionModel::Roles::actorRole).toString())); auto path = index.data(SessionModel::Roles::pathRole).toUrl(); // get shotgrid metadata.. auto site = std::string("mum"); try { scoped_actor sys{system()}; - site = request_receive(*sys, media_actor, json_store::get_json_atom_v, utility::Uuid(), std::string("/metadata/shotgun/version/attributes/sg_location")); - } catch(const std::exception &err) { + site = request_receive( + *sys, + media_actor, + json_store::get_json_atom_v, + utility::Uuid(), + std::string("/metadata/shotgun/version/attributes/sg_location")); + } catch (const std::exception &err) { spdlog::warn("{} {}", __PRETTY_FUNCTION__, err.what()); } return QtConcurrent::run([=]() { - const auto orig = uri_to_posix_path(UriFromQUrl(path), false); + const auto orig = uri_to_posix_path(UriFromQUrl(path), false); static const auto pathre = std::regex(R"(^/jobs/(([^/]+)/.+)$)"); std::cmatch m; - if(std::regex_match(orig.c_str(), m, pathre) and m[2] != site) { - const auto npath = std::string("/jobs/") + site + "/" + std::string(m[1]); + if (std::regex_match(orig.c_str(), m, pathre) and m[2] != site) { + const auto npath = std::string("/jobs/") + site + "/" + std::string(m[1]); // remap path to cache. utility::add_remap_file_path(orig, npath); @@ -637,7 +645,8 @@ QFuture ShotBrowserEngine::remapCachePathFuture(const QPersistentModelInde auto socket = new QUdpSocket(); QByteArray data; data.append(std::string("read|") + npath); - socket->writeDatagram(data, QHostInfo::fromName("globalnfscache").addresses().front(), 8081); + socket->writeDatagram( + data, QHostInfo::fromName("globalnfscache").addresses().front(), 8081); socket->deleteLater(); // trigger re-evaluation of source detail.. diff --git a/src/plugin/data_source/dneg/shotbrowser/src/shotbrowser_engine_ui.hpp b/src/plugin/data_source/dneg/shotbrowser/src/shotbrowser_engine_ui.hpp index c26bfa54e..fd5cd2358 100644 --- a/src/plugin/data_source/dneg/shotbrowser/src/shotbrowser_engine_ui.hpp +++ b/src/plugin/data_source/dneg/shotbrowser/src/shotbrowser_engine_ui.hpp @@ -142,7 +142,7 @@ class ShotBrowserEngine : public QMLActor { return getIvyVersionFuture(project, stalk).result(); } - QFuture remapCachePathFuture(const QPersistentModelIndex &sourceIndex) ; + QFuture remapCachePathFuture(const QPersistentModelIndex &sourceIndex); QFuture getSequencePathFuture( const QStringList &preferred, const QString &project, const QUuid &stalk); diff --git a/src/plugin/data_source/dneg/shotbrowser/src/shotbrowser_plugin.cpp b/src/plugin/data_source/dneg/shotbrowser/src/shotbrowser_plugin.cpp index f197e1bf8..424c84df1 100644 --- a/src/plugin/data_source/dneg/shotbrowser/src/shotbrowser_plugin.cpp +++ b/src/plugin/data_source/dneg/shotbrowser/src/shotbrowser_plugin.cpp @@ -2102,7 +2102,7 @@ void ShotBrowser::do_add_media_sources_from_ivy( .then( [=](bool got_media_detail) mutable { // if (got_media_detail) - good_sources->push_back(source); + good_sources->push_back(source); // else // send_exit( // source.actor(), caf::exit_reason::user_shutdown); diff --git a/src/plugin/data_source/dneg/shotbrowser/src/worker.cpp b/src/plugin/data_source/dneg/shotbrowser/src/worker.cpp index 3f7ea7581..71be80cab 100644 --- a/src/plugin/data_source/dneg/shotbrowser/src/worker.cpp +++ b/src/plugin/data_source/dneg/shotbrowser/src/worker.cpp @@ -13,24 +13,24 @@ using namespace xstudio; using namespace xstudio::utility; namespace { - bool server_path_or_local(const caf::uri &uri) { - // this is unmapped. - auto result = true; - const auto path = to_string(uri); - const static auto hostname = get_host_name(); - const static auto hostname_re = std::regex(R"(^file:.+?/hosts/([^/]+)/.+$)"); - std::cmatch m; - - if (std::regex_match(path.c_str(), m, hostname_re)) { - if(m[1] != hostname) { - result = false; - spdlog::warn("Media skipped, unsafe path {}", path); - } +bool server_path_or_local(const caf::uri &uri) { + // this is unmapped. + auto result = true; + const auto path = to_string(uri); + const static auto hostname = get_host_name(); + const static auto hostname_re = std::regex(R"(^file:.+?/hosts/([^/]+)/.+$)"); + std::cmatch m; + + if (std::regex_match(path.c_str(), m, hostname_re)) { + if (m[1] != hostname) { + result = false; + spdlog::warn("Media skipped, unsafe path {}", path); } - - return result; } + + return result; } +} // namespace void MediaWorker::add_media_step_1( @@ -182,19 +182,26 @@ MediaWorker::MediaWorker(caf::actor_config &cfg, const caf::actor_addr source) // if we keep this code is needs threading.. auto uri = posix_path_to_uri(jsn.at("attributes").at("sg_path_to_movie")); - if(server_path_or_local(uri)) { + if (server_path_or_local(uri)) { const auto source_uuid = Uuid::generate(); - auto source = spawn( - "SG Movie", uri, media_rate, source_uuid); + // this might be audio... Or someother thing.. + auto source_jsn = utility::JsonStore( + R"({"colour_pipeline": {}, "metadata": {"shotgun":{"version":{}}}})"_json); + source_jsn["metadata"]["shotgun"]["version"] = jsn; + + auto source = spawn( + "SG Movie", uri, media_rate, source_uuid, source_jsn); mail(media::acquire_media_detail_atom_v, media_rate) .request(source, infinite) .then( - [=](bool) mutable { rp.deliver(UuidActor(source_uuid, source)); }, + [=](bool) mutable { + rp.deliver(UuidActor(source_uuid, source)); + }, [=](error &err) mutable { // even though there is an error, we want the broken media - // source added so the user can see it in the UI (and its error - // state) + // source added so the user can see it in the UI (and its + // error state) rp.deliver(UuidActor(source_uuid, source)); }); } else { @@ -219,7 +226,9 @@ MediaWorker::MediaWorker(caf::actor_config &cfg, const caf::actor_addr source) try { if (not jsn.at("attributes").at("sg_path_to_frames").is_null()) { - if(auto test_uri = posix_path_to_uri(jsn.at("attributes").at("sg_path_to_frames")); server_path_or_local(test_uri)) { + if (auto test_uri = + posix_path_to_uri(jsn.at("attributes").at("sg_path_to_frames")); + server_path_or_local(test_uri)) { FrameList frame_list; caf::uri uri; @@ -229,10 +238,13 @@ MediaWorker::MediaWorker(caf::actor_config &cfg, const caf::actor_addr source) uri = parse_cli_posix_path( jsn.at("attributes").at("sg_path_to_frames"), frame_list, true); } else { - frame_list = FrameList( - jsn.at("attributes").at("frame_range").template get()); - uri = parse_cli_posix_path( - jsn.at("attributes").at("sg_path_to_frames"), frame_list, false); + frame_list = FrameList(jsn.at("attributes") + .at("frame_range") + .template get()); + uri = parse_cli_posix_path( + jsn.at("attributes").at("sg_path_to_frames"), + frame_list, + false); } const auto source_uuid = Uuid::generate(); @@ -246,11 +258,13 @@ MediaWorker::MediaWorker(caf::actor_config &cfg, const caf::actor_addr source) mail(media::acquire_media_detail_atom_v, media_rate) .request(source, infinite) .then( - [=](bool) mutable { rp.deliver(UuidActor(source_uuid, source)); }, + [=](bool) mutable { + rp.deliver(UuidActor(source_uuid, source)); + }, [=](error &err) mutable { // even though there is an error, we want the broken media - // source added so the user can see it in the UI (and its error - // state) + // source added so the user can see it in the UI (and its + // error state) rp.deliver(UuidActor(source_uuid, source)); }); } else { diff --git a/src/plugin/media_hook/dneg/dnhook/src/dneg.cpp b/src/plugin/media_hook/dneg/dnhook/src/dneg.cpp index 7fc0c667b..684bd99ca 100644 --- a/src/plugin/media_hook/dneg/dnhook/src/dneg.cpp +++ b/src/plugin/media_hook/dneg/dnhook/src/dneg.cpp @@ -236,6 +236,11 @@ class DNegMediaHook : public MediaHook { // check for ivy timeline_range try { const static auto tcp = json::json_pointer("/metadata/ivy/file/timeline_range"); + const static auto tcs = + json::json_pointer("/metadata/shotgun/version/attributes/frame_range"); + const static auto stt = + json::json_pointer("/metadata/shotgun/version/attributes/sg_twig_type"); + if (jsn.contains(tcp) and jsn.at(tcp).is_string()) { // Let's try and work out if there is a slate frame. @@ -276,7 +281,16 @@ class DNegMediaHook : public MediaHook { changed = true; } } + } else if ( + jsn.contains(stt) and jsn.at(stt) == "audio" and jsn.contains(tcs) and + jsn.at(tcs).is_string()) { + auto ifr = FrameList(jsn.at(tcs).get()); + result.set_timecode( + result.timecode() + + (ifr.start() - static_cast(result.timecode().total_frames()))); + changed = true; } + } catch (const std::exception &err) { spdlog::warn("{} {}", __PRETTY_FUNCTION__, err.what()); } @@ -364,7 +378,7 @@ class DNegMediaHook : public MediaHook { const std::string path = to_string(uri); // don't remap path, or regex won't work. - auto ppath = uri_to_posix_path(uri, false); + auto ppath = uri_to_posix_path(uri, false); // utility::JsonStore j(R"( diff --git a/src/plugin/media_reader/ffmpeg/src/ffmpeg.hpp b/src/plugin/media_reader/ffmpeg/src/ffmpeg.hpp index 0a6085881..cdd766458 100644 --- a/src/plugin/media_reader/ffmpeg/src/ffmpeg.hpp +++ b/src/plugin/media_reader/ffmpeg/src/ffmpeg.hpp @@ -28,9 +28,7 @@ class FFMpegMediaReader : public MediaReader { [[nodiscard]] uint8_t maximum_readers(const caf::uri &) const override { return readers_per_source_; } - [[nodiscard]] bool prefer_sequential_access() const override { - return true; - } + [[nodiscard]] bool prefer_sequential_access() const override { return true; } [[nodiscard]] bool can_decode_audio() const override { return true; } std::shared_ptr thumbnail(const media::AVFrameID &mptr, const size_t thumb_size) override; diff --git a/src/plugin/media_reader/openexr/src/openexr.cpp b/src/plugin/media_reader/openexr/src/openexr.cpp index 5f8dfc38c..a668c17bd 100644 --- a/src/plugin/media_reader/openexr/src/openexr.cpp +++ b/src/plugin/media_reader/openexr/src/openexr.cpp @@ -181,7 +181,8 @@ static ui::viewport::GPUShaderPtr OpenEXRMediaReader::OpenEXRMediaReader(const utility::JsonStore &prefs) : MediaReader("OpenEXR", prefs) { - Imf::setGlobalThreadCount(std::min(std::max(4, int(std::thread::hardware_concurrency()) - 2), 16)); + Imf::setGlobalThreadCount( + std::min(std::max(4, int(std::thread::hardware_concurrency()) - 2), 16)); max_exr_overscan_percent_ = 5.0f; readers_per_source_ = 1; diff --git a/src/plugin/media_reader/openexr/src/openexr.hpp b/src/plugin/media_reader/openexr/src/openexr.hpp index 79877faad..478f86d2d 100644 --- a/src/plugin/media_reader/openexr/src/openexr.hpp +++ b/src/plugin/media_reader/openexr/src/openexr.hpp @@ -20,9 +20,7 @@ class OpenEXRMediaReader : public MediaReader { [[nodiscard]] uint8_t maximum_readers(const caf::uri &) const override { return readers_per_source_; } - [[nodiscard]] bool prefer_sequential_access() const override { - return false; - } + [[nodiscard]] bool prefer_sequential_access() const override { return false; } static constexpr bool is_available() { return false; } diff --git a/src/plugin/media_reader/openimageio/src/openimageio.hpp b/src/plugin/media_reader/openimageio/src/openimageio.hpp index 87b171035..9e2fb2414 100644 --- a/src/plugin/media_reader/openimageio/src/openimageio.hpp +++ b/src/plugin/media_reader/openimageio/src/openimageio.hpp @@ -16,9 +16,7 @@ class OIIOMediaReader : public MediaReader { ImageBufPtr image(const media::AVFrameID &mptr) override; - [[nodiscard]] bool prefer_sequential_access() const override { - return false; - } + [[nodiscard]] bool prefer_sequential_access() const override { return false; } MRCertainty supported(const caf::uri &uri, const std::array &signature) override; diff --git a/src/plugin/python_plugins/CMakeLists.txt b/src/plugin/python_plugins/CMakeLists.txt index cd4ffaf65..5cc0206c8 100644 --- a/src/plugin/python_plugins/CMakeLists.txt +++ b/src/plugin/python_plugins/CMakeLists.txt @@ -4,7 +4,4 @@ add_python_plugin(on_screen_version_name) add_python_plugin(viewport_field_chart) add_python_plugin(annotations_exporter) add_python_plugin(basic_viewport_mask) -if (NOT WIN32) - # this feature needs a little more work on Win platform - add_python_plugin(filesystem_browser) -endif() +add_python_plugin(utility) \ No newline at end of file diff --git a/src/plugin/python_plugins/filesystem_browser/qml/FilesystemBrowserXStudio.1/FilesystemBrowser.qml b/src/plugin/python_plugins/filesystem_browser/qml/FilesystemBrowserXStudio.1/FilesystemBrowser.qml deleted file mode 100644 index 15f7a53bd..000000000 --- a/src/plugin/python_plugins/filesystem_browser/qml/FilesystemBrowserXStudio.1/FilesystemBrowser.qml +++ /dev/null @@ -1,1127 +0,0 @@ -import QtQuick 2.15 -import QtQuick.Layouts 1.15 -import QtQuick.Controls 2.15 -import Qt.labs.qmlmodels 1.0 -import QtQuick.Shapes 1.15 // Added for vector icon - -import xStudio 1.0 -import xstudio.qml.models 1.0 -import xstudio.qml.helpers 1.0 -import xstudio.qml.viewport 1.0 - -import "./styled_widgets" -//import "XsText.qml" as Text - -Item { - - id: root - - anchors.fill: parent // Ensure it fills the panel - - property var columnPositions: [0, 200, 280, 360, 460, 580] // Initial positions based on headerModel sizes - property var columnWidths: [200, 80, 80, 100, 120, 80] // Initial widths based on headerModel sizes - - // Access the attributes exposed by the plugin - property string currentFilterTime: "Any" - property string currentFilterVersion: "All Versions" - - property var selectedItems: [] - property var selectedItemsUrls: [] - property var selectedItemsPaths: [] - property var underMouseIndex: -1 - - onSelectedItemsChanged: { - var v = [] - var v2 = [] - if (root.viewMode === 3) { - for (var i = 0; i < selectedItems.length; i++) { - v.push(helpers.QUrlFromPosixPath(flatThumbnailModel[selectedItems[i]].path)) - v2.push(flatThumbnailModel[selectedItems[i]].path) - } - } else { - for (var i = 0; i < selectedItems.length; i++) { - v.push(helpers.QUrlFromPosixPath(visibleTreeList[selectedItems[i]].path)) - v2.push(visibleTreeList[selectedItems[i]].path) - } - } - selectedItemsUrls = v - selectedItemsPaths = v2 - } - - function selectItem(index, selectionMode) { - - if (selectionMode === 1) { // Shift - - if (selectedItems.includes(index)) return; - let nearest = -1 - for (let i = 0; i < selectedItems.length; i++) { - if (nearest === -1 || Math.abs(selectedItems[i] - index) < Math.abs(nearest - index)) { - nearest = selectedItems[i] - } - } - if (nearest !== -1) { - let start = Math.min(nearest, index) - let end = Math.max(nearest, index) - let v = selectedItems - for (let i = start; i <= end; i++) { - if (!selectedItems.includes(i)) v.push(i) - } - selectedItems = v - } - - } else if (selectionMode === 2) { // Ctrl - - if (selectedItems.includes(index)) { - let v = selectedItems.filter(i => i !== index) - selectedItems = v - } else { - let v = selectedItems - v.push(index) - selectedItems = v - } - - } else { - - if (!selectedItems.includes(index)) { - selectedItems = [index] - - var item = viewMode === 3 ? flatThumbnailModel[index] : visibleTreeList[index] - if (item.type != "header" && !item.data.is_folder) { - pendingPreviewPath = item.path - previewTimer.restart() - } - } - } - } - - XsGradientRectangle{ - anchors.fill: parent - } - - XsModuleData { - id: pluginData - modelDataName: "Filesystem Browser" - } - - XsModuleData { - id: filterTermAttrs - modelDataName: "Filter Terms" - } - - // State for Preview Mode - property bool isPreviewMode: false - property string pendingPreviewPath: "" - property bool haveSubFolders: false - - Timer { - id: previewTimer - interval: 200 // Wait for double click - repeat: false - onTriggered: { - if (pendingPreviewPath !== "") { - isPreviewMode = true - sendCommand({"action": "preview_file", "path": pendingPreviewPath}) - pendingPreviewPath = "" - } - } - } - - XsAttributeValue { - id: __deepScan - attributeTitle: "is_deepscan" - model: pluginData - } - property alias deepScan: __deepScan.value - - // Additional Attributes for History/Pins - XsAttributeValue { - id: history_attr - attributeTitle: "history_paths" - model: pluginData - role: "value" - - function updateList() { - var rawVal = value - try { - if (typeof(rawVal) === "string" && rawVal !== "") { - if (rawVal === "[]") { - historyList = [] - } else { - var parsed = JSON.parse(rawVal) - historyList = parsed - } - } else { - historyList = [] - } - } catch(e) { - //console.log("history_attr: Parse Error: " + e) - historyList = [] - } - } - - onValueChanged: updateList() - Component.onCompleted: updateList() - } - - XsAttributeValue { - id: pinned_attr - attributeTitle: "pinned_paths" - model: pluginData - role: "value" - - function updateList() { - var rawVal = value - try { - if (typeof(rawVal) === "string" && rawVal !== "") { - if (rawVal === "[]") { - pinnedList = [] - } else { - var parsed = JSON.parse(rawVal) - pinnedList = parsed - } - } else { - pinnedList = [] - } - } catch(e) { - //console.log("pinned_attr: Parse Error: " + e) - pinnedList = [] - } - } - - onValueChanged: updateList() - Component.onCompleted: updateList() - } - - property var historyList: [] - property var pinnedList: [] - property var combinedList: [] - property var filterField: "" - - function updateCombinedList() { - var combined = [] - var seen = new Set() // Set of paths - - // 1. Add Pinned Items - if (pinnedList) { - for (var i = 0; i < pinnedList.length; i++) { - var p = pinnedList[i] - combined.push({ - "name": p.name, - "path": p.path, - "isPinned": true - }) - // Add to seen set (mock Set using object for ES5/QML compat if needed, but modern QML has Set) - // actually JS in QML usually has Set. If not, use object keys. - seen.add(p.path) - } - } - - // 2. Add History Items - if (historyList) { - for (var j = 0; j < historyList.length; j++) { - var h = historyList[j] - if (!seen.has(h)) { - // Determine name (basename) - var name = h - if (h && h.indexOf("/") !== -1) { - var parts = h.split("/") - // Handle trailing slash - var last = parts[parts.length-1] - if (!last && parts.length > 1) last = parts[parts.length-2] - if (last) name = last - } - - combined.push({ - "name": name, - "path": h, - "isPinned": false - }) - seen.add(h) - } - } - } - - combinedList = combined - } - - // Trigger update when source lists change - onHistoryListChanged: updateCombinedList() - onPinnedListChanged: updateCombinedList() - - property bool isCurrentPinned: { - var curr = current_path_attr.value - for(var i=0; i 1) return false; - } - - return true; - } - - function updateTreeVisibility(nodes) { - var hasVisible = false; - for(var i=0; i 0) { - var rootAbs = current_path_attr.value || "" - - // Fast O(N·depth) group compression using cumulative descendant counts. - // For each file dir, walk UP until we find an ancestor with 2+ total - // descendant files. That ancestor becomes the group header. - - // 1. Accumulate file counts up the tree - var descCount = {} - for (var i = 0; i < thumbList.length; i++) { - var cursor = thumbList[i].folderGroup - while (cursor.length > rootAbs.length) { - descCount[cursor] = (descCount[cursor] || 0) + 1 - var sl = cursor.lastIndexOf("/") - cursor = sl > 0 ? cursor.substring(0, sl) : rootAbs - } - descCount[rootAbs] = (descCount[rootAbs] || 0) + 1 - } - - // 2. For each file, walk up from leaf to find lowest ancestor with >= 2 files - // (cache results to avoid redundant walks) - var groupCache = {} - for (var i = 0; i < thumbList.length; i++) { - var leaf = thumbList[i].folderGroup - if (groupCache[leaf] !== undefined) { - thumbList[i].folderGroup = groupCache[leaf] - continue - } - var d = leaf - while (d.length > rootAbs.length && (descCount[d] || 0) < 2) { - var sl = d.lastIndexOf("/") - d = sl > 0 ? d.substring(0, sl) : rootAbs - } - var grouped = (descCount[d] || 0) >= 2 ? d : rootAbs - groupCache[leaf] = grouped - thumbList[i].folderGroup = grouped - } - } - - // Sort by group then name - thumbList.sort(function(a, b) { - if (a.folderGroup < b.folderGroup) return -1 - if (a.folderGroup > b.folderGroup) return 1 - return a.name < b.name ? -1 : 1 - }) - - // Build complete flat mixed model - var flat = [] - var prevGrp = null - for (var j = 0; j < thumbList.length; j++) { - var t = thumbList[j] - if (t.folderGroup !== prevGrp) { - flat.push({ type: "header", path: t.folderGroup }) - prevGrp = t.folderGroup - } - flat.push({ type: "file", name: t.name, path: t.path, - frames: t.frames, thumbnailSource: t.thumbnailSource || "", data: t.data }) - } - flatThumbnailModel = flat - } - - function buildThumbsTree() { - - var thumbList = [] - for (var i = 0; i < fileList.length; i++) { - var file = fileList[i] - var isDir = (file.is_folder === true || file.type === "Folder") - thumbList.push({ - "name": file.name, - "path": file.path, - "isFolder": isDir, - "frames": file.frames || "", - "folderGroup": isDir ? file.path : file.path.replace(/\/[^\/]+$/, ""), // raw leaf dir - "thumbnailSource": file.thumbnailSource || "", - "data": file - }) - } - - // Sort by group then name - thumbList.sort(function(a, b) { - if (a.folderGroup < b.folderGroup) return -1 - if (a.folderGroup > b.folderGroup) return 1 - if (a.isFolder && !b.isFolder) return -1 - if (!a.isFolder && b.isFolder) return 1 - return a.name < b.name ? -1 : 1 - }) - - // Build complete flat mixed model - var flat = [] - for (var j = 0; j < thumbList.length; j++) { - var t = thumbList[j] - flat.push({ type: t.isFolder ? "header": "file", name: t.name, path: t.path, - frames: t.frames, thumbnailSource: t.thumbnailSource || "", data: t.data }) - } - - flatThumbnailModel = flat - } - - function buildTree() { - - var roots = [] - if (viewMode === 0) { - - var subfolders = false - // LIST VIEW: Flat list, no hierarchy logic - for(var i=0; i 0 ? v[v.length-1] : file.name - var node = { - "name": name, - "path": file.path, - "isFolder": false, - "data": file, - "children": [], - "expanded": false, - "visible": true // Default - } - roots.push(node) - } - treeRoots = roots - refreshFiltering() // Calculate visibility and flatten - sortTree() - haveSubFolders = subfolders - return - } - - // THUMBNAIL VIEW: files AND folders. Files are sorted *after* the folder - // that contains them. - if (viewMode === 3) { - buildThumbsTreeCompressed() - /*if (deepScan) buildThumbsTreeCompressed() - else buildThumbsTree()*/ - return - } - - // TREE / GROUPED VIEW - var lookups = {} - - function getFolderNode(path, name, parent) { - if (lookups[path]) return lookups[path]; - var node = { - "name": name, - "path": path, - "isFolder": true, - "children": [], - "data": null, - "expanded": (collapsedPaths[path] === undefined), - "visible": true - } - lookups[path] = node - if (parent) parent.children.push(node); - else roots.push(node); - return node - } - - var rootAbs = current_path_attr.value || "" - if (rootAbs !== "" && rootAbs.charAt(rootAbs.length-1) !== '/') rootAbs += '/' - - for(var i=0; i 0) compressNodes(node.children); - - while (node.children.length === 1) { - var child = node.children[0]; - node.name = node.name + "/" + child.name; - node.path = child.path; - node.data = child.data; - node.isFolder = child.isFolder; - node.children = child.children; - - if (node.isFolder) { - node.expanded = (collapsedPaths[node.path] === undefined); - } else { - node.expanded = false; - } - } - } - } - } - - // Only compress if in Grouped mode (2) - if (viewMode === 2) { - compressNodes(roots) - } - - treeRoots = roots - refreshFiltering() // Calculate visibility and flatten - sortTree() - } - - function sortTree() { - var col = sortColumn - var ord = sortOrder - - function recursiveSort(nodes) { - nodes.sort(function(a, b) { - if (a.isFolder !== b.isFolder) return (a.isFolder ? -1 : 1); - - if (a.isFolder) return a.name.localeCompare(b.name); - - var valA = a.data ? a.data[col] : "" - var valB = b.data ? b.data[col] : "" - - if (col === "size_str") { - var nA = parseFloat(valA) || 0 - var nB = parseFloat(valB) || 0 - return (nA - nB) * ord - } - if (col === "date" || col === "version" || col === "frames") { - return ((a.data ? (a.data[col]||0) : 0) - (b.data ? (b.data[col]||0) : 0)) * ord - } - - var sA = String(valA).toLowerCase() - var sB = String(valB).toLowerCase() - if (sA < sB) return -1 * ord - if (sA > sB) return 1 * ord - return 0 - }) - - for(var i=0; i 0) recursiveSort(nodes[i].children) - } - } - recursiveSort(treeRoots) - flattenTree() - } - - function flattenTree() { - var visible = [] - var subfolders = false - function traverse(nodes, depth) { - for(var i=0; i { - if (mouse.button === Qt.LeftButton) { - if (underMouseIndex != -1) { - selectItem(underMouseIndex, mouse.modifiers == Qt.ShiftModifier ? 1 : mouse.modifiers == Qt.ControlModifier ? 2 : 0) - } else { - previewTimer.stop() - isPreviewMode = false - sendCommand({"action": "preview_file", "path": ""}) - selectedItems = [] - } - } else if (mouse.button === Qt.RightButton) { - if (underMouseIndex != -1) { - if (!selectedItems.includes(underMouseIndex)) - selectItem(underMouseIndex, 0) - - let clickedItemPath = root.viewMode === 3 ? flatThumbnailModel[underMouseIndex].path : visibleTreeList[underMouseIndex].path - thumbContextMenu.selectedPaths = root.selectedItemsPaths - thumbContextMenu.itemPath = clickedItemPath - thumbContextMenu.showMenu( - mouseArea, - mouse.x, mouse.y); - } - } - } - - onDoubleClicked: (mouse) => { - if (mouse.button === Qt.LeftButton && underMouseIndex != -1) { - previewTimer.stop() - isPreviewMode = false - let clickedItemPath = root.viewMode === 3 ? flatThumbnailModel[underMouseIndex].path : visibleTreeList[underMouseIndex].path - sendCommand({"action": "load_file", "path": clickedItemPath}) - } - } - - onClicked: (mouse) => { - if (mouse.button === Qt.LeftButton && mouse.modifiers == Qt.NoModifier && underMouseIndex != -1) { - selectedItems = [] - selectItem(underMouseIndex, 0) - } - } - - DragHandler { - id: dragHandler - target: null - - onActiveChanged: { - if (active && underMouseIndex != -1) { - previewTimer.stop() - isPreviewMode = false - dragProxy.grabToImage(function(result) { - let d = root.selectedItemsPaths.map(p => encodeURIComponent(p)) - dragProxy.Drag.mimeData = {"text/plain": d.join("\n")} - dragProxy.Drag.imageSource = result.url - dragProxy.Drag.active = true - }) - } else { - dragProxy.Drag.active = false - } - } - } - - FSFileContextMenu { - id: thumbContextMenu - } - } - - // Nothing found message - ColumnLayout { - - anchors.centerIn: parent - spacing: 20 - XsText { - id: msg - visible: root.viewMode === 3 ? flatThumbnailModel.length === 0 : visibleTreeList.length === 0 - text: "No media found in " + current_path_attr.value + (haveSubFolders ? deepScan ? " or its subfolders" : ", but subfolders yet to be scanned." : "") - color: "#666666" - font.pixelSize: 18 - } - XsPrimaryButton { - Layout.preferredWidth: 200 - Layout.alignment: Qt.AlignHCenter - visible: msg.visible && haveSubFolders && !deepScan - text: "Run Full Scan" - onClicked: sendCommand({"action": "force_scan"}) - textDiv.font.pixelSize: 16 - } - } - - } - - // Scanned Dirs Log (Visible during scan) - Rectangle { - - Layout.fillWidth: true - Layout.preferredHeight: searching_attr.value ? 100 : 0 - color: "#1a1a1a" - visible: searching_attr.value === true - clip: true - - ListView { - anchors.fill: parent - model: scannedDirsList - clip: true - delegate: XsText { - text: modelData - color: "#888888" - font.pixelSize: 10 - width: ListView.view.width - elide: Text.ElideMiddle - horizontalAlignment: Text.AlignLeft - } - - // Auto-scroll to bottom - onCountChanged: { - positionViewAtEnd() - } - } - } - - } - } - - Item { - id: dragProxy - property var dragItem: underMouseIndex === -1 ? null : ( - root.viewMode === 3 ? thumbFlickable.thumbs.itemAt(underMouseIndex) : fileListView.itemAtIndex(underMouseIndex)) - property int dragCount: selectedItems.length - - width: dragItem ? Math.min(dragItem.width, 300) : 0 - height: dragItem ? Math.max(dragItem.height, 64) : 0 - visible: false - z: 100 - - Drag.dragType: Drag.Automatic - Drag.supportedActions: Qt.CopyAction - Drag.hotSpot.x: width/2 - Drag.hotSpot.y: height/2 - - ShaderEffectSource { - width: parent.width - height: parent.height - sourceRect.width: width - sourceRect.height: height - sourceItem: parent.dragItem - opacity: 0.65 - } - - // Count badge - Rectangle { - x: parent.width / 2 + 20 - y: parent.height / 2 - 20 - width: dragProxy.dragCount > 1 ? 22 : 0 - height: 22 - radius: 11 - color: "#e05a00" - clip: true - - Text { - anchors.centerIn: parent - text: dragProxy.dragCount - color: "#ffffff" - font.pixelSize: 10; font.weight: Font.Bold - } - } - } - - property alias dragProxy: dragProxy - - property alias thumbToolTip: thumbToolTip - XsToolTip { - - id: thumbToolTip - delay: 500 - maxWidth: 400 - } - -} diff --git a/src/plugin/python_plugins/filesystem_browser/qml/FilesystemBrowserXStudio.1/icons/folder_closed.svg b/src/plugin/python_plugins/filesystem_browser/qml/FilesystemBrowserXStudio.1/icons/folder_closed.svg deleted file mode 100644 index 281be32d9..000000000 --- a/src/plugin/python_plugins/filesystem_browser/qml/FilesystemBrowserXStudio.1/icons/folder_closed.svg +++ /dev/null @@ -1,38 +0,0 @@ - - - - - - diff --git a/src/plugin/python_plugins/filesystem_browser/qml/FilesystemBrowserXStudio.1/styled_widgets/FSFileContextMenu.qml b/src/plugin/python_plugins/filesystem_browser/qml/FilesystemBrowserXStudio.1/styled_widgets/FSFileContextMenu.qml deleted file mode 100644 index 10c43ac00..000000000 --- a/src/plugin/python_plugins/filesystem_browser/qml/FilesystemBrowserXStudio.1/styled_widgets/FSFileContextMenu.qml +++ /dev/null @@ -1,56 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -import QtQuick -import QtQuick.Layouts -import QtQuick.Controls - -import xStudio 1.0 -import xstudio.qml.models 1.0 -import xstudio.qml.helpers 1.0 -import ".." - -XsPopupMenu { - id: fileContextMenu - property string itemPath: "" - property var selectedPaths: [] - menu_model_name: "fbCtxMenu" + fileContextMenu - XsMenuModelItem { - text: "Replace" - menuItemPosition: 1 - menuPath: "" - onActivated: sendCommand({"action": "replace_current_media", "path": itemPath}) - menuModelName: fileContextMenu.menu_model_name - - } - XsMenuModelItem { - text: "Compare with" - menuItemPosition: 2 - menuPath: "" - onActivated: sendCommand({"action": "compare_with_current_media", "path": itemPath}) - menuModelName: fileContextMenu.menu_model_name - } - XsMenuModelItem { - text: "Append to Playlist" - menuItemPosition: 3 - menuPath: "" - onActivated: { - sendCommand({"action": "append_media", "paths": selectedPaths}) - } - menuModelName: fileContextMenu.menu_model_name - } - XsMenuModelItem { - text: "Copy Path" - menuPath: "" - onActivated: sendCommand({"action": "copy_path", "path": itemPath}) - menuModelName: fileContextMenu.menu_model_name - } - XsMenuModelItem { - text: "Show in Finder" - menuPath: "" - onActivated: { - helpers.showURIS([helpers.QUrlFromPosixPath(itemPath)]) - } - menuModelName: fileContextMenu.menu_model_name - } - - -} diff --git a/src/plugin/python_plugins/filesystem_browser/qml/FilesystemBrowserXStudio.1/styled_widgets/FSListItem.qml b/src/plugin/python_plugins/filesystem_browser/qml/FilesystemBrowserXStudio.1/styled_widgets/FSListItem.qml deleted file mode 100644 index 2a04d4e8c..000000000 --- a/src/plugin/python_plugins/filesystem_browser/qml/FilesystemBrowserXStudio.1/styled_widgets/FSListItem.qml +++ /dev/null @@ -1,65 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -import QtQuick -import QtQuick.Layouts - -import xStudio 1.0 - -import "." -import ".." - -Rectangle { - - id: delegate - height: XsStyleSheet.widgetStdHeight - - property bool isSelected: selectedItems.includes(index) - property bool isHovered: underMouseIndex === index - property string itemPath: modelData.path - property bool isItemFolder: modelData.isFolder - - Rectangle { - anchors.fill: parent - color: (isHovered ? XsStyleSheet.panelBgGradTopColor : (index % 2 == 0 ? XsStyleSheet.panelBgColor : Qt.lighter(XsStyleSheet.panelBgColor, 1.1))) - } - - Rectangle { - anchors.fill: parent - color: isSelected ? XsStyleSheet.accentColor : "transparent" - opacity: 0.5 - } - - property var indent: (modelData.depth || 0) * 20 + 10 - - // Cells - component Cell: XsText { - property int index: 0 - x: columnPositions[index] + (index === 0 ? indent + 24 : 0) - width: columnWidths[index] - property int elideMode: Text.ElideRight - height: parent.height - verticalAlignment: Text.AlignVCenter - horizontalAlignment: index ? Text.AlignHCenter : Text.AlignLeft - elide: elideMode - color: isSelected ? XsStyleSheet.primaryTextColor : XsStyleSheet.secondaryTextColor - } - - // Expander - XsSecondaryButton { - x: indent - width: visible ? 20 : 0 - height: visible ? 20 : 0 - imgSrc: "qrc:/icons/folder.svg" - visible: (root.viewMode !== 0 && root.viewMode !== 3 && modelData.isFolder) - //rotation: modelData.expanded ? 90 : 0 - imageSrcSize: width - onClicked: toggleExpand(index) - } - - Cell { index: 0; text: modelData.name || ""; elideMode: Text.ElideMiddle } - Cell { index: 1; text: (modelData.data && modelData.data.version) ? "v"+modelData.data.version : "" } - Cell { index: 2; text: (modelData.data && modelData.data.frames) || "" } - Cell { index: 3; text: (modelData.data && modelData.data.owner) || "" } - Cell { index: 4; text: modelData.data ? formatDate(modelData.data.date) : "" } - Cell { index: 5; text: (modelData.data && modelData.data.size_str) || "" } - -} \ No newline at end of file diff --git a/src/plugin/python_plugins/filesystem_browser/qml/FilesystemBrowserXStudio.1/styled_widgets/FSThumbItem.qml b/src/plugin/python_plugins/filesystem_browser/qml/FilesystemBrowserXStudio.1/styled_widgets/FSThumbItem.qml deleted file mode 100644 index 74249a3cd..000000000 --- a/src/plugin/python_plugins/filesystem_browser/qml/FilesystemBrowserXStudio.1/styled_widgets/FSThumbItem.qml +++ /dev/null @@ -1,188 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -import QtQuick -import QtQuick.Layouts -import QtQuick.Controls -import xStudio 1.0 - -import ".." -import "." - -Item { - - id: flatDelegate - - property bool visibleInFlickable: y > thumbFlickable.windowBottom && y < thumbFlickable.windowTop - - // ── Folder path header (spans full row) ──────────── - Rectangle { - anchors.fill: parent - visible: modelData.type === "header" - color: "#1a1a1a" - border.color: isHovered ? XsStyleSheet.accentColor : "transparent" - border.width: 1 - XsIcon { - id: folderIcon - anchors.verticalCenter: parent.verticalCenter - anchors.left: parent.left; anchors.leftMargin: 10 - width: 24; height: 24 - source: "qrc:/icons/folder.svg" - } - XsText { - anchors.verticalCenter: parent.verticalCenter - anchors.left: folderIcon.right; anchors.leftMargin: 10 - text: modelData.type === "header" ? modelData.path : "" - font.bold: true - elide: Text.ElideLeft - font.pixelSize: 14 - } - } - - // ── Thumbnail cell ────────────────────────────────── - property bool isSelected: selectedItems.includes(index) - property bool isHovered: underMouseIndex === index - property bool infoHighlighted: false - - Rectangle { - id: bgrect - anchors.fill: parent; anchors.margins: 5 - visible: modelData.type === "file" - color: XsStyleSheet.widgetBgNormalColor - radius: 4 - } - - Rectangle { - radius: 4 - anchors.fill: bgrect - visible: isSelected ? modelData.type === "file" : 0 - color: XsStyleSheet.accentColor - opacity: 0.5 - border.color: "#777777" - border.width: 2 - - } - - Rectangle { - radius: 4 - anchors.fill: bgrect - visible: modelData.type === "file" - color: isHovered ? XsStyleSheet.hintColor : "transparent" - opacity: 0.5 - border.color: "#777777" - border.width: 1 - } - - function mouseMove(x, y) { - var pt = mapToItem(infoIcon, x, y) - if (pt.x >= 0 && pt.x <= infoIcon.width && pt.y >= 0 && pt.y <= infoIcon.height) { - infoIcon.infoHighlighted = true - } else { - infoIcon.infoHighlighted = false - } - } - - - ColumnLayout { - - anchors.fill: parent; anchors.margins: 10 - spacing: 4 - visible: modelData.type === "file" - - Item { - - Layout.fillWidth: true; Layout.fillHeight: true - - // This adds a big computation load to the QML scene graph if there - // are many thumbnails - /*BusyIndicator { - anchors.centerIn: parent; width: 30; height: 30 - running: !modelData.data.thumbnailSource && modelData.type === "file" - visible: running - }*/ - - Image { - anchors.fill: parent - property var thumbSource: "image://thumbnail/file://" + modelData.data.thumbnailFrame - source: (visibleInFlickable || loaded) ? thumbSource : "" - fillMode: Image.PreserveAspectFit - asynchronous: true - Component.onDestruction: source = "" - property bool loaded: false - onStatusChanged: { - if (status === Image.Ready) { - loaded = true - } - } - } - - XsIcon { - id: infoIcon - anchors.top: parent.top; anchors.right: parent.right - anchors.margins: 4 - width: 20; height: 20 - source: "qrc:/icons/info.svg" - visible: isHovered - opacity: infoHighlighted ? 1.0 : 0.6 - property bool infoHighlighted: false - onInfoHighlightedChanged: { - if (infoHighlighted) { - var pt = mapToItem(root, 10, 10) - thumbToolTip.x = pt.x - thumbToolTip.y = pt.y - thumbToolTip.text = tooltipText() - thumbToolTip.visible = true - } else { - thumbToolTip.visible = false - } - } - - } - } - - Item { - Layout.fillWidth: true; height: 32; clip: true - property string rawName: modelData.name || "" - property string ext: { - var d = rawName.lastIndexOf(".") - return d >= 0 ? rawName.slice(d + 1) : "" - } - property string stem: { - var d = rawName.lastIndexOf(".") - return d >= 0 ? rawName.slice(0, d) : rawName - } - property string baseName: stem.replace(/[#@%]+$/, "").replace(/\.$/, "") - property string frameRange: modelData.frames || "" - - XsText { - anchors.top: parent.top - anchors.left: parent.left; anchors.right: parent.right - text: parent.baseName; color: "#e0e0e0"; font.pixelSize: 11 - horizontalAlignment: Text.AlignHCenter; elide: Text.ElideMiddle - } - XsText { - anchors.bottom: parent.bottom; anchors.left: parent.left - text: parent.ext; color: "#888888"; font.pixelSize: 10 - visible: parent.ext !== "" - } - XsText { - anchors.bottom: parent.bottom; anchors.right: parent.right - text: parent.frameRange; color: "#888888"; font.pixelSize: 10 - visible: parent.frameRange !== "" - } - } - } - - function tooltipText() { - if (modelData.type !== "file") return "" - // parent directory path only - var txt = modelData.path - var sl = txt.lastIndexOf("/") - if (sl >= 0) txt = txt.substring(0, sl) - - txt += "\n" + (modelData.name || "") - if (modelData.frames) txt += "\nFrames: " + modelData.frames - if (modelData.data && modelData.data.date) txt += "\nModified: " + formatDate(modelData.data.date) - if (modelData.data && modelData.data.size_str) txt += "\nSize: " + modelData.data.size_str - return txt - } - -} // delegate \ No newline at end of file diff --git a/src/plugin/python_plugins/filesystem_browser/qml/FilesystemBrowserXStudio.1/styled_widgets/FSThumbView.qml b/src/plugin/python_plugins/filesystem_browser/qml/FilesystemBrowserXStudio.1/styled_widgets/FSThumbView.qml deleted file mode 100644 index c6f095af9..000000000 --- a/src/plugin/python_plugins/filesystem_browser/qml/FilesystemBrowserXStudio.1/styled_widgets/FSThumbView.qml +++ /dev/null @@ -1,183 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -import QtQuick -import QtQuick.Layouts -import QtQuick.Controls -import Qt.labs.qmlmodels -import xStudio 1.0 - -import ".." -import "." - -// Thumbnail view: Flickable + Flow for reliable scrolling with folder headers -Flickable { - - id: thumbFlickable - anchors.fill: parent - visible: root.viewMode === 3 - clip: true - contentWidth: width - contentHeight: thumbFlow.implicitHeight - flickableDirection: Flickable.VerticalFlick - - focus: visible - onVisibleChanged: { if (visible) forceActiveFocus() } - - Keys.onLeftPressed: (event) => { - if (selectedItems.length == 0) return - - var newIdx = selectedItems[0] - do { - if (newIdx > 0) newIdx-- - else break - } while (flatThumbnailModel[newIdx] && flatThumbnailModel[newIdx].type === "header") - - selectedItems = [newIdx] - _handleThumbKeyPreview() - event.accepted = true - } - - Keys.onRightPressed: (event) => { - - if (selectedItems.length == 0) return - var newIdx = selectedItems[selectedItems.length-1] - var maxIdx = flatThumbnailModel.length - 1 - do { - if (newIdx < maxIdx) newIdx++ - else break - } while (flatThumbnailModel[newIdx] && flatThumbnailModel[newIdx].type === "header") - selectedItems = [newIdx] - _handleThumbKeyPreview() - event.accepted = true - } - Keys.onUpPressed: (event) => { - if (selectedItems.length == 0) return - var cols = Math.max(1, Math.floor(thumbFlow.width / 160)) - var newIdx = selectedItems[0] - cols - if (newIdx >= 0) { - while (newIdx > 0 && flatThumbnailModel[newIdx] && flatThumbnailModel[newIdx].type === "header") newIdx-- - selectedItems = [newIdx] - _handleThumbKeyPreview() - } - event.accepted = true - } - Keys.onDownPressed: (event) => { - var cols = Math.max(1, Math.floor(thumbFlow.width / 160)) - var maxIdx = flatThumbnailModel.length - 1 - var newIdx = selectedItems[selectedItems.length-1] + cols - if (newIdx <= maxIdx) { - while (newIdx < maxIdx && flatThumbnailModel[newIdx] && flatThumbnailModel[newIdx].type === "header") newIdx++ - selectedItems = [newIdx] - _handleThumbKeyPreview() - } - event.accepted = true - } - Keys.onReturnPressed: (event) => _handleThumbReturn(event) - Keys.onEnterPressed: (event) => _handleThumbReturn(event) - - function _handleThumbReturn(event) { - for (var i = 0; i < selectedItems.length; i++) { - var idx = selectedItems[i] - if (idx >= 0 && idx < flatThumbnailModel.length) { - var md = flatThumbnailModel[idx] - if (md && md.type === "file") { - previewTimer.stop() - isPreviewMode = false - sendCommand({"action": "load_file", "path": md.path}) - } - } - } - event.accepted = true - } - - function _handleThumbKeyPreview() { - if (thumbCurrentIndex >= 0 && thumbCurrentIndex < flatThumbnailModel.length) { - var md = flatThumbnailModel[thumbCurrentIndex] - if (md && md.type === "file") { - root.pendingPreviewPath = md.path - previewTimer.restart() - } - } - } - - - ScrollBar.vertical: XsScrollBar { - policy: ScrollBar.AlwaysOn - } - - // these properties track the current visible area of the flicable, - // to allow thumb delegates to decide when to load/clear their thumbnail source - // for better thumbnail load performance - property var windowBottom: 0 - property var windowTop: thumbFlickable.height + 160 - property var yMin: 0 - - // When the user is scrolling we delay update of the windowBottom/windowTop - // properties until scrolling has stopped for 200ms, to avoid excessive - // thumbnail loading/unloading during scroll - Timer { - id: scrollChangeTimer - interval: 200 - onTriggered: { - yMin = thumbFlickable.contentY - thumbFlickable.originY - windowBottom = yMin - 160 - windowTop = windowBottom + thumbFlickable.height + 160 - } - } - onOriginYChanged: scrollChangeTimer.restart() - - property var mousePosition: mouse - property var mousePositionInFlickArea: Qt.point(mousePosition.x, mousePosition.y + yMin) - onMousePositionChanged: { - if (!visible) return - for (var i = 0; i < flatThumbnailModel.length; i++) { - var item = thumbFlow.thumbRepeater.itemAt(i) - if (item) { - if (item.x < mousePositionInFlickArea.x && item.y < mousePositionInFlickArea.y && - (item.x + item.width) > mousePositionInFlickArea.x && (item.y + item.height) > mousePositionInFlickArea.y) { - underMouseIndex = i - var pt = mapToItem(item, mousePosition) - item.mouseMove(pt.x, pt.y) - return - } - } - } - underMouseIndex = -1 - } - - property alias thumbs: thumbFlow.thumbRepeater - - Flow { - id: thumbFlow - width: thumbFlickable.contentWidth - spacing: 0 - property alias thumbRepeater: thumbRepeater - - Repeater { - id: thumbRepeater - model: flatThumbnailModel - - delegate: DelegateChooser { - role: "type" - DelegateChoice { - roleValue: "header" - FSThumbFolderHeader { - width: thumbFlow.width - height: 32 - } - } - DelegateChoice { - roleValue: "file" - FSThumbItem { - width: 160 - height: 120 - } - } - } - } // Repeater - } // Flow - - onContentYChanged: { - scrollChangeTimer.restart() - } - -} // Flickable diff --git a/src/plugin/python_plugins/utility/__init__.py b/src/plugin/python_plugins/utility/__init__.py new file mode 100644 index 000000000..c1851c507 --- /dev/null +++ b/src/plugin/python_plugins/utility/__init__.py @@ -0,0 +1,2 @@ +# SPDX-License-Identifier: Apache-2.0 +from .utility_py_plugin import create_plugin_instance \ No newline at end of file diff --git a/src/plugin/python_plugins/utility/utility_py_plugin.py b/src/plugin/python_plugins/utility/utility_py_plugin.py new file mode 100644 index 000000000..10872c2de --- /dev/null +++ b/src/plugin/python_plugins/utility/utility_py_plugin.py @@ -0,0 +1,99 @@ +#!/usr/bin/env python +# SPDX-License-Identifier: Apache-2.0 + +import numpy as np + +from xstudio.core import AttributeRole, Uuid +from xstudio.plugin import PluginBase + +# This plugin is intended to allow for various utility functions to be +# implemented in Python. The first utility function is to allow for the +# setting of media flip/flop. + +class UtilityPythonPlugin(PluginBase): + """Python plugin for various utility functions.""" + + def __init__(self, connection): + """Initialize the utility plugin. + + """ + PluginBase.__init__( + self, + connection, + name="UtilityPythonPlugin" + ) + + # Flip/Flop setting + + self.flip_flop_attr = self.add_attribute( + attribute_name="Flip/Flop", + attribute_value="None", + attribute_role_data={ + "combo_box_options": ["None", "Flop", "Flip", "Flip/Flop"], + "combo_box_options_enabled": [True, True, True, True], + } + ) + + self.flip_flop_menu_id = self.insert_menu_item( + menu_model_name="media_list_menu_", + menu_text="Set Flip/Flop", + menu_path="Media Settings", + menu_item_position=1.0, + attr_id=self.flip_flop_attr.uuid, + ) + + self.set_submenu_position( + menu_model_name="media_list_menu_", + submenu_path="Media Settings|Set Flip/Flop", + menu_item_position=3.9) + + self.connect_to_ui() + self.curr_val = "None" + + def menu_item_shown(self, menu_item_data, user_data): + """Handle menu item shown event to update menu state. + """ + shown_uuid = Uuid(menu_item_data["uuid"]) + + # Always base the Auto label off the main selection + media = self.connection.api.session.selected_media[0] + # Update auto labels when menu is shown based on the current media metadata + if shown_uuid == self.flip_flop_attr.uuid: + matrix = media.transform_matrix + flopped = matrix[0][0] < 0 + flipped = matrix[1][1] < 0 + self.curr_val = "None" + if flopped and flipped: + self.curr_val = "Flip/Flop" + elif flopped: + self.curr_val = "Flop" + elif flipped: + self.curr_val = "Flip" + self.flip_flop_attr.set_value(self.curr_val) + + def attribute_changed(self, attribute, role): + """Handle attribute changes. + + * Ignore changes that are not value changes. + * Support batch changes when multiple media are selected + """ + if role != AttributeRole.Value: + return + + if attribute == self.flip_flop_attr: + flip_flop_value = self.flip_flop_attr.value() + if self.curr_val == flip_flop_value: + return + medias = self.connection.api.session.selected_media + flip = flip_flop_value in ["Flip", "Flip/Flop"] + flop = flip_flop_value in ["Flop", "Flip/Flop"] + + for media in medias: + tr = media.transform_matrix + tr[0] = [-abs(tr[0][0]) if flop else abs(tr[0][0]), tr[0][1], tr[0][2], tr[0][3]] + tr[1] = [tr[1][0], -abs(tr[1][1]) if flip else abs(tr[1][1]), tr[1][2], tr[1][3]] + media.transform_matrix = tr + +def create_plugin_instance(connection): + """Create and return a plugin instance.""" + return UtilityPythonPlugin(connection) diff --git a/src/plugin/utility/CMakeLists.txt b/src/plugin/utility/CMakeLists.txt index beb30c9cf..0f1a8b37c 100644 --- a/src/plugin/utility/CMakeLists.txt +++ b/src/plugin/utility/CMakeLists.txt @@ -1,3 +1,3 @@ add_src_and_test(session_snapshots) - +add_src_and_test(filesystem_browser) build_studio_plugins("${STUDIO_PLUGINS}") diff --git a/src/plugin/utility/filesystem_browser/CMakeLists.txt b/src/plugin/utility/filesystem_browser/CMakeLists.txt new file mode 100644 index 000000000..68422d357 --- /dev/null +++ b/src/plugin/utility/filesystem_browser/CMakeLists.txt @@ -0,0 +1,64 @@ +############################################################### +# +# Find Dependencies +# +############################################################### + +project(filesystem_browser VERSION ${XSTUDIO_GLOBAL_VERSION} LANGUAGES CXX) + +find_package(Qt6 COMPONENTS Core Concurrent Widgets Qml Quick REQUIRED) + +set(CMAKE_INCLUDE_CURRENT_DIR ON) +set(CMAKE_AUTOMOC ON) +set(CMAKE_AUTORCC ON) + +############################################################### +# +# xSTUDIO File Browser Plugin +# +############################################################### + +QT6_WRAP_CPP(SYNC_UI_MOC_SRC "${CMAKE_CURRENT_SOURCE_DIR}/scan_results_datamodel_ui.hpp") + +set(SOURCES + scan_results_datamodel_ui.cpp + FileSystemBrowser/resources/file_system_browser.qrc + ${SYNC_UI_MOC_SRC}) + +add_library(${PROJECT_NAME} SHARED ${SOURCES}) + +target_link_libraries(${PROJECT_NAME} + PUBLIC + xstudio::ui::qml::helper + xstudio::utility + xstudio::plugin_manager + xstudio::thumbnail + xstudio::bookmark + CAF::core + Qt6::Core + Qt6::Qml + Qt6::Quick + Qt6::Widgets + Qt6::Concurrent +) + +target_compile_definitions(${PROJECT_NAME} + PRIVATE XSTUDIO_GLOBAL_VERSION=\"${XSTUDIO_GLOBAL_VERSION}\") + +set_target_properties(${PROJECT_NAME} PROPERTIES LINK_DEPENDS_NO_SHARED true) + +file(GLOB QML_SOURCES RELATIVE ${CMAKE_CURRENT_LIST_DIR} FileSystemBrowser/xstudio/*.qml) + +qt_add_qml_module(${PROJECT_NAME} + URI FileSystemBrowser + VERSION 1.0 + NO_GENERATE_PLUGIN_SOURCE + NO_PLUGIN_OPTIONAL + RESOURCE_PREFIX / + PLUGIN_TARGET ${PROJECT_NAME} + QML_FILES ${QML_SOURCES} +) + +default_plugin_options(${PROJECT_NAME}) + +add_python_plugin(filesystem_browser_python) diff --git a/src/plugin/python_plugins/filesystem_browser/qml/FilesystemBrowserGeneric.1/DirectoryTree.qml b/src/plugin/utility/filesystem_browser/FileSystemBrowser/generic/DirectoryTree.qml similarity index 100% rename from src/plugin/python_plugins/filesystem_browser/qml/FilesystemBrowserGeneric.1/DirectoryTree.qml rename to src/plugin/utility/filesystem_browser/FileSystemBrowser/generic/DirectoryTree.qml diff --git a/src/plugin/python_plugins/filesystem_browser/qml/FilesystemBrowserGeneric.1/FilesystemBrowser.qml b/src/plugin/utility/filesystem_browser/FileSystemBrowser/generic/FilesystemBrowser.qml similarity index 100% rename from src/plugin/python_plugins/filesystem_browser/qml/FilesystemBrowserGeneric.1/FilesystemBrowser.qml rename to src/plugin/utility/filesystem_browser/FileSystemBrowser/generic/FilesystemBrowser.qml diff --git a/src/plugin/python_plugins/filesystem_browser/qml/FilesystemBrowserGeneric.1/XsFileSystemStyle.qml b/src/plugin/utility/filesystem_browser/FileSystemBrowser/generic/XsFileSystemStyle.qml similarity index 100% rename from src/plugin/python_plugins/filesystem_browser/qml/FilesystemBrowserGeneric.1/XsFileSystemStyle.qml rename to src/plugin/utility/filesystem_browser/FileSystemBrowser/generic/XsFileSystemStyle.qml diff --git a/src/plugin/python_plugins/filesystem_browser/qml/FilesystemBrowserGeneric.1/qmldir b/src/plugin/utility/filesystem_browser/FileSystemBrowser/generic/qmldir similarity index 100% rename from src/plugin/python_plugins/filesystem_browser/qml/FilesystemBrowserGeneric.1/qmldir rename to src/plugin/utility/filesystem_browser/FileSystemBrowser/generic/qmldir diff --git a/src/plugin/python_plugins/filesystem_browser/qml/FilesystemBrowserXStudio.1/qmldir b/src/plugin/utility/filesystem_browser/FileSystemBrowser/qmldir similarity index 100% rename from src/plugin/python_plugins/filesystem_browser/qml/FilesystemBrowserXStudio.1/qmldir rename to src/plugin/utility/filesystem_browser/FileSystemBrowser/qmldir diff --git a/src/plugin/utility/filesystem_browser/FileSystemBrowser/resources/file_system_browser.qrc b/src/plugin/utility/filesystem_browser/FileSystemBrowser/resources/file_system_browser.qrc new file mode 100644 index 000000000..7a8e2bd85 --- /dev/null +++ b/src/plugin/utility/filesystem_browser/FileSystemBrowser/resources/file_system_browser.qrc @@ -0,0 +1,6 @@ + + + folder_closed.svg + refresh.svg + + diff --git a/src/plugin/python_plugins/filesystem_browser/qml/FilesystemBrowserGeneric.1/icons/folder_closed.svg b/src/plugin/utility/filesystem_browser/FileSystemBrowser/resources/folder_closed.svg similarity index 97% rename from src/plugin/python_plugins/filesystem_browser/qml/FilesystemBrowserGeneric.1/icons/folder_closed.svg rename to src/plugin/utility/filesystem_browser/FileSystemBrowser/resources/folder_closed.svg index 281be32d9..36f119c96 100644 --- a/src/plugin/python_plugins/filesystem_browser/qml/FilesystemBrowserGeneric.1/icons/folder_closed.svg +++ b/src/plugin/utility/filesystem_browser/FileSystemBrowser/resources/folder_closed.svg @@ -1,38 +1,38 @@ - - - - - - + + + + + + diff --git a/src/plugin/python_plugins/filesystem_browser/qml/FilesystemBrowserXStudio.1/icons/refresh.svg b/src/plugin/utility/filesystem_browser/FileSystemBrowser/resources/refresh.svg similarity index 100% rename from src/plugin/python_plugins/filesystem_browser/qml/FilesystemBrowserXStudio.1/icons/refresh.svg rename to src/plugin/utility/filesystem_browser/FileSystemBrowser/resources/refresh.svg diff --git a/src/plugin/utility/filesystem_browser/FileSystemBrowser/xstudio/FSDirectoryGroup.qml b/src/plugin/utility/filesystem_browser/FileSystemBrowser/xstudio/FSDirectoryGroup.qml new file mode 100644 index 000000000..a5cca5dc0 --- /dev/null +++ b/src/plugin/utility/filesystem_browser/FileSystemBrowser/xstudio/FSDirectoryGroup.qml @@ -0,0 +1,99 @@ +// SPDX-License-Identifier: Apache-2.0 +import QtQuick +import QtQuick.Layouts +import QtQuick.Controls +import Qt.labs.qmlmodels + +import xStudio 1.0 + +import ".." +import "." + +Item { + + id: itemGroup + property var thumbsModel + property var thumbsModelIndex + property var depth: 0 + + //Layout.preferredHeight: num_thumbnail_rows*130 + header.height + 10 + implicitHeight: header.height + layout.height//num_expanded_subitems*XsStyleSheet.widgetStdHeight + header.height + //visible: (depth == 0 || is_visible) + + property var yInFlick: y+parentYInFlick + property var parentYInFlick: 0 + + FSListItem { + id: header + width: parent.width + visible: depth != 0 + height: depth != 0 ? XsStyleSheet.widgetStdHeight : 0 + modelIndex: thumbsModelIndex + } + + DelegateModel { + id: delegateModel + model: thumbsModel + rootIndex: thumbsModelIndex + delegate: chooser + } + + DelegateChooser { + id: chooser + role: "is_folder" + DelegateChoice { + roleValue: false + FSListItem { + Layout.fillWidth: true + modelIndex: thumbsModel.index(index, 0 , thumbsModelIndex) + } + } + DelegateChoice { + roleValue: true + Item { + id: container + width: itemGroup.width + Layout.fillWidth: true + property var group: undefined + implicitHeight: group ? group.implicitHeight : 0 + Component.onCompleted: { + let component = Qt.createComponent("./FSDirectoryGroup.qml") + if (component.status == Component.Ready) { + group = component.createObject( + container, + { + width: parent.width, + parentYInFlick: itemGroup.yInFlick, + depth: itemGroup.depth + 1, + thumbsModel: itemGroup.thumbsModel, + thumbsModelIndex: thumbsModel.index(index, 0, itemGroup.thumbsModelIndex) + }) + } + } + } + } + } + + ColumnLayout { + anchors.top: header.bottom + width: parent.width + id: layout + spacing: 0 + Repeater { + id: thumbRepeater + model: (depth == 0 || is_expanded) ? delegateModel : undefined + } + } + + function getItemAtIndex(index, depth) { + let p = index + let d = depth + while (d) { + p = p.parent + d-- + } + if (depth == 1) return thumbRepeater.itemAt(p.row) + return thumbRepeater.itemAt(p.row).getItemAtIndex(index, depth-1) + } + +} // delegate \ No newline at end of file diff --git a/src/plugin/python_plugins/filesystem_browser/qml/FilesystemBrowserXStudio.1/styled_widgets/FSDirectoryTree.qml b/src/plugin/utility/filesystem_browser/FileSystemBrowser/xstudio/FSDirectoryTree.qml similarity index 97% rename from src/plugin/python_plugins/filesystem_browser/qml/FilesystemBrowserXStudio.1/styled_widgets/FSDirectoryTree.qml rename to src/plugin/utility/filesystem_browser/FileSystemBrowser/xstudio/FSDirectoryTree.qml index 0e3b46384..965449a1e 100644 --- a/src/plugin/python_plugins/filesystem_browser/qml/FilesystemBrowserXStudio.1/styled_widgets/FSDirectoryTree.qml +++ b/src/plugin/utility/filesystem_browser/FileSystemBrowser/xstudio/FSDirectoryTree.qml @@ -9,7 +9,6 @@ import "." // Tree Container Rectangle { - SplitView.preferredWidth: showDirectoryTree ? 250 : 30 SplitView.minimumWidth: showDirectoryTree ? 150 : 30 SplitView.maximumWidth: showDirectoryTree ? 1000 : 30 color: XsStyleSheet.panelBgColor diff --git a/src/plugin/python_plugins/filesystem_browser/qml/FilesystemBrowserXStudio.1/styled_widgets/FSDirectoryTreeBody.qml b/src/plugin/utility/filesystem_browser/FileSystemBrowser/xstudio/FSDirectoryTreeBody.qml similarity index 92% rename from src/plugin/python_plugins/filesystem_browser/qml/FilesystemBrowserXStudio.1/styled_widgets/FSDirectoryTreeBody.qml rename to src/plugin/utility/filesystem_browser/FileSystemBrowser/xstudio/FSDirectoryTreeBody.qml index 74c94ea47..e60eb02d3 100644 --- a/src/plugin/python_plugins/filesystem_browser/qml/FilesystemBrowserXStudio.1/styled_widgets/FSDirectoryTreeBody.qml +++ b/src/plugin/utility/filesystem_browser/FileSystemBrowser/xstudio/FSDirectoryTreeBody.qml @@ -24,6 +24,22 @@ Item { // Auto-expand logic property string pendingExpandPath: "" property bool isSyncing: false + readonly property string pathSep: Qt.platform.os === "windows" ? "\\" : "/" + property bool wasClicked: false + + Timer { + id: clickResetTimer + interval: 1000 + running: false + repeat: false + onTriggered: treeRoot.wasClicked = false + } + onWasClickedChanged: { + if (wasClicked) { + clickResetTimer.start(); + } + } + function getPathDepth(p) { if (!p || p === "/") return 0; @@ -44,6 +60,11 @@ Item { } } + function stripTrailingPathSeparator(p) { + p = p.endsWith(pathSep) ? p.slice(0, -pathSep.length) : p + return p; + } + function syncToPath() { if (!pendingExpandPath) return; @@ -73,21 +94,25 @@ Item { } if (deepestIndex !== -1) { - var node = treeModel.get(deepestIndex); - - if (node.path === pendingExpandPath) { + + var node = treeModel.get(deepestIndex); + if (stripTrailingPathSeparator(node.path) === stripTrailingPathSeparator(pendingExpandPath)) { // We reached the target! treeView.currentIndex = deepestIndex; pendingExpandPath = ""; isSyncing = false; // Ensure visible - treeView.positionViewAtIndex(deepestIndex, ListView.Visible); + if (!wasClicked) { // Don't auto-scroll if user just clicked to navigate + treeView.positionViewAtIndex(deepestIndex, ListView.Center); + } // Also expand to show children as requested if (!node.expanded) { // Actually - we don't want to auto expand // expandNode(deepestIndex); } + } else { + // We need to go deeper. Expand this node if not expanded. if (!node.expanded) { expandNode(deepestIndex); @@ -281,7 +306,7 @@ Item { "path": d.path, "level": parentLevel + 1, "expanded": false, - "hasChildren": true, + "hasChildren": d.has_subdir, "isLoading": false }); } @@ -400,7 +425,7 @@ Item { Rectangle { anchors.fill: parent - color: (model.path === treeRoot.currentPath) ? XsStyleSheet.accentColor : isHovered ? XsStyleSheet.hintColor : "transparent" + color: (stripTrailingPathSeparator(model.path) === stripTrailingPathSeparator(treeRoot.currentPath)) ? XsStyleSheet.accentColor : isHovered ? XsStyleSheet.hintColor : "transparent" opacity: 0.33 } @@ -414,6 +439,7 @@ Item { onClicked: (mouse) => { if (mouse.button === Qt.LeftButton) { sendCommand({"action": "change_path", "path": model.path}); + wasClicked = true; } else if (mouse.button === Qt.RightButton) { treeContextMenu.path = model.path; treeContextMenu.showMenu(msgMouse, mouse.x, mouse.y); @@ -439,6 +465,7 @@ Item { imgSrc: "qrc:/icons/chevron_right.svg" rotation: model.expanded ? 90 : 0 Behavior on rotation {NumberAnimation{duration: 150 }} + visible: model.hasChildren // Show expander if we know there are subdirs, or if we don't know yet (optimistic) onClicked: { if (model.expanded) { collapseNode(index); @@ -447,6 +474,10 @@ Item { } } } + + Item { + Layout.preferredWidth: model.hasChildren ? 0 : 20 // Align with expander space if no children + } // Folder Icon XsIcon { diff --git a/src/plugin/utility/filesystem_browser/FileSystemBrowser/xstudio/FSFileContextMenu.qml b/src/plugin/utility/filesystem_browser/FileSystemBrowser/xstudio/FSFileContextMenu.qml new file mode 100644 index 000000000..e5ce93517 --- /dev/null +++ b/src/plugin/utility/filesystem_browser/FileSystemBrowser/xstudio/FSFileContextMenu.qml @@ -0,0 +1,94 @@ +// SPDX-License-Identifier: Apache-2.0 +import QtQuick +import QtQuick.Layouts +import QtQuick.Controls + +import xStudio 1.0 +import xstudio.qml.models 1.0 +import xstudio.qml.helpers 1.0 +import xstudio.qml.clipboard 1.0 +import ".." + +XsPopupMenu { + id: fileContextMenu + property string itemPath: "" + menu_model_name: "fbCtxMenu" + fileContextMenu + + Clipboard { + id: clipboard + } + + XsMenuModelItem { + text: "Add To New Playlist" + menuItemPosition: 1 + menuPath: "" + onActivated: sendCommand({"action": "add_to_new_playlist", "paths": selectedItemsPaths}) + menuModelName: fileContextMenu.menu_model_name + + } + XsMenuModelItem { + text: "Add To Current Playlist" + menuItemPosition: 2 + menuPath: "" + onActivated: sendCommand({"action": "append_media", "paths": selectedItemsPaths}) + menuModelName: fileContextMenu.menu_model_name + } + XsMenuModelItem { + text: "Compare" + menuItemPosition: 3 + menuPath: "" + onActivated: { + sendCommand({"action": "compare_with_current_media", "paths": selectedItemsPaths}) + } + menuModelName: fileContextMenu.menu_model_name + } + XsMenuModelItem { + text: "Replace" + menuItemPosition: 4 + menuPath: "" + onActivated: { + sendCommand({"action": "replace_current_media", "paths": selectedItemsPaths}) + } + menuModelName: fileContextMenu.menu_model_name + } + + XsMenuModelItem { + text: "Reveal On Disk ..." + menuPath: "" + menuItemPosition: 5 + onActivated: { + if (itemPath) { + helpers.showURIS([helpers.QUrlFromPosixPath(itemPath)]) + } else if (selectedItemsPaths.length > 0) { + helpers.showURIS([helpers.QUrlFromPosixPath(selectedItemsPaths[0])]) + } + } + menuModelName: fileContextMenu.menu_model_name + } + XsMenuModelItem { + text: "Copy Selected File Names" + menuPath: "" + menuItemPosition: 6 + onActivated: { + var v = [] + for (var i = 0; i < selectedItemsPaths.length; ++i) { + var path = selectedItemsPaths[i] + var fileName = helpers.QUrlFromPosixPath(path).fileName + v.push(fileName) + } + clipboard.text = v.join("\n") + } + menuModelName: fileContextMenu.menu_model_name + } + + XsMenuModelItem { + text: "Copy Selected File Paths" + menuPath: "" + menuItemPosition: 7 + onActivated: { + clipboard.text = selectedItemsPaths.join("\n") + } + menuModelName: fileContextMenu.menu_model_name + } + +} diff --git a/src/plugin/python_plugins/filesystem_browser/qml/FilesystemBrowserXStudio.1/styled_widgets/FSListHeader.qml b/src/plugin/utility/filesystem_browser/FileSystemBrowser/xstudio/FSListHeader.qml similarity index 99% rename from src/plugin/python_plugins/filesystem_browser/qml/FilesystemBrowserXStudio.1/styled_widgets/FSListHeader.qml rename to src/plugin/utility/filesystem_browser/FileSystemBrowser/xstudio/FSListHeader.qml index f60f4cea2..b1fe7f949 100644 --- a/src/plugin/python_plugins/filesystem_browser/qml/FilesystemBrowserXStudio.1/styled_widgets/FSListHeader.qml +++ b/src/plugin/utility/filesystem_browser/FileSystemBrowser/xstudio/FSListHeader.qml @@ -50,21 +50,6 @@ Rectangle{ } } - - RowLayout{ - - id: titleBar - width: parent.width - height: parent.height - spacing: 0 - - Repeater{ - id: repeater - model: columns_model - } - - } - MouseArea { id: mouseArea anchors.fill: parent @@ -122,5 +107,18 @@ Rectangle{ } + RowLayout{ + + id: titleBar + width: parent.width + height: parent.height + spacing: 0 + + Repeater{ + id: repeater + model: columns_model + } + + } } \ No newline at end of file diff --git a/src/plugin/python_plugins/filesystem_browser/qml/FilesystemBrowserXStudio.1/styled_widgets/FSListHeaderColumn.qml b/src/plugin/utility/filesystem_browser/FileSystemBrowser/xstudio/FSListHeaderColumn.qml similarity index 51% rename from src/plugin/python_plugins/filesystem_browser/qml/FilesystemBrowserXStudio.1/styled_widgets/FSListHeaderColumn.qml rename to src/plugin/utility/filesystem_browser/FileSystemBrowser/xstudio/FSListHeaderColumn.qml index 8a0219ada..9facd1d98 100644 --- a/src/plugin/python_plugins/filesystem_browser/qml/FilesystemBrowserXStudio.1/styled_widgets/FSListHeaderColumn.qml +++ b/src/plugin/utility/filesystem_browser/FileSystemBrowser/xstudio/FSListHeaderColumn.qml @@ -22,6 +22,34 @@ Item{ horizontalAlignment: index ? Text.AlignHCenter : Text.AlignLeft elide: Text.ElideRight } + + MouseArea { + id: ma + anchors.fill: parent + anchors.leftMargin: 4 + anchors.rightMargin: 4 + hoverEnabled: true + } + + XsSecondaryButton { + width: height + height: parent.height-6 + anchors.top: parent.top + anchors.right: parent.right + anchors.rightMargin: 5 + anchors.topMargin: 3 + imgSrc: "qrc:/icons/arrow_upward.svg" + rotation: scanResultsModel.sortAscending ? 180 : 0 + visible: ma.containsMouse || hovered || sortRole == sortId + onClicked: { + if (sortRole != sortId) { + sortRole = sortId + } else { + sortAscending = !sortAscending + } + } + } + Rectangle{ visible: index != 0 diff --git a/src/plugin/utility/filesystem_browser/FileSystemBrowser/xstudio/FSListItem.qml b/src/plugin/utility/filesystem_browser/FileSystemBrowser/xstudio/FSListItem.qml new file mode 100644 index 000000000..284022135 --- /dev/null +++ b/src/plugin/utility/filesystem_browser/FileSystemBrowser/xstudio/FSListItem.qml @@ -0,0 +1,158 @@ +// SPDX-License-Identifier: Apache-2.0 +import QtQuick +import QtQuick.Layouts + +import xStudio 1.0 + +import "." +import ".." + +Item { + + id: delegate + height: XsStyleSheet.widgetStdHeight + + property var modelIndex + property bool isSelected: selectedItems.includes(modelIndex) + property bool isHovered: underMouseIndex === modelIndex + + Loader { + id: dataLoader + anchors.fill: parent + sourceComponent: dataVis + } + + Component { + id: dataVis + Item { + + Rectangle { + anchors.fill: parent + color: (isHovered ? XsStyleSheet.panelBgGradTopColor : (modelIndex.row % 2 == 0 ? XsStyleSheet.panelBgColor : Qt.lighter(XsStyleSheet.panelBgColor, 1.1))) + } + + Rectangle { + anchors.fill: parent + color: isSelected ? XsStyleSheet.accentColor : "transparent" + opacity: 0.5 + } + + property var indent: depth * 20 + 44 + (is_folder ? 20 : 0) + + // Cells + component Cell: XsText { + property int index: 0 + x: columnPositions[index] + (index === 0 ? indent : 0) + width: columnWidths[index] + property int elideMode: Text.ElideRight + height: parent.height + verticalAlignment: Text.AlignVCenter + horizontalAlignment: index ? Text.AlignHCenter : Text.AlignLeft + elide: elideMode + color: isSelected ? XsStyleSheet.primaryTextColor : XsStyleSheet.secondaryTextColor + } + + Loader { + x: indent-24 + width: 20 + height: 20 + anchors.verticalCenter: parent.verticalCenter + sourceComponent: is_folder ? undefined : isHovered ? playButton : undefined + Component { + id: playButton + XsIcon { + id: playIcon + source: "qrc:/icons/play_circle.svg" + visible: isHovered + opacity: playHighlighted ? 1.0 : 0.6 + property bool playHighlighted: false + + Connections { + target: mouseArea + enabled: isHovered + function onPositionChanged(mouse) { + var pt = mouseArea.mapToItem(playIcon, mouse.x, mouse.y) + playHighlighted = (pt.x >= 0 && pt.x <= 20 && pt.y >= 0 && pt.y <= 20) + } + function onPressed() { + mouseArea.startDrag(delegate) + if (playHighlighted) { + sendCommand({"action": "preview_file", "path": scanResultsModel.get(modelIndex, "path")}) + } + } + } + } + } + } + + // Expander + Loader { + x: indent - 44 + width: 40 + height: 20 + sourceComponent: is_folder ? folder_icon : undefined + Component { + id: folder_icon + Item { + anchors.fill: parent + property bool expanderHovered: false + XsIcon { + id: expander + x: 2 + y: 2 + width: 16 + height: 16 + source: "qrc:/icons/chevron_right.svg" + rotation: is_expanded ? 90 : 0 + Behavior on rotation {NumberAnimation{duration: 150 }} + } + + Rectangle { + anchors.fill: expander + color: "transparent" + border.color: isHovered && expanderHovered ? XsStyleSheet.accentColor : "transparent" + border.width: 1 + } + + XsIcon { + x: 20 + width: 20 + height: 20 + source: is_expanded ? "qrc:/icons/folder_open.svg" : "qrc:/icons/folder.svg" + } + Connections { + target: mouseArea + enabled: isHovered + function onPositionChanged(mouse) { + var pt = mouseArea.mapToItem(expander, mouse.x, mouse.y) + expanderHovered = (pt.x >= 0 && pt.x <= 16 && pt.y >= 0 && pt.y <= 16) + } + function onPressed() { + mouseArea.startDrag(delegate) + if (isHovered && expanderHovered) { + if (is_expanded) { is_expanded = false } + else { + is_expanded = true + if (!is_scanned) { + sendCommand({"action": "scan_single", "path": scanResultsModel.get(underMouseIndex, "path")}) + } + } + } + } + } + } + } + + } + + Cell { index: 0; text: name_and_count; elideMode: Text.ElideMiddle } + Cell { index: 1; text: version ? "v"+ version : "" } + Cell { index: 2; text: frames } + Cell { index: 3; text: owner } + Cell { index: 4; text: date_string} + Cell { index: 5; text: size_str} + + } + } + +} \ No newline at end of file diff --git a/src/plugin/python_plugins/filesystem_browser/qml/FilesystemBrowserXStudio.1/styled_widgets/FSListView.qml b/src/plugin/utility/filesystem_browser/FileSystemBrowser/xstudio/FSListView.qml similarity index 69% rename from src/plugin/python_plugins/filesystem_browser/qml/FilesystemBrowserXStudio.1/styled_widgets/FSListView.qml rename to src/plugin/utility/filesystem_browser/FileSystemBrowser/xstudio/FSListView.qml index 84137e18c..a28a8b274 100644 --- a/src/plugin/python_plugins/filesystem_browser/qml/FilesystemBrowserXStudio.1/styled_widgets/FSListView.qml +++ b/src/plugin/utility/filesystem_browser/FileSystemBrowser/xstudio/FSListView.qml @@ -10,9 +10,9 @@ import "." XsListView { id: fileListView - visible: root.viewMode !== 3 focus: visible onVisibleChanged: { if (visible) forceActiveFocus() } + property var depth: 0 Keys.onLeftPressed: (event) => { if (currentIndex > 0) currentIndex-- @@ -33,7 +33,6 @@ XsListView { if (md.isFolder) { sendCommand({"action": "change_path", "path": md.path}) } else { - isPreviewMode = false sendCommand({"action": "load_file", "path": md.path}) } } @@ -45,29 +44,42 @@ XsListView { visible: fileListView.height < fileListView.contentHeight } - onCurrentIndexChanged: { + /*onCurrentIndexChanged: { if (activeFocus && currentItem) { if (!currentItem.isItemFolder) { root.pendingPreviewPath = currentItem.itemPath previewTimer.restart() } } - } + }*/ clip: true - model: visibleTreeList + //model: scanResultsModel flickableDirection: Flickable.HorizontalAndVerticalFlick boundsBehavior: Flickable.StopAtBounds - delegate: FSListItem { - width: fileListView.width - isItemFolder: modelData.isFolder - itemPath: modelData.path - property int index: model.index + property var aRootIndex: scanResultsModel.rootIndex + + model: DelegateModel { + id: delegateModel + model: scanResultsModel + rootIndex: aRootIndex + delegate: FSListItem { + modelIndex: scanResultsModel.index(index, 0, aRootIndex) + width: fileListView.width + property var n: name + onNChanged: modelIndex = scanResultsModel.index(index, 0, aRootIndex) + } } - property var yMin: 0 + function getItemAtIndex(index) { + return itemAtIndex(index) + } + + property var yMin: contentY - originY + property var windowBottom: yMin-60 + property var windowTop: yMin + height + 60 // When the user is scrolling we delay update of the windowBottom/windowTop // properties until scrolling has stopped for 200ms, to avoid excessive @@ -84,9 +96,12 @@ XsListView { property var mousePosition: mouse property var mousePositionInFlickArea: Qt.point(mousePosition.x, mousePosition.y + yMin) // adjust for header height onMousePositionChanged: { - if (!visible) return - var index = fileListView.indexAt(mousePositionInFlickArea.x, mousePositionInFlickArea.y) - underMouseIndex = index + if (!visible) return + let newIdx = scanResultsModel.indexAtVisibleRow(mousePositionInFlickArea.y/XsStyleSheet.widgetStdHeight) + if (newIdx != underMouseIndex) { + underMouseIndex = newIdx + } + } } diff --git a/src/plugin/python_plugins/filesystem_browser/qml/FilesystemBrowserXStudio.1/styled_widgets/FSMainHeader.qml b/src/plugin/utility/filesystem_browser/FileSystemBrowser/xstudio/FSMainHeader.qml similarity index 81% rename from src/plugin/python_plugins/filesystem_browser/qml/FilesystemBrowserXStudio.1/styled_widgets/FSMainHeader.qml rename to src/plugin/utility/filesystem_browser/FileSystemBrowser/xstudio/FSMainHeader.qml index acf635518..991846f40 100644 --- a/src/plugin/python_plugins/filesystem_browser/qml/FilesystemBrowserXStudio.1/styled_widgets/FSMainHeader.qml +++ b/src/plugin/utility/filesystem_browser/FileSystemBrowser/xstudio/FSMainHeader.qml @@ -38,14 +38,18 @@ Rectangle { Layout.fillHeight: true isExpanded: false onTextChanged: { - filterField = text - refreshFiltering() - sendCommand({"action": "change_path", "path": current_path_attr.value}); + scanResultsModel.searchString = text } - hint: "Filter String..." + hint: "Filter String ..." + } + + XsPrimaryButton { + Layout.fillHeight: true + visible: searching_attr.value === true + text: "Stop Scan" + onClicked: sendCommand({"action": "stop_scan"}) } - // Progress Bar (Left - fills remaining space) ProgressBar { @@ -83,21 +87,18 @@ Rectangle { } // Preview Indicator - Rectangle { - Layout.preferredWidth: 60 - Layout.preferredHeight: 18 + XsPrimaryButton { + Layout.preferredWidth: 80 + Layout.fillHeight: true Layout.alignment: Qt.AlignVCenter - color: "transparent" - - XsText { - anchors.centerIn: parent - text: "Preview" - color: isPreviewMode ? "#66ff66" : "#444444" - font.pixelSize: 10 - font.bold: isPreviewMode + text: "Previewing" + enabled: previewing + onClicked: { + sendCommand({"action": "stop_preview"}) } + isActive: previewing } - + // Divider (Vertical line) Rectangle { Layout.preferredWidth: 1 @@ -117,6 +118,7 @@ Rectangle { Layout.preferredWidth: 32 Layout.fillHeight: true + visible: index != 2 // hiding 'group' view for now isActive: viewMode === index imgSrc: modelData diff --git a/src/plugin/python_plugins/filesystem_browser/qml/FilesystemBrowserXStudio.1/styled_widgets/FSPathInput.qml b/src/plugin/utility/filesystem_browser/FileSystemBrowser/xstudio/FSPathInput.qml similarity index 98% rename from src/plugin/python_plugins/filesystem_browser/qml/FilesystemBrowserXStudio.1/styled_widgets/FSPathInput.qml rename to src/plugin/utility/filesystem_browser/FileSystemBrowser/xstudio/FSPathInput.qml index 9c6fda8d4..3a6a105d3 100644 --- a/src/plugin/python_plugins/filesystem_browser/qml/FilesystemBrowserXStudio.1/styled_widgets/FSPathInput.qml +++ b/src/plugin/utility/filesystem_browser/FileSystemBrowser/xstudio/FSPathInput.qml @@ -12,6 +12,7 @@ import ".." RowLayout { spacing: 1 + property var completionList: [] XsPrimaryButton { @@ -202,6 +203,7 @@ RowLayout { text = completionList[completionListView.currentIndex]; // Reset selection completionListView.currentIndex = 0; + sendCommand({"action": "complete_path", "path": pathField.text}) } } // ENTER / RETURN @@ -301,7 +303,7 @@ RowLayout { padding: 0 onClosed: { - historyBtn.lastCloseTime = Date.now() + //historyBtn.lastCloseTime = Date.now() } closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside diff --git a/src/plugin/python_plugins/filesystem_browser/qml/FilesystemBrowserXStudio.1/styled_widgets/FSThumbFolderHeader.qml b/src/plugin/utility/filesystem_browser/FileSystemBrowser/xstudio/FSThumbFolderHeader.qml similarity index 96% rename from src/plugin/python_plugins/filesystem_browser/qml/FilesystemBrowserXStudio.1/styled_widgets/FSThumbFolderHeader.qml rename to src/plugin/utility/filesystem_browser/FileSystemBrowser/xstudio/FSThumbFolderHeader.qml index 18e4d4713..415c3d101 100644 --- a/src/plugin/python_plugins/filesystem_browser/qml/FilesystemBrowserXStudio.1/styled_widgets/FSThumbFolderHeader.qml +++ b/src/plugin/utility/filesystem_browser/FileSystemBrowser/xstudio/FSThumbFolderHeader.qml @@ -27,6 +27,5 @@ Rectangle { font.pixelSize: 14 } property bool isHovered: underMouseIndex === index - function mouseMove(x, y) {} } diff --git a/src/plugin/utility/filesystem_browser/FileSystemBrowser/xstudio/FSThumbGroup.qml b/src/plugin/utility/filesystem_browser/FileSystemBrowser/xstudio/FSThumbGroup.qml new file mode 100644 index 000000000..9c656c04d --- /dev/null +++ b/src/plugin/utility/filesystem_browser/FileSystemBrowser/xstudio/FSThumbGroup.qml @@ -0,0 +1,104 @@ +// SPDX-License-Identifier: Apache-2.0 +import QtQuick +import QtQuick.Layouts +import QtQuick.Controls +import xStudio 1.0 + +import ".." +import "." + +Item { + + id: thumbGroup + property var thumbsModel + property var thumbsModelIndex + + //Layout.preferredHeight: num_thumbnail_rows*130 + header.height + 10 + implicitHeight: Math.ceil(file_count/scanResultsModel.numThumbnailCols)*120 + header.height + + property bool strictlyVisible: (y < thumbFlickable.windowTop) && (y+height) > (thumbFlickable.windowBottom) + property bool visibleInFlickable: smallResultSet || ((y < thumbFlickable.windowTopMore) && (y+height) > (thumbFlickable.windowBottomMore)) + + // ── Folder path header (spans full row) ──────────── + Rectangle { + id: header + width: parent.width + height: 40 + color: "#1a1a1a" + property bool isHovered: underMouseIndex === thumbsModelIndex + border.color: isHovered ? XsStyleSheet.accentColor : "transparent" + border.width: 1 + XsIcon { + id: folderIcon + anchors.verticalCenter: parent.verticalCenter + anchors.left: parent.left; anchors.leftMargin: 10 + width: 24; height: 24 + source: "qrc:/icons/folder.svg" + } + XsText { + anchors.verticalCenter: parent.verticalCenter + anchors.left: folderIcon.right; anchors.leftMargin: 10 + text: path + " (" + total_file_count + ")" + font.bold: true + elide: Text.ElideLeft + font.pixelSize: 14 + } + } + + DelegateModel { + id: delegateModel + model: thumbsModel + rootIndex: thumbsModelIndex//visibleInFlickable ? thumbsModelIndex : undefined + delegate: FSThumbItem { + rooty: thumbGroup.y + header.height + width: 160 + height: 120 + } + } + + Connections { + target: mouseArea + enabled: thumbGroup.strictlyVisible + function onPositionChanged(mouse) { + var pt = mouseArea.mapToItem(thumbGroup, mouse.x, mouse.y) + var row = Math.floor((pt.y-40)/120) + var col = Math.floor(pt.x/160) + var index = scanResultsModel.index(row*scanResultsModel.numThumbnailCols + col, 0, thumbsModelIndex) + if (index.valid) { + var item = thumbRepeater.itemAt(index.row) + if (item) { + underMouseIndex = index + return true + } + } + if (pt.y > 0 && pt.y < header.height && pt.x > 0 && pt.x < header.width) { + underMouseIndex = thumbsModelIndex + return true + } + } + function onPressed() { + + if (underMouseIndex == thumbsModelIndex) { + mouseArea.startDrag(header) + } + } + } + + GridLayout { + id: flow + anchors.top: header.bottom + columns: scanResultsModel.numThumbnailCols + columnSpacing: 0 + rowSpacing: 0 + Repeater { + id: thumbRepeater + model: visibleInFlickable ? delegateModel : undefined + } + + } + + function getThumbAtIndex(row) { + return thumbRepeater.itemAt(row) + } + +} // delegate \ No newline at end of file diff --git a/src/plugin/utility/filesystem_browser/FileSystemBrowser/xstudio/FSThumbItem.qml b/src/plugin/utility/filesystem_browser/FileSystemBrowser/xstudio/FSThumbItem.qml new file mode 100644 index 000000000..010bd4e69 --- /dev/null +++ b/src/plugin/utility/filesystem_browser/FileSystemBrowser/xstudio/FSThumbItem.qml @@ -0,0 +1,201 @@ +// SPDX-License-Identifier: Apache-2.0 +import QtQuick +import QtQuick.Layouts +import QtQuick.Controls +import xStudio 1.0 + +import ".." +import "." + +Item { + + id: flatDelegate + + property real rooty: 0 + + // this is true if we're either in or close to being in the flicaable viewport. We only load the actual widget content + // if this is true to aid performance when there are many thumbnails in the list. + property bool visibleInFlickableFuzzy: smallResultSet || ((yInWindow) > (thumbFlickable.windowBottomMore) && (yInWindow) < (thumbFlickable.windowTopMore)) + property var yInWindow: y + rooty + property alias thumbLoader: thumbLoader + Loader { + id: thumbLoader + anchors.fill: parent + sourceComponent: visibleInFlickableFuzzy ? theThumb : undefined + } + + Rectangle { + id: bgrect + anchors.fill: parent; anchors.margins: 5 + color: XsStyleSheet.widgetBgNormalColor + radius: 4 + } + + Component { + id: theThumb + Item { + // ── Thumbnail cell ────────────────────────────────── + property bool isSelected: selectedItems.includes(modelIndex) + property bool isHovered: modelIndex === underMouseIndex + property bool infoHighlighted: false + + // this is true if we're exactly in the flicaable viewport. We only load the thumbnail (which is expensive) if we are visible. + property bool visibleInFlickable: (yInWindow) > (thumbFlickable.windowBottom) && (yInWindow) < (thumbFlickable.windowTop) + + property var modelIndex: scanResultsModel.index(index, 0, thumbsModelIndex) + + Rectangle { + radius: 4 + anchors.fill: parent; anchors.margins: 5 + color: isSelected ? XsStyleSheet.accentColor : isHovered ? XsStyleSheet.hintColor : "transparent" + opacity: 0.5 + border.color: "#777777" + border.width: 1 + } + + ColumnLayout { + + anchors.fill: parent; anchors.margins: 10 + spacing: 4 + //visible: type === "file" + + Item { + + Layout.fillWidth: true; Layout.fillHeight: true + + // This adds a big computation load to the QML scene graph if there + // are many thumbnails + /*BusyIndicator { + anchors.centerIn: parent; width: 30; height: 30 + running: !thumbnailSource && type === "file" + visible: running + }*/ + + Image { + anchors.fill: parent + property var thumbSource: "image://thumbnail/file://" + thumbnailFrame + source: (visibleInFlickable || loaded) ? thumbSource : "" + fillMode: Image.PreserveAspectFit + asynchronous: true + Component.onDestruction: source = "" + property bool loaded: false + onStatusChanged: { + if (status === Image.Ready) { + loaded = true + } + } + } + + } + + Item { + Layout.fillWidth: true; height: 32; clip: true + + XsText { + anchors.top: parent.top + anchors.left: parent.left; anchors.right: parent.right + text: stem; color: "#e0e0e0"; font.pixelSize: 11 + horizontalAlignment: Text.AlignHCenter; elide: Text.ElideMiddle + } + XsText { + anchors.bottom: parent.bottom; anchors.left: parent.left + text: extension; color: "#888888"; font.pixelSize: 10 + visible: parent.extension !== "" + } + XsText { + anchors.bottom: parent.bottom; anchors.right: parent.right + text: frames; color: "#888888"; font.pixelSize: 10 + } + } + } + + Loader { + id: overlays + anchors.fill: parent + sourceComponent: is_folder ? undefined : isHovered ? playButton : undefined + Component { + id: playButton + Item { + + XsIcon { + id: playIcon + anchors.top: parent.top; anchors.left: parent.left + anchors.margins: 14 + width: 24; height: 24 + source: "qrc:/icons/play_circle.svg" + visible: isHovered + opacity: playHighlighted ? 1.0 : 0.6 + property bool playHighlighted: false + + } + + XsIcon { + id: infoIcon + anchors.top: parent.top; anchors.right: parent.right + anchors.margins: 14 + width: 24; height: 24 + source: "qrc:/icons/info.svg" + visible: isHovered + opacity: infoHighlighted ? 1.0 : 0.6 + property bool infoHighlighted: false + onInfoHighlightedChanged: { + if (infoHighlighted) { + var pt = mapToItem(root, 10, 10) + thumbToolTip.x = pt.x + thumbToolTip.y = pt.y + thumbToolTip.text = tooltipText() + thumbToolTip.visible = true + } else { + thumbToolTip.visible = false + } + } + + } + + Connections { + target: mouseArea + enabled: isHovered + function onPositionChanged(mouse) { + var pt = mouseArea.mapToItem(playIcon, mouse.x, mouse.y) + if (pt.x >= 0 && pt.x <= playIcon.width && pt.y >= 0 && pt.y <= playIcon.height) { + playIcon.playHighlighted = true + } else { + playIcon.playHighlighted = false + pt = mouseArea.mapToItem(infoIcon, mouse.x, mouse.y) + if (pt.x >= 0 && pt.x <= infoIcon.width && pt.y >= 0 && pt.y <= infoIcon.height) { + infoIcon.infoHighlighted = true + } else { + infoIcon.infoHighlighted = false + } + } + } + function onPressed() { + + mouseArea.startDrag(flatDelegate) + if (playIcon.playHighlighted) { + sendCommand({"action": "preview_file", "path": scanResultsModel.get(modelIndex, "path")}) + } + } + } + } + } + } + + function tooltipText() { + // parent directory path only + var txt = path + var sl = txt.lastIndexOf("/") + if (sl >= 0) txt = txt.substring(0, sl) + + txt += "\n" + (name || "") + if (frames) txt += "\nFrames: " + frames + if (data && date) txt += "\nModified: " + formatDate(date) + if (data && size_str) txt += "\nSize: " + size_str + return txt + } + + } + } + + +} // delegate \ No newline at end of file diff --git a/src/plugin/utility/filesystem_browser/FileSystemBrowser/xstudio/FSThumbView.qml b/src/plugin/utility/filesystem_browser/FileSystemBrowser/xstudio/FSThumbView.qml new file mode 100644 index 000000000..07731ca9c --- /dev/null +++ b/src/plugin/utility/filesystem_browser/FileSystemBrowser/xstudio/FSThumbView.qml @@ -0,0 +1,95 @@ +// SPDX-License-Identifier: Apache-2.0 +import QtQuick +import QtQuick.Layouts +import QtQuick.Controls +import Qt.labs.qmlmodels +import xStudio 1.0 + +import ".." +import "." + +// Thumbnail view: Flickable + Flow for reliable scrolling with folder headers +Flickable { + + id: thumbFlickable + anchors.fill: parent + visible: root.viewMode === 3 + clip: true + flickableDirection: Flickable.VerticalFlick + focus: visible + onVisibleChanged: { if (visible) forceActiveFocus() } + contentHeight: mainLayout.height + + ScrollBar.vertical: XsScrollBar { + policy: ScrollBar.AlwaysOn + } + + // these properties track the current visible area of the flicable, + // to allow thumb delegates to decide when to load/clear their thumbnail source + // for better thumbnail load performance + property var windowBottom: 0 + property var windowTop: thumbFlickable.height + 160 + property var windowBottomMore: windowBottom-2000 + property var windowTopMore: windowTop+2000 + property var yMin: 0 + property var smallResultSet: scanResultsModel.numMediaFiles < 2000 + + // When the user is scrolling we delay update of the windowBottom/windowTop + // properties until scrolling has stopped for 200ms, to avoid excessive + // thumbnail loading/unloading during scroll + Timer { + id: scrollChangeTimer + interval: 200 + onTriggered: { + yMin = thumbFlickable.contentY - thumbFlickable.originY + windowBottom = yMin - 130 + windowTop = windowBottom + thumbFlickable.height + 130 + } + } + + onOriginYChanged: scrollChangeTimer.restart() + + property var aRootIndex: scanResultsModel.rootIndex + + ColumnLayout { + id: mainLayout + width: parent.width + spacing: 0 + Repeater { + id: thumbRepeater + model: delegateModel + } + } + + function getItemAtIndex(index) { + let p = index.parent + if (p.valid) { + let row = p.row + let group = thumbRepeater.itemAt(row) + if (group) { + return group.getThumbAtIndex(index.row) + } + } + return undefined + } + + DelegateModel { + + id: delegateModel + model: scanResultsModel + + rootIndex: aRootIndex + + delegate: FSThumbGroup { + Layout.fillWidth: true + //Layout.preferredHeight: expectedHeight + thumbsModel: scanResultsModel + thumbsModelIndex: scanResultsModel.index(index, 0, delegateModel.rootIndex) + } + } + + onContentYChanged: { + scrollChangeTimer.restart() + } + +} // Flickable diff --git a/src/plugin/utility/filesystem_browser/FileSystemBrowser/xstudio/FSTreeView.qml b/src/plugin/utility/filesystem_browser/FileSystemBrowser/xstudio/FSTreeView.qml new file mode 100644 index 000000000..10f5b8596 --- /dev/null +++ b/src/plugin/utility/filesystem_browser/FileSystemBrowser/xstudio/FSTreeView.qml @@ -0,0 +1,121 @@ +// SPDX-License-Identifier: Apache-2.0 +import QtQuick +import QtQuick.Layouts +import QtQuick.Controls +import xStudio 1.0 + +import ".." +import "." + +Flickable { + + id: fileListView + focus: visible + onVisibleChanged: { if (visible) forceActiveFocus() } + contentHeight: rootGroup.height + + Keys.onLeftPressed: (event) => { + if (currentIndex > 0) currentIndex-- + event.accepted = true + } + Keys.onRightPressed: (event) => { + if (currentIndex < count - 1) currentIndex++ + event.accepted = true + } + Keys.onReturnPressed: (event) => _handleListReturn(event) + Keys.onEnterPressed: (event) => _handleListReturn(event) + + function _handleListReturn(event) { + if (currentIndex >= 0 && currentIndex < count) { + var md = visibleTreeList[currentIndex] + if (md) { + previewTimer.stop() + if (md.isFolder) { + sendCommand({"action": "change_path", "path": md.path}) + } else { + sendCommand({"action": "load_file", "path": md.path}) + } + } + } + event.accepted = true + } + + ScrollBar.vertical: XsScrollBar { + visible: fileListView.height < fileListView.contentHeight + } + + /*onCurrentIndexChanged: { + if (activeFocus && currentItem) { + if (!currentItem.isItemFolder) { + root.pendingPreviewPath = currentItem.itemPath + previewTimer.restart() + } + } + }*/ + + clip: true + //model: scanResultsModel + + flickableDirection: Flickable.HorizontalAndVerticalFlick + boundsBehavior: Flickable.StopAtBounds + + property var aRootIndex: scanResultsModel.rootIndex + + FSDirectoryGroup { + id: rootGroup + width: fileListView.width + thumbsModelIndex: aRootIndex + thumbsModel: scanResultsModel + property var is_folder: true + property var name: "" + property var frames: "" + property var owner: "" + property var date_string: "" + property var size_str: "" + property var version: "" + property var is_expanded: true + } + + function getItemAtIndex(index) { + let d = 0 + let p = index + while (p.valid) { + p = p.parent + d++ + } + return rootGroup.getItemAtIndex(index, d-1) + } + + property var yMin: contentY - originY + property var windowBottom: yMin-60 + property var windowTop: yMin + height + 60 + + // When the user is scrolling we delay update of the windowBottom/windowTop + // properties until scrolling has stopped for 200ms, to avoid excessive + // thumbnail loading/unloading during scroll + Timer { + id: scrollChangeTimer + interval: 200 + onTriggered: { + yMin = contentY - originY + } + } + onOriginYChanged: scrollChangeTimer.restart() + onContentYChanged: scrollChangeTimer.restart() + property var mousePosition: mouse + property var mousePositionInFlickArea: Qt.point(mousePosition.x, mousePosition.y + yMin) // adjust for header height + onMousePositionChanged: { + if (!visible) return + + + let newIdx = scanResultsModel.indexAtVisibleRow(mousePositionInFlickArea.y/XsStyleSheet.widgetStdHeight) + if (newIdx != underMouseIndex) { + underMouseIndex = newIdx + } + + // rootGroup.mouseMove(Qt.point(mousePositionInFlickArea.x, mousePositionInFlickArea.y-rootGroup.y)) + //var index = fileListView.indexAt(mousePositionInFlickArea.x, mousePositionInFlickArea.y) + //underMouseIndex = index + } + +} diff --git a/src/plugin/utility/filesystem_browser/FileSystemBrowser/xstudio/FileSystemBrowser.qml b/src/plugin/utility/filesystem_browser/FileSystemBrowser/xstudio/FileSystemBrowser.qml new file mode 100644 index 000000000..06e3427ec --- /dev/null +++ b/src/plugin/utility/filesystem_browser/FileSystemBrowser/xstudio/FileSystemBrowser.qml @@ -0,0 +1,836 @@ +import QtQuick 2.15 +import QtQuick.Layouts 1.15 +import QtQuick.Controls 2.15 +import Qt.labs.qmlmodels 1.0 +import QtQuick.Shapes 1.15 // Added for vector icon + +import xStudio 1.0 +import xstudio.qml.models 1.0 +import xstudio.qml.helpers 1.0 +import xstudio.qml.viewport 1.0 + +import filesystembrowser.qml 1.0 + + +import "." +//import "XsText.qml" as Text + +Item { + + id: root + + anchors.fill: parent // Ensure it fills the panel + + property var columnPositions: [0, 200, 280, 360, 460, 580] // Initial positions based on headerModel sizes + property var columnWidths: [200, 80, 80, 100, 120, 80] // Initial widths based on headerModel sizes + + property var selectedItems: [] + property var selectedItemsUrls: [] + property var selectedItemsPaths: [] + property var underMouseIndex: undefined + + property var previewing: previewMediaUuid != helpers.QVariantFromUuidString("") ? previewMediaUuid == currentPlayhead.mediaUuid : false + + FileSystemBrowserScanResultsModel { + id: __scanResultsModel + viewMode: root.viewMode + numThumbnailCols: 4 + sortRole: root.sortRole + sortAscending: root.sortAscending + } + + property alias scanResultsModel: __scanResultsModel + + XsHotkeyArea { + id: hotkey_area + anchors.fill: parent + context: "fsb" + focus: true + } + XsFocusRemover { + target: hotkey_area + } + + Keys.forwardTo: hotkey_area + + function jumpSelection(step) { + + if (selectedItems.length == 0) return + + var newIdx = selectedItems[0] + var rc = scanResultsModel.rowCount(newIdx.parent) + while (step != 0 && newIdx.row < (rc-1) && newIdx.row > 0) { + if (step > 0) { + newIdx = scanResultsModel.index(newIdx.row + 1, 0, newIdx.parent) + step--; + } else { + newIdx = scanResultsModel.index(newIdx.row - 1, 0, newIdx.parent) + step++; + } + } + + if (newIdx.valid && newIdx != selectedItems[0]) { + selectItem(newIdx, 0) + } + + } + + function loadSelectedItems() { + + if (selectedItems.length == 1) { + var idx = selectedItems[0] + if (scanResultsModel.get(idx, "is_folder")) { + sendCommand({"action": "change_path", "path": scanResultsModel.get(idx, "path")}); + return + } + } + + var v = [] + for (var i = 0; i < selectedItems.length; i++) { + var idx = selectedItems[i] + if (!scanResultsModel.get(idx, "is_folder")) { + v.push(scanResultsModel.get(idx, "path")) + } + } + sendCommand({"action": "load_files", "paths": v}) + + } + + XsHotkey { + context: "fsb" + sequence: "Left" + name: "Step To Previous" + description: "When using the File Browser use this hotkey to quickly step backward through the media items found by the scan and start an immediate preview." + onActivated: { + jumpSelection(-1) + } + componentName: "File Browser" + } + + XsHotkey { + context: "fsb" + sequence: "Right" + name: "Step To Next" + description: "When using the File Browser use this hotkey to quickly step forward through the media items found by the scan and start an immediate preview." + onActivated: { + jumpSelection(1) + } + componentName: "File Browser" + } + + XsHotkey { + context: "fsb" + sequence: "Return" + name: "Load all selected media" + description: "Load all selected media (File Browser)" + onActivated: loadSelectedItems() + componentName: "File Browser" + } + + XsHotkey { + context: "fsb" + sequence: "Ender" + name: "SLoad all selected media" + description: "Load all selected media (File Browser)" + onActivated: loadSelectedItems() + componentName: "File Browser" + } + + XsHotkey { + context: "fsb" + sequence: "Down" + name: "Step Down" + description: "When using the File Browser in thumbnail mode use this hotkey to quickly step down through the media items found by the scan and start an immediate preview." + onActivated: { + if (viewMode === 3) { + jumpSelection(scanResultsModel.numThumbnailCols) + } + } + componentName: "File Browser" + } + + XsHotkey { + context: "fsb" + sequence: "Down" + name: "Step Down" + description: "When using the File Browser in thumbnail mode use this hotkey to quickly step down through the media items found by the scan and start an immediate preview." + onActivated: { + if (viewMode === 3) { + jumpSelection(scanResultsModel.numThumbnailCols) + } + } + componentName: "File Browser" + } + + onSelectedItemsChanged: { + var v = [] + var v2 = [] + for (var i = 0; i < selectedItems.length; i++) { + if (scanResultsModel.get(selectedItems[i], "is_folder")) { + for (var j = 0; j < scanResultsModel.rowCount(selectedItems[i]); j++) { + let idx = scanResultsModel.index(j, 0, selectedItems[i]) + if (scanResultsModel.get(idx, "is_folder")) continue; + else { + v.push(helpers.QUrlFromPosixPath(scanResultsModel.get(idx, "path"))) + v2.push(scanResultsModel.get(idx, "path")) + } + } + } else { + v.push(helpers.QUrlFromPosixPath(scanResultsModel.get(selectedItems[i], "path"))) + v2.push(scanResultsModel.get(selectedItems[i], "path")) + } + } + selectedItemsUrls = v + selectedItemsPaths = v2 + } + + function selectItem(index, selectionMode) { + + if (selectionMode === 1) { // Shift + + if (selectedItems.includes(index)) return; + let nearest = -1 + for (let i = 0; i < selectedItems.length; i++) { + if (selectedItems[i].parent == index.parent) { + if (nearest === -1 || Math.abs(selectedItems[i] - index.row) < Math.abs(nearest - index.row)) { + nearest = selectedItems[i].row + } + } + } + if (nearest !== -1) { + let start = Math.min(nearest, index.row) + let end = Math.max(nearest, index.row) + let v = selectedItems + for (let i = start; i <= end; i++) { + let sidx = scanResultsModel.index(i, 0, index.parent) + if (!selectedItems.includes(sidx)) v.push(sidx) + } + selectedItems = v + } + + } else if (selectionMode === 2) { // Ctrl + + if (selectedItems.includes(index)) { + let v = selectedItems.filter(i => i !== index) + selectedItems = v + } else { + let v = selectedItems + v.push(index) + selectedItems = v + } + + } else { + + if (!selectedItems.includes(index)) { + selectedItems = [index] + } + } + + } + + XsGradientRectangle{ + anchors.fill: parent + } + + XsModuleData { + id: pluginData + modelDataName: "Filesystem Browser" + } + + XsModuleData { + id: settings + modelDataName: "Filesystem Browser Settings" + } + + XsModuleData { + id: filterTermAttrs + modelDataName: "Filter Terms" + } + + // State for Preview Mode + property bool haveSubFolders: false + + XsAttributeValue { + id: __deepScan + attributeTitle: "is_deepscan" + model: pluginData + } + property alias deepScan: __deepScan.value + + // Additional Attributes for History/Pins + XsAttributeValue { + id: history_attr + attributeTitle: "history_paths" + model: pluginData + role: "value" + + function updateList() { + var rawVal = value + try { + if (typeof(rawVal) === "string" && rawVal !== "") { + if (rawVal === "[]") { + historyList = [] + } else { + var parsed = JSON.parse(rawVal) + historyList = parsed + } + } else { + historyList = [] + } + } catch(e) { + + historyList = [] + } + } + + onValueChanged: updateList() + Component.onCompleted: updateList() + } + + XsAttributeValue { + id: __preview_media_uuid + attributeTitle: "preview_media_uuid" + model: pluginData + } + property var previewMediaUuid: helpers.QVariantFromUuidString(__preview_media_uuid.value) + + XsAttributeValue { + id: pinned_attr + attributeTitle: "pinned_paths" + model: pluginData + role: "value" + + function updateList() { + var rawVal = value + try { + if (typeof(rawVal) === "string" && rawVal !== "") { + if (rawVal === "[]") { + pinnedList = [] + } else { + var parsed = JSON.parse(rawVal) + pinnedList = parsed + } + } else { + pinnedList = [] + } + } catch(e) { + + pinnedList = [] + } + } + + onValueChanged: updateList() + Component.onCompleted: updateList() + } + + property var historyList: [] + property var pinnedList: [] + property var combinedList: [] + property var filterField: "" + + function updateCombinedList() { + var combined = [] + var seen = new Set() // Set of paths + + // 1. Add Pinned Items + if (pinnedList) { + for (var i = 0; i < pinnedList.length; i++) { + var p = pinnedList[i] + combined.push({ + "name": p.name, + "path": p.path, + "isPinned": true + }) + // Add to seen set (mock Set using object for ES5/QML compat if needed, but modern QML has Set) + // actually JS in QML usually has Set. If not, use object keys. + seen.add(p.path) + } + } + + // 2. Add History Items + if (historyList) { + for (var j = 0; j < historyList.length; j++) { + var h = historyList[j] + if (!seen.has(h)) { + // Determine name (basename) + var name = h + if (h && h.indexOf("/") !== -1) { + var parts = h.split("/") + // Handle trailing slash + var last = parts[parts.length-1] + if (!last && parts.length > 1) last = parts[parts.length-2] + if (last) name = last + } + + combined.push({ + "name": name, + "path": h, + "isPinned": false + }) + seen.add(h) + } + } + } + + combinedList = combined + } + + // Trigger update when source lists change + onHistoryListChanged: updateCombinedList() + onPinnedListChanged: updateCombinedList() + + property bool isCurrentPinned: { + var curr = current_path_attr.value + for(var i=0; i { + + if (underMouseIndex != -1 && mouse.button === Qt.LeftButton && mouse.modifiers == Qt.NoModifier) { + selectedItems = [] + selectItem(underMouseIndex, 0) + + } + + } + + onPressed: (mouse) => { + + if (underMouseIndex != undefined && mouse.button === Qt.LeftButton) { + selectItem(underMouseIndex, mouse.modifiers == Qt.ShiftModifier ? 1 : mouse.modifiers == Qt.ControlModifier ? 2 : 0) + } + + } + + onDoubleClicked: (mouse) => { + if (mouse.button === Qt.LeftButton && underMouseIndex != -1) { + + if (scanResultsModel.get(underMouseIndex, "is_folder")) { + sendCommand({"action": "change_path", "path": scanResultsModel.get(underMouseIndex, "path")}); + } else { + loadSelectedItems() + } + } + } + + onClicked: (mouse) => { + + if (mouse.button === Qt.RightButton && underMouseIndex != -1) { + let clickedItemPath = scanResultsModel.get(underMouseIndex, "path") + thumbContextMenu.itemPath = clickedItemPath + thumbContextMenu.showMenu( + mouseArea, + mouse.x, mouse.y); + } + } + + function startDrag(draggedVisItem) { + dragProxy.dragItem = draggedVisItem + } + + FSFileContextMenu { + id: thumbContextMenu + } + + DragHandler { + id: dragHandler + target: null + + onActiveChanged: { + if (active && underMouseIndex != -1) { + dragProxy.grabToImage(function(result) { + let d = root.selectedItemsPaths.map(p => encodeURIComponent(p)) + dragProxy.Drag.mimeData = {"text/plain": d.join("\n")} + dragProxy.Drag.imageSource = result.url + dragProxy.Drag.active = true + }) + } else { + dragProxy.Drag.active = false + } + } + } + + } + + // Nothing found message + ColumnLayout { + + anchors.centerIn: parent + spacing: 20 + visible: scanResultsModel.numMediaFiles === 0 && !searching_attr.value && !deepScan + XsText { + id: msg + text: "No media found in current folder." + color: "#666666" + font.pixelSize: 18 + } + XsPrimaryButton { + Layout.preferredWidth: 200 + Layout.alignment: Qt.AlignHCenter + visible: scanResultsModel.numRootFolders && !deepScan + text: "Run Full Scan" + onClicked: sendCommand({"action": "force_scan"}) + textDiv.font.pixelSize: 16 + } + } + + } + + // Scanned Dirs Log (Visible during scan) + Rectangle { + + Layout.fillWidth: true + Layout.preferredHeight: searching_attr.value ? 100 : 0 + color: "#1a1a1a" + visible: searching_attr.value === true + clip: true + + ListView { + anchors.fill: parent + model: scannedDirsList + clip: true + delegate: XsText { + text: modelData + color: "#888888" + font.pixelSize: 10 + width: ListView.view.width + elide: Text.ElideMiddle + horizontalAlignment: Text.AlignLeft + } + + // Auto-scroll to bottom + onCountChanged: { + positionViewAtEnd() + } + } + } + + } + } + + Item { + id: dragProxy + property var dragItem: null + property int dragCount: selectedItems.length + + width: dragItem ? Math.min(dragItem.width, 300) : 0 + height: dragItem ? Math.max(dragItem.height, 64) : 0 + visible: false + z: 100 + + Drag.dragType: Drag.Automatic + Drag.supportedActions: Qt.CopyAction + Drag.hotSpot.x: width/2 + Drag.hotSpot.y: height/2 + + ShaderEffectSource { + width: parent.width + height: parent.height + sourceRect.width: width + sourceRect.height: height + sourceItem: parent.dragItem + opacity: 0.65 + } + + // Count badge + Rectangle { + x: parent.width / 2 + 20 + y: parent.height / 2 - 20 + width: dragProxy.dragCount > 1 ? 22 : 0 + height: 22 + radius: 11 + color: "#e05a00" + clip: true + + Text { + anchors.centerIn: parent + text: dragProxy.dragCount + color: "#ffffff" + font.pixelSize: 10; font.weight: Font.Bold + } + } + } + + property alias dragProxy: dragProxy + + property alias thumbToolTip: thumbToolTip + XsToolTip { + + id: thumbToolTip + delay: 500 + maxWidth: 400 + } + +} diff --git a/src/plugin/python_plugins/filesystem_browser/README.md b/src/plugin/utility/filesystem_browser/README.md similarity index 100% rename from src/plugin/python_plugins/filesystem_browser/README.md rename to src/plugin/utility/filesystem_browser/README.md diff --git a/src/plugin/python_plugins/filesystem_browser/__init__.py b/src/plugin/utility/filesystem_browser/filesystem_browser_python/__init__.py similarity index 100% rename from src/plugin/python_plugins/filesystem_browser/__init__.py rename to src/plugin/utility/filesystem_browser/filesystem_browser_python/__init__.py diff --git a/src/plugin/python_plugins/filesystem_browser/config.json b/src/plugin/utility/filesystem_browser/filesystem_browser_python/config.json similarity index 100% rename from src/plugin/python_plugins/filesystem_browser/config.json rename to src/plugin/utility/filesystem_browser/filesystem_browser_python/config.json diff --git a/src/plugin/python_plugins/filesystem_browser/ffmpeg_thumbnails.py b/src/plugin/utility/filesystem_browser/filesystem_browser_python/ffmpeg_thumbnails.py similarity index 100% rename from src/plugin/python_plugins/filesystem_browser/ffmpeg_thumbnails.py rename to src/plugin/utility/filesystem_browser/filesystem_browser_python/ffmpeg_thumbnails.py diff --git a/src/plugin/python_plugins/filesystem_browser/filesystem_browser.py b/src/plugin/utility/filesystem_browser/filesystem_browser_python/filesystem_browser.py similarity index 83% rename from src/plugin/python_plugins/filesystem_browser/filesystem_browser.py rename to src/plugin/utility/filesystem_browser/filesystem_browser_python/filesystem_browser.py index 91c7734ae..bc413d839 100644 --- a/src/plugin/python_plugins/filesystem_browser/filesystem_browser.py +++ b/src/plugin/utility/filesystem_browser/filesystem_browser_python/filesystem_browser.py @@ -2,7 +2,7 @@ # Copyright (c) 2026 Sam Richards from xstudio.plugin import PluginBase -from xstudio.core import JsonStore, FrameList, add_media_atom, Uuid +from xstudio.core import JsonStore, FrameList, add_media_atom, Uuid, event_atom from xstudio.core import KeyboardModifier import os from enum import Enum @@ -14,6 +14,7 @@ import shutil import pathlib import atexit +import traceback class HostApp(Enum): generic = 1 xstudio = 2 @@ -41,6 +42,18 @@ def _dbg(*msg): except Exception: pass +def get_windows_drives(): + """Return a list of available drive letters on Windows.""" + if sys.platform != "win32": + return [] + drives = [] + from ctypes import windll + bitmask = windll.kernel32.GetLogicalDrives() + for letter in range(26): + if bitmask & (1 << letter): + drives.append(f"{chr(65 + letter)}:/") + return drives + # PySide6 dependency removed # from PySide6.QtCore import QObject, Signal, Qt # from PySide6.QtWidgets import QApplication, QFileDialog @@ -68,6 +81,7 @@ def __init__(self, connection, plugin): self.connection = connection self.plugin = plugin self.preview_playlist = None + self.previously_viewed_container = None def _resolve_active_playlist(self): """ @@ -107,7 +121,7 @@ def _format_sequence_path(path): return f"{seq.dirname()}{seq.basename()}{brace_padding}{seq.extension()}={seq.frameRange()}" except Exception: return None - def _add_media_to_playlist(self, playlist, path): + def _add_media_to_playlist(self, playlist, path, before_uuid=Uuid()): """ Adds a file or sequence to the given playlist. Formats the path as a sequence if applicable. @@ -120,7 +134,7 @@ def _add_media_to_playlist(self, playlist, path): """ try: seq_path = self._format_sequence_path(path) - return playlist.add_media(seq_path if seq_path else path) + return playlist.add_media(seq_path if seq_path else path, before_uuid=before_uuid) except Exception as e: import traceback _dbg (traceback.format_exc()) @@ -190,64 +204,72 @@ def load_media(self, path): target_playlist.playhead_selection.set_selection([media.uuid]) target_playlist.playhead.playing = True - def replace_current_media(self, path): + def compare_with_current_media(self, paths): """ - Replaces the currently selected/playing media in the active playlist with the new source. + Adds the specified media to the current selection, and puts the playhead into A/B compare mode. Args: - path (str): The new file path to insert into the playlist in place of the old one. + paths (list): The new media file paths to compare. """ try: playlist = self._resolve_active_playlist() if not playlist: return self.connection.api.session.set_on_screen_source(playlist) - new_media = self._add_media_to_playlist(playlist, path) + new_media = [] + for path in paths: + m = self._add_media_to_playlist(playlist, path) + if m: + new_media.append(m) if not new_media: return - items_to_remove = [] + new_selection = [] if hasattr(playlist, 'playhead_selection'): - current_selection = playlist.playhead_selection.selected_sources - if current_selection: items_to_remove = current_selection - + for m in playlist.playhead_selection.selected_sources: + new_selection.append(m.uuid) + for m in new_media: + new_selection.append(m.uuid) if hasattr(playlist, 'playhead_selection'): - playlist.playhead_selection.set_selection([new_media.uuid]) - - if items_to_remove: - try: playlist.move_media(new_media, before=items_to_remove[0].uuid) - except Exception: pass - - for m in items_to_remove: - try: playlist.remove_media(m) - except Exception: pass - + playlist.playhead_selection.set_selection(new_selection) if hasattr(playlist, 'playhead'): + playlist.playhead.compare_mode = "A/B" playlist.playhead.playing = True + except Exception as e: _dbg(f"Compare error: {e}") - except Exception as e: _dbg(f"Replace error: {e}") - - def compare_with_current_media(self, path): + def replace_current_media(self, paths): """ - Adds the specified media to the current selection, and puts the playhead into A/B compare mode. + Replaces the currently selected/playing media in the active playlist with the new source(s). Args: - path (str): The new media file path to compare. + paths (list): The new media file paths to insert into the playlist in place of the old one. """ try: playlist = self._resolve_active_playlist() if not playlist: return self.connection.api.session.set_on_screen_source(playlist) - new_media = self._add_media_to_playlist(playlist, path) - if not new_media: return - new_selection = [] + new_media = [] + items_to_remove = [] if hasattr(playlist, 'playhead_selection'): - for m in playlist.playhead_selection.selected_sources: - new_selection.append(m.uuid) - new_selection.append(new_media.uuid) + current_selection = playlist.playhead_selection.selected_sources + if current_selection: items_to_remove = current_selection + if hasattr(playlist, 'playhead_selection'): + new_selection = [m.uuid for m in new_media] playlist.playhead_selection.set_selection(new_selection) + + for path in paths: + m = self._add_media_to_playlist(playlist, path, before_uuid=items_to_remove[0].uuid if items_to_remove else Uuid()) + if m: + new_media.append(m) + if not new_media: return + + if items_to_remove: + for m in items_to_remove: + try: playlist.remove_media(m) + except Exception: pass + if hasattr(playlist, 'playhead'): - playlist.playhead.compare_mode = "A/B" playlist.playhead.playing = True - except Exception as e: _dbg(f"Compare error: {e}") + + except Exception as e: _dbg(f"Replace error: {e}") def append_media(self, paths): """ @@ -264,6 +286,20 @@ def append_media(self, paths): self._add_media_to_playlist(playlist, path) except Exception as e: _dbg(f"Append error: {e}") + def add_to_new_playlist(self, paths): + """ + Add a piece of media to a new playlist without altering + the playhead's current playback mode or selection. + + Args: + paths (list): The media file paths to append. + """ + try: + target_playlist = self.connection.api.session.create_playlist("")[1] + for path in paths: + self._add_media_to_playlist(target_playlist, path) + except Exception as e: _dbg(f"Append error: {e}") + def preview_media(self, path): """ Creates or utilizes a hidden 'Preview' playlist to temporarily view an item. @@ -271,25 +307,38 @@ def preview_media(self, path): Args: path (str): The media file path to preview. """ + + if self.connection.api.session.viewed_container != self.preview_playlist: + self.previously_viewed_container = self.connection.api.session.viewed_container + if not self.preview_playlist: - self.preview_playlist = self.connection.api.session.create_hidden_playlist("Preview") + self.preview_playlist = self.connection.api.session.create_hidden_playlist("File Browser Preview") self.preview_playlist.clear() media = self._add_media_to_playlist(self.preview_playlist, path) if not media: return + self.plugin.preview_media_uuid.set_value(str(media.uuid)) + self.connection.api.session.viewed_container = self.preview_playlist self.preview_playlist.playhead_selection.set_selection([media.uuid]) self.preview_playlist.playhead.playing = True + def stop_preview(self): + """ + Stops the preview by clearing the 'Preview' playlist and resetting the viewed container. + """ + if self.connection.api.session.viewed_container.uuid == self.preview_playlist.uuid and self.previously_viewed_container: + self.connection.api.session.viewed_container = self.previously_viewed_container + self.plugin.preview_media_uuid.set_value("") + self.preview_playlist.clear() class FilesystemBrowserPlugin(PluginBase): def __init__(self, connection): PluginBase.__init__( self, connection, - "File System Browser", - qml_folder="qml/FilesystemBrowserXStudio.1" if HOST_APP == HostApp.xstudio else "qml/FilesystemBrowserGeneric.1" + "File Browser" ) # Initialize the host application interface (xStudio concrete implementation) self.host = XStudioHostInterface(self.connection, self) @@ -332,6 +381,17 @@ def __init__(self, connection): ) self.command_attr.expose_in_ui_attrs_group("Filesystem Browser") + self.preview_media_uuid = self.add_attribute( + "preview_media_uuid", + "", + {"title": "preview_media_uuid"}, + register_as_preference=False + ) + self.preview_media_uuid.expose_in_ui_attrs_group("Filesystem Browser") + + # Action to toggle the panel + self.toggle_action_uuid = "2669e4a3-7186-4556-9818-80949437b018" + self.toggle_browser_action = self.register_hotkey( self.toggle_browser, # hotkey_callback "B", # default_keycode @@ -345,9 +405,10 @@ def __init__(self, connection): # Register the panel, passing the action self.register_ui_panel_qml( - "File System Browser", + "File Browser", """ - FilesystemBrowser { + import FileSystemBrowser 1.0 + FileSystemBrowser { anchors.fill: parent } """, @@ -428,6 +489,22 @@ def __init__(self, connection): ) self.view_mode_attr.expose_in_ui_attrs_group("Filesystem Browser") + self.directory_tree_width = self.add_attribute( + "directory_tree_width", + 300, + {}, + register_as_preference=True + ) + self.directory_tree_width.expose_in_ui_attrs_group("Filesystem Browser Settings") + + self.directory_tree_visible = self.add_attribute( + "directory_tree_visible", + True, + {}, + register_as_preference=True + ) + self.directory_tree_visible.expose_in_ui_attrs_group("Filesystem Browser Settings") + # New: Scan Required flag (for manual scan mode) self.scan_required_attr = self.add_attribute( "scan_required", @@ -468,6 +545,22 @@ def __init__(self, connection): ) self.filter_version_attr.expose_in_ui_attrs_group(["Filesystem Browser", "Filter Terms"]) + self.sort_role = self.add_attribute( + "Sort Role", + "name", + {}, + register_as_preference=True + ) + self.sort_role.expose_in_ui_attrs_group(["Filesystem Browser Settings"]) + + self.sort_ascending_attr = self.add_attribute( + "Sort Ascending", + True, + {}, + register_as_preference=True + ) + self.sort_ascending_attr.expose_in_ui_attrs_group(["Filesystem Browser Settings"]) + # History and Pinned Attributes self.history_attr = self.add_attribute( "history_paths", @@ -599,6 +692,11 @@ def __init__(self, connection): # Build the QML command dispatch table self._command_handlers = self._build_command_handlers() + self.results_backend_receiver = None + for plugin_detail in self.connection.api.plugin_manager.plugin_detail: + if plugin_detail.name() == "Scan Results Middleman": + self.results_backend_receiver = connection.api.plugin_manager.get_plugin_instance(plugin_detail.uuid()) + # Initial search self.start_search(self.current_path_attr.value()) @@ -703,6 +801,19 @@ def _cmd_go_to_parent(data): self.current_path_attr.set_value(str(path.parent.absolute())) self.start_search(self.current_path_attr.value()) + def _cmd_stop_scan(data): + self.cancel_search = True + if hasattr(self, 'scanner'): + self.scanner.stop() + if self.search_thread and self.search_thread.is_alive(): + self.search_thread.join() + self.searching_attr.set_value(False) + self.progress_attr.set_value("0") + with self.results_lock: + self.current_scan_results = [] + self.scanned_attr.set_value("0") + self.scanned_dirs_attr.set_value("[]") + def _cmd_force_scan(data): path = data.get("path") if path: @@ -712,24 +823,40 @@ def _cmd_force_scan(data): else: self.start_search(self.current_path_attr.value(), force=True, depth=20) + def _scan_single(data): + path = data.get("path") + if path and os.path.exists(path): + self.start_search(path, force=True, depth=1, refresh=False) + + def _load_paths(data): + paths = data.get("paths", []) + for p in paths: + self.load_file(p) + return { "change_path": _cmd_change_path, "load_file": lambda d: self.load_file(d.get("path")), + "load_files": lambda d: [self.load_file(p) for p in d.get("paths", [])], "preview_file": lambda d: self.host.preview_media(d.get("path")), "request_browser": lambda d: self._open_browser_dialog(self.current_path_attr.value()), "complete_path": lambda d: self.compute_completions(d.get("path", "")), "replace_current_media": lambda d: self.host.replace_current_media(d.get("path")), - "compare_with_current_media": lambda d: self.host.compare_with_current_media(d.get("path")), + "compare_with_current_media": lambda d: self.host.compare_with_current_media(d.get("paths")), "append_media": lambda d: self.host.append_media(d.get("paths")), + "replace_current_media": lambda d: self.host.replace_current_media(d.get("paths")), + "add_to_new_playlist": lambda d: self.host.add_to_new_playlist(d.get("paths")), "set_attribute": _cmd_set_attribute, "copy_path": _cmd_copy_path, "reveal_in_finder": _cmd_reveal_in_finder, "add_pin": lambda d: self._add_pin(d.get("name"), d.get("path")), "remove_pin": lambda d: self._remove_pin(d.get("path")), "force_scan": _cmd_force_scan, + "stop_scan": _cmd_stop_scan, + "stop_preview": lambda d: self.host.stop_preview(), "go_to_parent_folder": _cmd_go_to_parent, "get_subdirs": lambda d: self._get_subdirs(d.get("path")), "request_thumbnail": lambda d: self.thumbnail_generator(d.get("path")), + "scan_single": _scan_single } def attribute_changed(self, attribute, role): @@ -758,8 +885,7 @@ def attribute_changed(self, attribute, role): traceback.print_exc() elif attribute.uuid in (self.filter_time_attr.uuid, self.filter_version_attr.uuid): - if role == AttributeRole.Value: - self._on_filter_changed(attribute, role) + pass elif attribute.uuid == self.depth_limit_attr.uuid: if role == AttributeRole.Value: # Recursion limit changed, re-scan @@ -791,16 +917,14 @@ def _manual_scan_required(self): self.current_scan_results = [] self.scanned_attr.set_value("0") self.scanned_dirs_attr.set_value("[]") - - self.apply_filters() - + if self.search_thread and self.search_thread.is_alive(): self.cancel_search = True if hasattr(self, 'scanner'): self.scanner.stop() self.search_thread.join() - def start_search(self, start_path, force=False, depth=None): + def start_search(self, start_path, force=False, depth=None, refresh=True): """ Start the file search in a separate thread. If force=False and depth <= 4, skip auto-scan and ask user to confirm. @@ -808,6 +932,10 @@ def start_search(self, start_path, force=False, depth=None): if not start_path: return + # Reset message to backend receiver + if refresh: + self.connection.send(self.results_backend_receiver, event_atom(), True) + # Check path depth norm_path = os.path.normpath(start_path) parts = norm_path.strip(os.sep).split(os.sep) @@ -864,20 +992,31 @@ def _search_worker(self, start_path, custom_depth=None): self.scanned_dirs_cache = [] self.scanned_dirs_attr.set_value("[]") self.last_update = 0 + self.last_progress_update = 0 - def progress_callback(results, info): + def progress_callback(results, scanned_dir, index_path_in_tree, info): + + import json + + + if scanned_dir != "": + if self.results_backend_receiver: + self.connection.send(self.results_backend_receiver, event_atom(), scanned_dir, index_path_in_tree==[], json.dumps(results)) + scanned = info.get("scanned", 0) phase = info.get("phase", "") progress = info.get("progress", 0) new_dirs = info.get("scanned_dirs", []) - biased_progress = pow(progress / 100.0, 2.0)*100 - self.progress_attr.set_value(str(biased_progress)) - self.scanned_attr.set_value(str(scanned)) + now_p = time.time() + if now_p - self.last_progress_update >= 0.5: + self.last_progress_update = now_p + biased_progress = pow(progress / 100.0, 2.0)*100 + self.progress_attr.set_value(str(biased_progress)) + self.scanned_attr.set_value(str(scanned)) if new_dirs: self.scanned_dirs_cache.extend(new_dirs) - import json self.scanned_dirs_attr.set_value(json.dumps(self.scanned_dirs_cache)) if results and phase == "scanning": @@ -887,7 +1026,6 @@ def progress_callback(results, info): self.last_update = now with self.results_lock: self.current_scan_results.extend(self.pending_scan_results) - self.apply_filters() self.pending_scan_results = [] if phase == "complete": @@ -901,7 +1039,6 @@ def progress_callback(results, info): with self.results_lock: self.current_scan_results = results - self.apply_filters() _dbg(f"Search finished, found {len(results)} items") @@ -1008,7 +1145,10 @@ def _get_subdirs(self, path): """Fetch subdirectories for the given path and update attribute.""" result = {"path": path, "dirs": []} try: - if os.path.exists(path) and os.path.isdir(path): + if path == "/" and sys.platform == "win32": + drivenames = get_windows_drives() + result["dirs"] = [{"name": d, "path": d, "has_subdir": True} for d in drivenames] + elif os.path.exists(path) and os.path.isdir(path): dirs = [] with os.scandir(path) as it: for entry in it: @@ -1021,9 +1161,20 @@ def _get_subdirs(self, path): continue if entry.is_dir(): + # Does this hit performance of the tree? + has_subdir = False + try: + with os.scandir(entry.path) as sub_it: + for sub_entry in sub_it: + if sub_entry.is_dir(): + has_subdir = True + break + except Exception: + pass dirs.append({ "name": entry.name, - "path": entry.path + "path": entry.path, + "has_subdir": has_subdir }) # Sort alphabetically dirs.sort(key=lambda x: x["name"].lower()) @@ -1047,108 +1198,6 @@ def load_file(self, path): return self.host.load_media(path) - def apply_filters(self): - """Re-run filtering logic on the current results cache.""" - try: - with self.results_lock: - results = list(self.current_scan_results) - - # Offload heavy filtering if list is huge? - # For now, do it in main thread or worker? - # Safe to do in main thread if count < 100k? - # Better to spawn a thread if we want UI responsiveness. - - # Doing it synchronously for now, but catching errors - self._apply_filters_logic(results) - except Exception as e: - _dbg(f"Error applying filters: {e}") - - def _apply_filters_logic(self, results): - import os - # Use cached values if available (from worker), else fetch live (UI update) - if hasattr(self, 'cached_filter_time'): - filter_time = self.cached_filter_time - else: - filter_time = self.filter_time_attr.value() if hasattr(self, 'filter_time_attr') else "Any" - - if hasattr(self, 'cached_filter_version'): - filter_version = self.cached_filter_version - else: - filter_version = self.filter_version_attr.value() if hasattr(self, 'filter_version_attr') else "All Versions" - - _dbg(f"Applying filters: Time={filter_time}, Version={filter_version}, Count={len(results)}") - - # Separate directories and files - dirs = [] - files = [] - for r in results: - if r.get("is_folder") or r.get("type") == "Folder": - dirs.append(r) - else: - files.append(r) - - # 1. Apply Time Filter (to files only?) - # User wants to see directories "even if there isnt data in them". - # So we probably shouldn't filter directories by time unless requested. - # Let's Apply Time Filter ONLY to files for now. - if filter_time != "Any": - now = time.time() - cutoff = 0 - if filter_time == "Last 1 day": - cutoff = now - 86400 - elif filter_time == "Last 2 days": - cutoff = now - 2 * 86400 - elif filter_time == "Last 1 week": - cutoff = now - 7 * 86400 - elif filter_time == "Last 1 month": - cutoff = now - 30 * 86400 - - if cutoff > 0: - files = [r for r in files if r.get("date", 0) >= cutoff] - - # 2. Apply Version Filter with Grouping (Files only) - grouped_results = {} - for r in files: - grp = r.get("version_group") - if grp: - grouped_results.setdefault(grp, []).append(r) - else: - grouped_results.setdefault(id(r), [r]) - - filtered_files = [] - - for grp, items in grouped_results.items(): - if len(items) <= 1: - filtered_files.extend(items) - continue - - items.sort(key=lambda x: x.get("version", 0), reverse=True) - - if filter_version == "Latest Version": - filtered_files.extend(items[:1]) - elif filter_version == "Latest 2 Versions": - filtered_files.extend(items[:2]) - else: - filtered_files.extend(items) - - - # Combine: Keep all discovered directories to facilitate browsing, - # and combine with filtered files. - final_results = dirs + filtered_files - - # Resort by name for display - final_results.sort(key=lambda x: x["name"]) - - # Serialize - # json_str = json.dumps(final_results) - self.files_attr.set_value(json.dumps(final_results)) - - def _on_filter_changed(self, attribute, role): - from xstudio.core import AttributeRole - if role == AttributeRole.Value: - # Re-apply filters on cached results - threading.Thread(target=self.apply_filters).start() - def _add_to_history(self, path): try: current_history = json.loads(self.history_attr.value()) diff --git a/src/plugin/python_plugins/filesystem_browser/scanner.py b/src/plugin/utility/filesystem_browser/filesystem_browser_python/scanner.py similarity index 81% rename from src/plugin/python_plugins/filesystem_browser/scanner.py rename to src/plugin/utility/filesystem_browser/filesystem_browser_python/scanner.py index 9f232beab..f94c308f2 100644 --- a/src/plugin/python_plugins/filesystem_browser/scanner.py +++ b/src/plugin/utility/filesystem_browser/filesystem_browser_python/scanner.py @@ -6,6 +6,8 @@ import threading import queue import time +import pathlib +from datetime import datetime try: import pwd except ImportError: @@ -56,15 +58,15 @@ def format_size_str(self, size_bytes): def scan(self, start_path, callback=None): """ Scans from start_path using BFS and weighted progress. - callback(results, progress_info) is called periodically. + callback(results, scanned_dir, progress_info) is called periodically. """ self.cancel_event.clear() from collections import deque from concurrent.futures import wait, FIRST_COMPLETED - # Queue of (path, weight, depth) - queue = deque([(start_path, 1.0, 0)]) + # Queue of (path, weight, depth, index_path_in_tree) + queue = deque([(start_path, 1.0, 0, [])]) # Futures set futures = set() @@ -83,20 +85,21 @@ def scan(self, start_path, callback=None): # Helper to schedule def schedule_next(): while queue and len(futures) < self.max_workers: - path, weight, depth = queue.popleft() + path, weight, depth, index_path_in_tree = queue.popleft() # Submit task - futures.add(self.executor.submit(self._scan_and_process_worker, path, start_path, weight, depth)) + futures.add(self.executor.submit(self._scan_and_process_worker, path, start_path, weight, depth, index_path_in_tree)) schedule_next() - + while (futures or queue) and not self.cancel_event.is_set(): + # Wait for some work to complete done, _ = wait(futures, timeout=0.05, return_when=FIRST_COMPLETED) for f in done: futures.remove(f) try: - subdirs, items, weight, depth, scanned_path = f.result() + subdirs, items, weight, depth, scanned_path, index_path_in_tree = f.result() # Accumulate results if items: @@ -105,19 +108,20 @@ def schedule_next(): recent_scanned_dirs.append(scanned_path) - if callback and items: + if callback: # Send partial results # Note: We send empty list for items here if we want to batch them? # Original code sent items immediately. - callback(items, {"scanned": scanned_count, "progress": total_progress * 100, "phase": "scanning", "scanned_dirs": []}) + callback(items, scanned_path, index_path_in_tree, {"scanned": scanned_count, "progress": total_progress * 100, "phase": "scanning", "scanned_dirs": []}) # Distribute weight or complete it # print ("Scanned: {}, Depth: {}, Subdirs: {} = {}".format(scanned_path, depth, subdirs, self.max_depth)) if subdirs and depth < self.max_depth: if len(subdirs) > 0: + subdirs = sorted(subdirs) # Sort for consistent order child_weight = weight / len(subdirs) - for d in subdirs: - queue.append((d, child_weight, depth + 1)) + for i, d in enumerate(subdirs): + queue.append((d, child_weight, depth + 1, index_path_in_tree + [i])) else: # Leaf node (in terms of dirs or recursion limit), this weight is done total_progress += weight @@ -131,7 +135,7 @@ def schedule_next(): # Periodic Progress update if time.time() - last_update > 0.2: if callback: - callback([], { + callback([], "", [], { "scanned": scanned_count, "progress": min(100, int(total_progress * 100)), "phase": "scanning", @@ -147,11 +151,11 @@ def schedule_next(): # Final update if callback: - callback([], {"scanned": scanned_count, "progress": 100, "phase": "complete", "scanned_dirs": list(recent_scanned_dirs)}) + callback([], "", [], {"scanned": scanned_count, "progress": 100, "phase": "complete", "scanned_dirs": list(recent_scanned_dirs)}) return all_items - def _scan_and_process_worker(self, path, root_path, weight, depth): + def _scan_and_process_worker(self, path, root_path, weight, depth, index_path_in_tree): """ Scans a directory, processes files therein, returns (subdirs, items, weight, depth, path). """ @@ -175,7 +179,7 @@ def _scan_and_process_worker(self, path, root_path, weight, depth): raw_files.append((entry.path, entry.name, entry.stat(), True)) # True for is_dir except OSError: pass - elif entry.is_file(): + elif entry.is_file() and not entry.name.startswith('.'): ext = os.path.splitext(entry.name)[1].lower() if ext in self.extensions: try: @@ -186,8 +190,8 @@ def _scan_and_process_worker(self, path, root_path, weight, depth): pass # Process files immediately - items = self._process_files(raw_files, root_path) - return subdirs, items, weight, depth, path + items = self._process_files(raw_files, root_path, depth) + return subdirs, items, weight, depth, path, index_path_in_tree def _resolve_sequence_frame(self, seq): """Given a fileseq, return (concrete_file_path, frame_number). @@ -203,7 +207,32 @@ def _resolve_sequence_frame(self, seq): pass return path - def _process_files(self, raw_files, start_path): + @staticmethod + def _format_sequence_path(seq): + """ + Converts a fileseq path string into the specific URI formatting xStudio demands + for loading image sequences (e.g. `/dir/prefix{:04d}.ext=1001-1050`). + + Args: + path (str): The raw file path or fileseq string. + + Returns: + str | None: The formatted sequence path, or None if fileseq is unavailable or it's a single file. + """ + if len(seq) <= 1: return None + pad_str = seq.padding() + if pad_str and pad_str.startswith("%"): + import re + m = re.search(r"%(0(\d+))?d", pad_str) + pad_len = int(m.group(2)) if m and m.group(2) else 0 + elif pad_str: + pad_len = pad_str.count('#') * 4 + pad_str.count('@') + else: + pad_len = 0 + brace_padding = f"{{:0{pad_len}d}}" if pad_len > 0 else "{:d}" + return f"{seq.dirname()}{seq.basename()}{brace_padding}{seq.extension()}={seq.frameRange()}" + + def _process_files(self, raw_files, start_path, depth): """ raw_files: list of (full_path, basename, stat_obj, is_dir) """ @@ -215,13 +244,13 @@ def _process_files(self, raw_files, start_path): # Split into sequence candidates and singles for p, name, st, is_dir in raw_files: if is_dir: - final_items.append(self._make_item(p, name, st, start_path, is_directory=True)) + final_items.append(self._make_item(p, name, st, start_path, is_directory=True, depth=depth)) continue ext = os.path.splitext(name)[1].lower() if ext in self.non_sequence_extensions: # Treat strictly as single file - final_items.append(self._make_item(p, name, st, start_path)) + final_items.append(self._make_item(p, name, st, start_path, depth=depth)) else: sequence_candidate_paths.append(p) @@ -241,7 +270,7 @@ def _process_files(self, raw_files, start_path): for p in sequence_candidate_paths: info = path_map.get(p) if info: - final_items.append(self._make_item(p, info[0], info[1], start_path)) + final_items.append(self._make_item(p, info[0], info[1], start_path, depth=depth)) for seq in sequences: # Check if we should explode this sequence (if it's actually versioned files matching config) @@ -265,7 +294,7 @@ def _process_files(self, raw_files, start_path): str_p = str(seq[0]) info = path_map.get(str_p) if info: - final_items.append(self._make_item(str_p, info[0], info[1], start_path)) + final_items.append(self._make_item(str_p, info[0], info[1], start_path, depth=depth)) continue # It's a sequence @@ -311,9 +340,21 @@ def _process_files(self, raw_files, start_path): # fileseq.FileSequence string conversion gives the sequence string (path-#.ext). # We want that as 'path'? # xstudio expects 'path' to be loadable. + + match = self.version_regex.search(name) + if match: + span = match.span() + prefix = name[:span[0]] + suffix = name[span[1]:] + version_stream_key = prefix + suffix + else: + version_stream_key = name + + + item = { "name": name, - "path": str(seq), # Sequence string path + "path": self._format_sequence_path(seq), # Sequence string path "relpath": os.path.relpath(first_path, start_path), # Relative path of ONE file? Or sequence? # relpath is used for tree building. # If we use first_path, detailed logic might split it. @@ -329,10 +370,13 @@ def _process_files(self, raw_files, start_path): "size": total_size, "size_str": self.format_size_str(total_size), "date": max_mtime, + "date_string": datetime.fromtimestamp(max_mtime).strftime("%Y-%m-%d %H:%M:%S"), "owner": owner, "extension": seq.extension(), "is_sequence": True, - "is_folder": False + "is_folder": False, + "version_stream_key": version_stream_key, + "depth": depth } # Fix relpath to be based on the abstract sequence path if possible? # actually `str(seq)` gives the sequence path. @@ -343,21 +387,37 @@ def _process_files(self, raw_files, start_path): return self._group_versions(final_items) - def _make_item(self, path, name, st, start_path, is_directory=False): + def _make_item(self, path, name, st, start_path, is_directory=False, depth=0): + p = pathlib.Path(path) + + match = self.version_regex.search(name) + if match: + span = match.span() + prefix = name[:span[0]] + suffix = name[span[1]:] + version_stream_key = prefix + suffix + else: + version_stream_key = name + return { "name": name, "path": path, + "stem": p.stem, + "ext": p.suffix, "relpath": os.path.relpath(path, start_path), "type": "Folder" if is_directory else "File", "frames": "" if is_directory else "1", "size": 0 if is_directory else st.st_size, "size_str": "" if is_directory else self.format_size_str(st.st_size), "date": st.st_mtime, + "date_string": datetime.fromtimestamp(st.st_mtime).strftime("%Y-%m-%d %H:%M:%S"), "owner": self.get_owner(st.st_uid), "extension": "" if is_directory else os.path.splitext(name)[1], "is_sequence": False, "is_folder": is_directory, - "thumbnailFrame": path if not is_directory else None + "thumbnailFrame": path if not is_directory else None, + "depth": depth, + "version_stream_key": version_stream_key } def _group_versions(self, items): diff --git a/src/plugin/python_plugins/filesystem_browser/scanner_benchmark.py b/src/plugin/utility/filesystem_browser/filesystem_browser_python/scanner_benchmark.py similarity index 100% rename from src/plugin/python_plugins/filesystem_browser/scanner_benchmark.py rename to src/plugin/utility/filesystem_browser/filesystem_browser_python/scanner_benchmark.py diff --git a/src/plugin/python_plugins/filesystem_browser/test_scanner.py b/src/plugin/utility/filesystem_browser/filesystem_browser_python/test_scanner.py similarity index 100% rename from src/plugin/python_plugins/filesystem_browser/test_scanner.py rename to src/plugin/utility/filesystem_browser/filesystem_browser_python/test_scanner.py diff --git a/src/plugin/utility/filesystem_browser/scan_results_datamodel_ui.cpp b/src/plugin/utility/filesystem_browser/scan_results_datamodel_ui.cpp new file mode 100644 index 000000000..a64d90b8e --- /dev/null +++ b/src/plugin/utility/filesystem_browser/scan_results_datamodel_ui.cpp @@ -0,0 +1,962 @@ +// SPDX-License-Identifier: Apache-2.0 +#include +#include + +#include "xstudio/broadcast/broadcast_actor.hpp" +#include "xstudio/plugin_manager/plugin_factory.hpp" +#include "scan_results_datamodel_ui.hpp" +#include "xstudio/ui/qml/helper_ui.hpp" + +CAF_PUSH_WARNINGS +#include +CAF_POP_WARNINGS + +using namespace xstudio; +using namespace xstudio::file_system_browser; +using namespace xstudio::ui::qml; +using namespace xstudio::utility; + +class ScanResultsMiddleman : public caf::event_based_actor { + public: + + inline static const utility::Uuid PLUGIN_UUID = utility::Uuid("dad9cfc6-6275-408b-8c96-eee5e1f3a47d"); + + ScanResultsMiddleman(caf::actor_config &cfg, const utility::JsonStore &) : caf::event_based_actor(cfg) { + + system().registry().put("ScanResultsMiddleman", caf::actor_cast(this)); + + } + + ~ScanResultsMiddleman() override = default; + + void on_exit() override { + clients_.clear(); + system().registry().erase("ScanResultsMiddleman"); + caf::event_based_actor::on_exit(); + } + + caf::behavior make_behavior() override { + return { + [=](broadcast::join_broadcast_atom atom, caf::actor actor) { + + monitor(actor, [this, addr = actor.address()](const error &err) { + auto p = clients_.begin(); + while (p != clients_.end()) { + if (addr == (*p).address()) { + p = clients_.erase(p); + } else { + p++; + } + } + }); + clients_.push_back(actor); + bool root = true; + for(auto &entry : last_scan_results_) { + mail(utility::event_atom_v, entry.first, root, entry.second).send(actor); + root = false; + } + + }, + [=](utility::event_atom, const bool new_scan) { + for (auto &client : clients_) { + mail(utility::event_atom_v, new_scan).send(client); + } + last_scan_results_.clear(); + }, + [=](utility::event_atom, const std::string &directory, const bool is_root, const std::string &__scan_dir_results) { + last_scan_results_[directory] = __scan_dir_results; + for (auto &client : clients_) { + mail(utility::event_atom_v, directory, is_root, __scan_dir_results).send(client); + } + }, + [=](caf::message &m) { + spdlog::warn( + "{} : unrecognised message received. Message content: {}", + __PRETTY_FUNCTION__, + to_string(m)); + }}; + } + + std::vector clients_; + std::map last_scan_results_; +}; + +namespace xstudio::file_system_browser { + // QAbstractItemModel requires us to back the model with our own + // internal data. In this example we implement a simple tree structure + // (with no columns) that maps exactly to the structure of the QAbstractItemModel + struct ScanResultsNode { + + ScanResultsNode(ScanResultsNode *p, const quintptr id); + ~ScanResultsNode(); + + [[nodiscard]] int row() const { return row_; } + [[nodiscard]] int num_rows() const { return static_cast(visible_children.size()); } + [[nodiscard]] bool visible() const { return data.at(ScanResultsModel::IS_VISIBLE).toBool(); } + [[nodiscard]] int column() const { return 0; } + [[nodiscard]] int total_visible_rows() const { return total_visible_rows_; } + [[nodiscard]] int total_file_count() const { return total_file_count_; } + [[nodiscard]] bool is_expanded() const { return data.at(ScanResultsModel::IS_EXPANDED).toBool(); } + [[nodiscard]] bool is_folder() const { return data.at(ScanResultsModel::IS_FOLDER).toBool(); } + [[nodiscard]] bool is_scanned() const { return data.at(ScanResultsModel::IS_SCANNED).toBool(); } + [[nodiscard]] bool is_deep_scanned() const { return deep_scanned_; } + [[nodiscard]] ScanResultsNode * entry_at_visible_row(int local_row) const; + [[nodiscard]] ScanResultsNode * nth_visible_child(int n) const; + + bool filter_and_sort(const QString &filter_string, const double not_older_than, const int current_filter_version_rank, const int sort_role, const bool sort_ascending); + bool sort(const int sort_role, const bool ascending, const bool recurse = true); + void set_expanded(bool expanded); + void add_rows(std::vector> &nc); + void add_row(std::shared_ptr nc); + void update_total_visible_rows(const bool auto_update_parent = true); + void set_data(const nlohmann::json &data); + void set_row(int row) { row_ = row; } + + ScanResultsNode *parent = {nullptr}; + quintptr node_id = 0; + // The actual data (model values) at this node in the tree + std::map data = { + {ScanResultsModel::NAME, QString()}, + {ScanResultsModel::NAME_AND_COUNT, QString()}, + {ScanResultsModel::IS_EXPANDED, false}, + {ScanResultsModel::IS_VISIBLE, true}, + {ScanResultsModel::IS_FOLDER, false}, + {ScanResultsModel::IS_SCANNED, false}}; + + std::vector> children; + std::vector> visible_children; + std::vector child_local_row_starts; + + std::string path; + int total_visible_rows_ = 1; + int row_ = 0; + int total_file_count_ = 0; + int visible_file_count_ = 0; + bool deep_scanned_ = false; + + }; +} + +ScanResultsNode::ScanResultsNode(ScanResultsNode *p, const quintptr id) : parent(p), node_id(id) { + if (!p) { + // this is the root entry - always expanded + data[ScanResultsModel::IS_EXPANDED] = true; + } +} + +ScanResultsNode::~ScanResultsNode() { +} + +bool ScanResultsNode::sort(const int sort_role, const bool ascending, const bool recurse) { + if (children.empty()) return false; + bool changed = false; + + auto sort_func1 = [](const std::shared_ptr &a, const std::shared_ptr &b, const int sort_role) -> int { + + const auto &a_p = a->data.find(sort_role); + const auto &b_p = b->data.find(sort_role); + if (a_p != a->data.end() && b_p == b->data.end()) return -1; + else if (a_p == a->data.end() && b_p != b->data.end()) { + return 1; + } + else if (a_p == a->data.end() && b_p == b->data.end()) return 0; + else { + const auto r = QVariant::compare(a_p->second, b_p->second); + return r == QPartialOrdering::Less ? -1 : (r == QPartialOrdering::Greater ? 1 : 0); + } + return 0; + + }; + + std::sort(children.begin(), children.end(), [sort_role, ascending, &changed, sort_func1](const std::shared_ptr &a, const std::shared_ptr &b) { + + auto r = sort_func1(a, b, ScanResultsModel::IS_FOLDER); + if (r == 0) r = sort_func1(a, b, sort_role); + if (r == 0) r = sort_func1(a, b, ScanResultsModel::NAME); + return ascending ? r < 0 : r > 0; + + }); + if (recurse) { + for (auto &child : children) { + changed |= child->sort(sort_role, ascending); + } + } + visible_children.clear(); + return changed; +} + +bool ScanResultsNode::filter_and_sort( + const QString &filter_string, + const double not_older_than, + const int current_filter_version_rank, + const int sort_role, + const bool sort_ascending + ) { + + auto p = data.find(ScanResultsModel::DATE); + bool v = true; + if (p != data.end() && p->second.canConvert()) { + v = p->second.toDouble() >= not_older_than; + } + + if (v && !filter_string.isEmpty()) { + p = data.find(ScanResultsModel::NAME); + if (p != data.end() && p->second.canConvert()) { + v = p->second.toString().contains(filter_string, Qt::CaseInsensitive); + } + } + + std::set hidden_version_children; + if (current_filter_version_rank >= 0) { + // first, sort by the version key. That way adjacent versions will be next to each other, and we can filter out all but the latest version etc. + sort(ScanResultsModel::VERSION_STREAM_KEY, false, false); + QString last_version_stream_key; + int ct = current_filter_version_rank; + int ii = -1; + for (auto &child : children) { + ii++; + if (child->is_folder()) continue; + auto p = child->data.find(ScanResultsModel::VERSION_STREAM_KEY); + if (p != child->data.end() && p->second.canConvert()) { + const auto vrank = p->second.toString(); + if (vrank == last_version_stream_key ) { + if (ct) ct--; + else { + child->data[ScanResultsModel::IS_VISIBLE] = false; + hidden_version_children.insert(ii); + } + } else { + last_version_stream_key = vrank; + ct = current_filter_version_rank; + } + } + } + } + + int ii = 0; + for (const auto &child : children) { + if (hidden_version_children.find(ii) == hidden_version_children.end()) { + child->filter_and_sort(filter_string, not_older_than, current_filter_version_rank, sort_role, sort_ascending); + } + ii++; + } + + // now sort by the requested sort role and order + sort(sort_role, sort_ascending, false); + + visible_children.clear(); + int row = 0; + total_file_count_ = 0; + visible_file_count_ = 0; + deep_scanned_ = is_scanned(); + for (const auto &child : children) { + if (child->visible()) { + visible_children.push_back(child); + child->set_row(row++); + if (!child->is_folder()) { + visible_file_count_++; + } + } else { + child->set_row(-1); + } + if (!child->is_folder()) { + total_file_count_++; + } else { + total_file_count_ += child->total_file_count(); + deep_scanned_ &= child->is_deep_scanned(); + } + + } + v |= !visible_children.empty(); + + if (is_folder() && is_scanned() && visible_children.empty()) { + // hide folders that have been scanned but have no visible children + data[ScanResultsModel::IS_EMPTY] = true; + } + + data[ScanResultsModel::IS_VISIBLE] = v; + data[ScanResultsModel::TOTAL_FILE_COUNT] = total_file_count_; + data[ScanResultsModel::FILE_COUNT] = visible_file_count_; + if (is_folder()) { + if (total_file_count_) + data[ScanResultsModel::NAME_AND_COUNT] = QString("%1 (%2)").arg(data[ScanResultsModel::NAME].toString()).arg(total_file_count_); + else if (!deep_scanned_) + //data[ScanResultsModel::NAME_AND_COUNT] = QString("%1 (not scanned)").arg(data[ScanResultsModel::NAME].toString()); + data[ScanResultsModel::NAME_AND_COUNT] = data[ScanResultsModel::NAME]; + else + data[ScanResultsModel::NAME_AND_COUNT] = QString("%1 (no media)").arg(data[ScanResultsModel::NAME].toString()); + + } else { + data[ScanResultsModel::NAME_AND_COUNT] = data[ScanResultsModel::NAME]; + } + update_total_visible_rows(false); + return v; +} + +void ScanResultsNode::add_rows(std::vector> &nc) { + children.insert(children.end(), nc.begin(), nc.end()); + visible_children.clear(); + int row = 0; + for (const auto &child : children) { + + if (child->visible()) { + visible_children.push_back(child); + child->set_row(row++); + } else { + child->set_row(-1); + } + } + update_total_visible_rows(); +} + +void ScanResultsNode::add_row(std::shared_ptr nc) { + children.push_back(nc); + if (nc->visible()) { + nc->set_row(static_cast(visible_children.size())); + visible_children.push_back(nc); + } else { + nc->set_row(-1); + } + update_total_visible_rows(); +} + +void ScanResultsNode::set_expanded(bool expanded) { + if (is_expanded() != expanded) { + data[ScanResultsModel::IS_EXPANDED] = expanded; + update_total_visible_rows(); + } +} + +void ScanResultsNode::update_total_visible_rows(const bool auto_update_parent) { + const int bf = total_visible_rows_; + if (!visible()) { + total_visible_rows_ = 0; + } else { + total_visible_rows_ = parent ? 1 : 0; + if (is_expanded()) { + child_local_row_starts.clear(); + for (const auto &child : visible_children) { + child_local_row_starts.push_back(total_visible_rows_); + total_visible_rows_ += child->total_visible_rows(); + } + } + } + if (total_visible_rows_ != bf && parent && auto_update_parent) { + parent->update_total_visible_rows(); + } +} + +ScanResultsNode * ScanResultsNode::entry_at_visible_row(int local_row) const { + if (parent && local_row == 0) return const_cast(this); + std::vector::const_iterator it = std::upper_bound(child_local_row_starts.begin(), child_local_row_starts.end(), local_row); + if (it == child_local_row_starts.begin()) return nullptr; + auto p = visible_children.begin() + std::distance(child_local_row_starts.begin(), it) - 1; + return (*p)->entry_at_visible_row(local_row - child_local_row_starts[std::distance(visible_children.begin(), p)]); +} + +ScanResultsNode * ScanResultsNode::nth_visible_child(int n) const { + if (n < 0 || n >= num_rows()) return nullptr; + return visible_children[n].get(); +} + +void ScanResultsNode::set_data(const nlohmann::json &d) { + + for (const auto &p : ScanResultsModel::data_model_roles) { + const auto &role_name = p.second; + if (d.contains(role_name)) { + data[p.first] = ui::qml::json_to_qvariant(d[role_name]); + } + } + data[ScanResultsModel::NAME_AND_COUNT] = data[ScanResultsModel::NAME]; +} + +ScanResultsModel::ScanResultsModel(QObject *parent) : super(parent) { + + root_entry_.reset(createNode(nullptr)); + + for (const auto &p : data_model_roles) { + data_model_role_name_to_id_[p.second] = p.first; + } + init(CafSystemObject::get_actor_system()); +} + +void ScanResultsModel::init(caf::actor_system &_system) { + + super::init(_system); + connectToPythonPlugin(); + + // We can defined custom message handlers here so that messages can be + // received from other components (actors) in xSTUDIO. In this case we will + // be interfacing with the plugin backend + set_message_handler([=](caf::actor_companion * /*self*/) -> caf::message_handler { + return { + [=](utility::event_atom, utility::time_point when_to_sort) { + + if (sort_filter_time_point_ == when_to_sort) { + doFiltering(); + } + + }, + [=](utility::event_atom, bool reset) { + // the message from the python plugin tells us to clear the whole model + // ahead of a re-scan. + if (reset) { + beginResetModel(); + old_root_ = root_entry_; + root_entry_.reset(createNode(nullptr)); + path_entries_.clear(); + results_per_directory_.clear(); + endResetModel(); + root_index_ = index(0, 0); + emit rootIndexChanged(); + } + }, + [=](broadcast::broadcast_down_atom, const caf::actor_addr &) {}, + + [=](utility::event_atom, const std::string &directory, const bool is_root, const std::string &__scan_dir_results) { + + auto scan_dir_results = std::make_shared(); + scan_dir_results->parse_string(__scan_dir_results); + // make a seperate store + results_per_directory_[directory] = scan_dir_results; + addDirectoryToModel(directory, scan_dir_results, is_root, false); + + schedule_sort_and_filter(); + + }, + + [=](caf::message &m) { + spdlog::warn( + "{} : unrecognised message received. Message content: {}", + __PRETTY_FUNCTION__, + to_string(m)); + }}; + }); +} + +QHash ScanResultsModel::roleNames() const { + + QHash roles; + for (const auto &p : data_model_roles) { + roles[p.first] = p.second.c_str(); + } + + return roles; +} + +void ScanResultsModel::schedule_sort_and_filter() { + + // set a time point in the future to do the sort. If more data comes in + // this gets reset so we're not running sort/filter while the scan data + // is still incoming. + auto when_to_sort = utility::clock::now(); + sort_filter_time_point_ = when_to_sort; + anon_mail(utility::event_atom_v, when_to_sort).delay(std::chrono::milliseconds(400)).send( + as_actor()); + +} + + +int ScanResultsModel::rowCount(const QModelIndex &parent) const { + + const ScanResultsNode *e = get_entry(parent); + if (e && e->num_rows() != -1) { + return e->num_rows(); + } + + // we don't know the row count yet! + return 1; + +} + +int ScanResultsModel::columnCount(const QModelIndex &parent) const { return 1; } + +bool ScanResultsModel::hasChildren(const QModelIndex &parent) const { return rowCount(parent) != 0; } + +ScanResultsNode *ScanResultsModel::createNode(ScanResultsNode *parent) { + + if (!parent) { + // creating a new root node. + root_node_id_ = next_node_id_; + node_map_.clear(); + } + auto n = new ScanResultsNode(parent, next_node_id_); + node_map_[next_node_id_] = n; + next_node_id_++; + return n; +} + +const ScanResultsNode * +ScanResultsModel::get_entry(int row, int column, const ScanResultsNode *parent_entry) const { + + const ScanResultsNode *result = nullptr; + if (parent_entry) { + if (parent_entry->num_rows() > row) { + result = parent_entry->nth_visible_child(row); + } else { + } + } else { + result = root_entry_.get(); + } + return result; +} + + +QModelIndex ScanResultsModel::index(int row, int column, const QModelIndex &parent) const { + + if (row < 0) { + return createIndex(row, column); + } + const ScanResultsNode *parent_entry = get_entry(parent); + const ScanResultsNode *entry = get_entry(row, column, parent_entry); + return createIndex(row, column, entry ? entry->node_id : 0); +} + + +QModelIndex ScanResultsModel::parent(const QModelIndex &child) const { + + ScanResultsNode *e = get_entry(const_cast(child)); + if (e && e->parent) { + return createIndex(e->parent->row(), e->parent->column(), e->parent->node_id); + } + return {}; +} + +void ScanResultsModel::set(const QModelIndex &index, const QVariant &value, const QString &role_name) +{ + auto p = data_model_role_name_to_id_.find(role_name.toStdString()); + if (p != data_model_role_name_to_id_.end()) { + setData(index, value, p->second); + } +} + +QVariant ScanResultsModel::get(const QModelIndex &index, const QString &role_name) const { + + auto p = data_model_role_name_to_id_.find(role_name.toStdString()); + if (p != data_model_role_name_to_id_.end()) { + return data(index, p->second); + } + return QVariant(); +} + +bool ScanResultsModel::setData(const QModelIndex &index, const QVariant &value, int role) { + + ScanResultsNode *e = get_entry(index); + + if (e) { + const auto p = e->data.find(role); + if (p == e->data.end() || p->second != value) { + e->data[role] = value; + emit dataChanged(index, index, {role}); + if (role == IS_EXPANDED) { + // if the expanded state has changed, we need to update the row count for this entry and all parents. + e->update_total_visible_rows(); + } + return true; + } + } + return false; +} + +QVariant ScanResultsModel::data(const QModelIndex &index, int role) const { + + ScanResultsNode *e = get_entry(index); + + if (e) { + const auto p = e->data.find(role); + if (p != e->data.end()) { + return p->second; + } + } + // no data ... YET! + return QVariant(QString()); + +} + +bool ScanResultsModel::canFetchMore(const QModelIndex &parent) const { + auto result = false; + ScanResultsNode *e = get_entry(parent); + if (!e || e->num_rows() == -1) + result = true; + return result; +} + +void ScanResultsModel::addDirectoryToModel( + const std::string &directory, + std::shared_ptr &scan_dir_results, + const bool is_root, + const bool full_rebuild) { + + if (view_mode_ == 0) { + + // view mode 0 is a flat list of all scanned files. No folders are shown. + if (scan_dir_results->is_array()) { + + // auto t0 = utility::clock::now(); + // total number of rows - if we're mode 0 (flat list of files) then + // we skip directories. + int count = 0; + for (auto &j: *scan_dir_results) { + // skip directories in view mode 0 + if ((view_mode_ == 0 && j.value("is_folder", true))) continue; + count++; + + } + //auto t1 = utility::clock::now(); + + if (!count) { + // no results we want to show, so return early + return; + } + + + int rc = root_entry_->num_rows(); + std::vector> new_entries(count); + int i = 0; + for (auto &j: *scan_dir_results) { + if (j.value("is_folder", true)) continue; + new_entries[i].reset(createNode(root_entry_.get())); + new_entries[i]->set_data(j); + i++; + } + root_entry_->add_rows(new_entries); + + int new_rc = root_entry_->num_rows(); + if (new_rc != rc && !full_rebuild) { + QModelIndex parent_index = index(0, 0); + beginInsertRows(parent_index, rc, new_rc-1); + insertRows(rc, (new_rc-rc), parent_index); + endInsertRows(); + } + + } + + } else if (view_mode_ == 1) { + + auto parent_entry = root_entry_; + auto p = path_entries_.find(directory); + if (p != path_entries_.end()) { + parent_entry = p->second; + } + + int count = scan_dir_results->size(); + if (!count) { + parent_entry->data[IS_SCANNED] = true; + return; + } + + utility::JsonStore j; + j["is_folder"] = true; + j["is_expanded"] = is_root; + j["is_scanned"] = true; + parent_entry->set_data(j); + + int rc = parent_entry->num_rows(); + std::vector> new_entries(scan_dir_results->size()); + int i = 0; + for (auto &j: *scan_dir_results) { + new_entries[i].reset(createNode(parent_entry.get())); + new_entries[i]->set_data(j); + if (j.value("is_folder", true) == true) { + path_entries_[j["path"].get()] = new_entries[i]; + } + i++; + } + parent_entry->add_rows(new_entries); + parent_entry->filter_and_sort(search_string_, timestamp_cutoff(), current_filter_version_rank_, sort_role_, sort_ascending_); + + int new_rc = parent_entry->num_rows(); + if (new_rc > rc && !full_rebuild) { + QModelIndex parent_index = createIndex(parent_entry->row(), 0, parent_entry->node_id); + beginInsertRows(parent_index, rc, new_rc-1); + insertRows(rc, (new_rc-rc), parent_index); + endInsertRows(); + } + + } else if (view_mode_ == 3 && scan_dir_results->size()) { + + int count = 0; + for (auto &j: *scan_dir_results) { + if (j.value("is_folder", true)) continue; + count++; + } + if (!count) { + return; + } + + // add a row to root entry for the directory. + utility::JsonStore j; + j["name"] = directory; + j["is_folder"] = true; + j["path"] = directory; + std::shared_ptr directory_entry(createNode(root_entry_.get())); + directory_entry->set_data(j); + int rc0 = root_entry_->num_rows(); + root_entry_->add_row(directory_entry); + + std::vector> new_entries; + for (auto &j: *scan_dir_results) { + if (j.value("is_folder", true)) continue; + new_entries.emplace_back(createNode(directory_entry.get())); + new_entries.back()->set_data(j); + } + directory_entry->add_rows(new_entries); + + int new_rc = root_entry_->num_rows(); + if (new_rc != rc0 && !full_rebuild) { + QModelIndex parent_index = index(0, 0); + beginInsertRows(parent_index, rc0, new_rc-1); + insertRows(rc0, (new_rc-rc0), parent_index); + endInsertRows(); + } + + } +} + +ScanResultsNode * ScanResultsModel::get_entry(const QModelIndex &index) const { + if (!index.isValid()) return nullptr; + if (index.internalId() < root_node_id_) { + return nullptr; + } + const auto p = node_map_.find(index.internalId()); + if (p == node_map_.end()) { + return nullptr; + } + return p->second; +} + +void ScanResultsModel::setEntryData(QModelIndex &index, const std::string &path, const nlohmann::json &data) { + + ScanResultsNode *e = get_entry(index); + + if (e) { + e->path = data.value("path", ""); + for (const auto &p : data_model_roles) { + const auto &role_name = p.second; + if (data.contains(role_name)) { + e->data[p.first] = ui::qml::json_to_qvariant(data[role_name]); + } + } + /*e->data = data; + if (view_mode_ == 0) { + e->data["depth"] = 0; + }*/ + } +} + +QModelIndex ScanResultsModel::indexAtVisibleRow(int row) const { + + QModelIndex result; + if (root_entry_) { + auto entry = root_entry_->entry_at_visible_row(row); + if (entry) { + result = createIndex(entry->row(), 0, entry->node_id); + } + } + return result; +} + +void ScanResultsModel::doFiltering() { + + if (view_mode_ == 0) { + // in view mode 0 we don't have a tree structure, so we can just filter the root entry. + const int nr = root_entry_->num_rows(); + root_entry_->filter_and_sort(search_string_, timestamp_cutoff(), current_filter_version_rank_, sort_role_, sort_ascending_); + + // assess change in number of rows. + const int new_nr = root_entry_->num_rows(); + + if (new_nr > nr) { + beginInsertRows(index(0, 0), nr, new_nr-1); + insertRows(nr, new_nr-nr, index(0, 0)); + endInsertRows(); + } else if (new_nr < nr) { + beginRemoveRows(index(0, 0), new_nr, nr-1); + removeRows(new_nr, nr-new_nr, index(0, 0)); + endRemoveRows(); + } + + // brute force update all rows - too hard to work out which rows have changed, so just update all of them. + emit dataChanged(index(0, 0, index(0, 0)), index(new_nr-1, 0, index(0, 0)), {NAME, NAME_AND_COUNT, DATE, SIZE_STR, OWNER, PATH, VERSION}); + + } else { + // in view mode 1 we have a tree structure, so we need to filter all entries. + emit layoutAboutToBeChanged(); + beginResetModel(); + root_entry_->filter_and_sort(search_string_, timestamp_cutoff(), current_filter_version_rank_, sort_role_, sort_ascending_); + if (root_entry_->num_rows() > 0) { + beginInsertRows(index(0, 0), 0, root_entry_->num_rows()-1); + insertRows(0, root_entry_->num_rows(), index(0, 0)); + endInsertRows(); + } + endResetModel(); + emit layoutChanged(); + root_index_ = index(0, 0); + emit rootIndexChanged(); + } + + int num_media_files = root_entry_->total_file_count(); + if (num_media_files != num_media_files_) { + num_media_files_ = num_media_files; + emit numMediaFilesChanged(); + } + + int num_root_folders = 0; + if (!results_per_directory_.empty()) { + // scan results for the root folder are always the first entry in the map + // because the map is keyed by directory path and the root folder is always + // the first entry in lexicographical order. + for (const auto & j: *(results_per_directory_.begin()->second)) { + if (j.value("is_folder", true)) num_root_folders++; + } + } + if (num_root_folders != num_root_folders_) { + num_root_folders_ = num_root_folders; + emit numRootFoldersChanged(); + } + +} + +void ScanResultsModel::setSearchString(const QString &s) { + if (s != search_string_) { + search_string_ = s; + emit searchStringChanged(); + doFiltering(); + } +} + +void ScanResultsModel::setCurrentFilterTime(const QString &s) { + if (s != current_filter_time_) { + current_filter_time_ = s; + static std::map filter_time_map = { + {"Any", 0}, + {"Last 1 day", 60*60*24}, + {"Last 2 days", 60*60*24*2}, + {"Last 1 week", 60*60*24*7}, + {"Last 1 month", 60*60*24*30} + }; + auto p = filter_time_map.find(s); + if (p != filter_time_map.end()) { + current_filter_time_seconds_ = p->second; + } else { + current_filter_time_seconds_ = 0; + } + emit currentFilterTimeChanged(); + doFiltering(); + } +} + +void ScanResultsModel::setCurrentFilterVersion(const QString &s) { + if (s != current_filter_version_) { + current_filter_version_ = s; + static std::map filter_version_map = { + {"All Versions", -1}, + {"Latest Version", 0}, + {"Latest 2 Versions", 1} + }; + auto p = filter_version_map.find(s); + if (p != filter_version_map.end()) { + current_filter_version_rank_ = p->second; + } else { + current_filter_version_rank_ = -1; + } + emit currentFilterVersionChanged(); + doFiltering(); + } +} + +void ScanResultsModel::fullRebuild() +{ + beginResetModel(); + emit layoutAboutToBeChanged(); + + old_root_ = root_entry_; + root_entry_.reset(createNode(nullptr)); + + root_index_ = index(0, 0); + emit rootIndexChanged(); + path_entries_.clear(); + bool root = true; + for (auto &entry : results_per_directory_) { + addDirectoryToModel(entry.first, entry.second, root, true); + root = false; + } + emit layoutChanged(); + doFiltering(); + endResetModel(); + root_index_ = index(0, 0); + emit rootIndexChanged(); + +} + +double ScanResultsModel::timestamp_cutoff() const { + if (current_filter_time_seconds_ == 0) return 0; + const auto tp = std::chrono::system_clock::now().time_since_epoch(); + auto time_since_epoch = static_cast(std::chrono::duration_cast(tp).count()); + return time_since_epoch - current_filter_time_seconds_; +} + +void ScanResultsModel::connectToPythonPlugin() { + + // get to the plugin manager actor through the actor registry. + auto foodar = system().registry().template get("ScanResultsMiddleman"); + anon_mail(broadcast::join_broadcast_atom_v, as_actor()).send(foodar); + + /*try { + + // get the python interpreter actor from the global actor + scoped_actor sys{system()}; + auto python_interp = utility::request_receive( + *sys, + global_actor, + global::get_python_atom_v); + if (!python_interp) { + spdlog::critical("{} Failed to get python interpreter actor", __PRETTY_FUNCTION__); + return; + } + + // now we add our actor address to an args dictionary. xSTUDIO python interpreter + // will convert this back to an actor object (python wrapped object) and then + // call the method register_scan_results_ui_model on the File Browser python plugin, passing + // the actor as an argument. This allows the python plugin to send messages to this UI model actor (for example to send scan results back to the UI). + utility::JsonStore args; + args["results_model_actor"] = utility::actor_to_string(system(), as_actor()); + + auto r = utility::request_receive( + *sys, + python_interp, + embedded_python::python_exec_atom_v, + "File Browser", // plugin name + "register_scan_results_ui_model", // method on plugin + args // arguments for method + ); + std::cerr << "Got response from python plugin: " << r.dump(4) << std::endl; + } catch (const std::exception &err) { + spdlog::critical("{} {}", __PRETTY_FUNCTION__, err.what()); + }*/ + +} + +ScanResultsFilterModel::ScanResultsFilterModel(QObject *parent) : QSortFilterProxyModel(parent) { + +} + +bool ScanResultsFilterModel::filterAcceptsRow(int source_row, const QModelIndex &source_parent) const { + if (!sourceModel()) return false; + auto idx = sourceModel()->index(source_row, 0, source_parent); + return sourceModel()->data(idx, ScanResultsModel::IS_VISIBLE).toBool(); +} + +XSTUDIO_PLUGIN_DECLARE_BEGIN() + +XSTUDIO_REGISTER_PLUGIN( + ScanResultsMiddleman, + ScanResultsMiddleman::PLUGIN_UUID, + Scan Results Middleman, + plugin_manager::PluginFlags::PF_UTILITY, + true, + Ted Waine Sam Richardson, + Plugin actor to receive scan results from Python plugin and forward to UI model, + 1.0.0) + +XSTUDIO_PLUGIN_DECLARE_END() \ No newline at end of file diff --git a/src/plugin/utility/filesystem_browser/scan_results_datamodel_ui.hpp b/src/plugin/utility/filesystem_browser/scan_results_datamodel_ui.hpp new file mode 100644 index 000000000..03ff54f57 --- /dev/null +++ b/src/plugin/utility/filesystem_browser/scan_results_datamodel_ui.hpp @@ -0,0 +1,361 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include +#include + +CAF_PUSH_WARNINGS +#include +#include +#include +#include +#include +#include +CAF_POP_WARNINGS + +#include "xstudio/ui/qml/helper_ui.hpp" +#include "xstudio/ui/qml/json_tree_model_ui.hpp" + +namespace xstudio::file_system_browser { + +class ScanResultsNode; + +/* +ScanResultsModel class. + +This class is a QtAbstractItemModel that manages the results of the file system +scan performed by the python component. Using a QAbstractItemModel +gives us the benefit of many under-the-hood Qt optimisations when displaying +data in a QML view. +*/ +class ScanResultsModel : public caf::mixin::actor_object { + + Q_OBJECT + + Q_PROPERTY(int viewMode READ viewMode WRITE setViewMode NOTIFY viewModeChanged) + Q_PROPERTY(int numThumbnailCols READ numThumbnailCols WRITE setNumThumbnailCols NOTIFY numThumbnailColsChanged) + Q_PROPERTY( + QModelIndex rootIndex READ rootIndex NOTIFY rootIndexChanged) + Q_PROPERTY(QString searchString READ searchString WRITE setSearchString NOTIFY searchStringChanged) + Q_PROPERTY(bool sortAscending READ sortAscending WRITE setSortAscending NOTIFY sortAscendingChanged) + Q_PROPERTY(QString currentFilterTime READ currentFilterTime WRITE setCurrentFilterTime NOTIFY currentFilterTimeChanged) + Q_PROPERTY(QString currentFilterVersion READ currentFilterVersion WRITE setCurrentFilterVersion NOTIFY currentFilterVersionChanged) + Q_PROPERTY(QString sortRole READ sortRole WRITE setSortRole NOTIFY sortRoleChanged) + Q_PROPERTY(int numMediaFiles READ numMediaFiles NOTIFY numMediaFilesChanged) + Q_PROPERTY(int numRootFolders READ numRootFolders NOTIFY numRootFoldersChanged) + + QML_NAMED_ELEMENT(ScanResultsModel) + + public: + + enum LogRoles { + DATE = Qt::UserRole + 1, + DATE_STRING, + STEM, + DEPTH, + EXTENSION, + FRAMES, + FILE_COUNT, + TOTAL_FILE_COUNT, + IS_FOLDER, + IS_LATEST_VERSION, + IS_SEQUENCE, + IS_EXPANDED, + IS_SCANNED, + IS_VISIBLE, + IS_EMPTY, + NAME, + NAME_AND_COUNT, + OWNER, + PATH, + RELPATH, + SIZE, + SIZE_STR, + THUMBNAIL_FRAME, + TYPE, + VERSION, + VERSION_GROUP, + VERSION_RANK, + VERSION_STREAM_KEY + }; + + // Provide a mapping from DATA_MODEL_ROLE to strings. This is needed to define + // the names of the 'roleData' elements that our QML Model will expose + inline static const std::map data_model_roles = { + {DATE, "date"}, + {DATE_STRING, "date_string"}, + {STEM, "stem"}, + {EXTENSION, "extension"}, + {FRAMES, "frames"}, + {IS_FOLDER, "is_folder"}, + {IS_LATEST_VERSION, "is_latest_version"}, + {IS_SEQUENCE, "is_sequence"}, + {IS_EXPANDED, "is_expanded"}, + {IS_SCANNED, "is_scanned"}, + {IS_VISIBLE, "is_visible"}, + {IS_EMPTY, "is_empty"}, + {NAME, "name"}, + {NAME_AND_COUNT, "name_and_count"}, + {OWNER, "owner"}, + {PATH, "path"}, + {RELPATH, "relpath"}, + {TOTAL_FILE_COUNT, "total_file_count"}, + {FILE_COUNT, "file_count"}, + {SIZE, "size"}, + {SIZE_STR, "size_str"}, + {THUMBNAIL_FRAME, "thumbnailFrame"}, + {TYPE, "type"}, + {VERSION, "version"}, + {VERSION_GROUP, "version_group"}, + {VERSION_RANK, "version_rank"}, + {VERSION_STREAM_KEY, "version_stream_key"}}; + + using super = caf::mixin::actor_object; + + ScanResultsModel(QObject *parent = nullptr); + ~ScanResultsModel() override = default; + + void init(caf::actor_system &system); + + [[nodiscard]] caf::actor_system &system() const { + return const_cast(self())->home_system(); + } + + /* These pure virtual methods from QAbstractItemModel need to be overriden*/ + [[nodiscard]] int rowCount(const QModelIndex &parent = QModelIndex()) const override; + [[nodiscard]] int columnCount(const QModelIndex &parent = QModelIndex()) const override; + [[nodiscard]] bool hasChildren(const QModelIndex &parent = QModelIndex()) const override; + [[nodiscard]] QModelIndex + index(int row, int column, const QModelIndex &parent = QModelIndex()) const override; + [[nodiscard]] QModelIndex parent(const QModelIndex &child) const override; + [[nodiscard]] QVariant + data(const QModelIndex &index, int role = Qt::DisplayRole) const override; + [[nodiscard]] QHash roleNames() const override; + [[nodiscard]] bool canFetchMore(const QModelIndex &parent) const override; + + // When roleData is set in QML, this method is invoked + [[nodiscard]] bool + setData(const QModelIndex &index, const QVariant &value, int role = Qt::EditRole) override; + + int viewMode() const { return view_mode_; } + void setViewMode(int mode) { + if (mode != view_mode_) { + view_mode_ = mode; + fullRebuild(); + emit viewModeChanged(); + } + } + + QString searchString() const { return search_string_; } + void setSearchString(const QString &s); + + QString sortRole() const { + auto p = data_model_roles.find(sort_role_); + if (p != data_model_roles.end()) { + return ui::qml::QStringFromStd(p->second); + } + return QString(); + } + + void setSortRole(const QString &s) { + auto p = std::find_if( + data_model_roles.begin(), + data_model_roles.end(), + [&](const auto &p) { return p.second == s.toStdString(); }); + if (p != data_model_roles.end()) { + if (p->first != sort_role_) { + sort_role_ = p->first; + doFiltering(); + emit sortRoleChanged(); + } + } + } + + bool sortAscending() const { return sort_ascending_; } + void setSortAscending(bool ascending) { + if (ascending != sort_ascending_) { + sort_ascending_ = ascending; + fullRebuild(); + emit sortAscendingChanged(); + } + } + + QString currentFilterTime() const { return current_filter_time_; } + void setCurrentFilterTime(const QString &s); + + QString currentFilterVersion() const { return current_filter_version_; } + void setCurrentFilterVersion(const QString &s); + + [[nodiscard]] const QModelIndex &rootIndex() const { return root_index_; } + + int numThumbnailCols() const { return numCols_; } + void setNumThumbnailCols(int num) { + if (num != numCols_) { + numCols_ = num; + emit numThumbnailColsChanged(); + } + } + + int numMediaFiles() const { return num_media_files_; } + int numRootFolders() const { return num_root_folders_; } + + Q_INVOKABLE void set(const QModelIndex &index, const QVariant &value, const QString &role_name); + Q_INVOKABLE QVariant get(const QModelIndex &index, const QString &role_name) const; + Q_INVOKABLE QModelIndex indexAtVisibleRow(int row) const; + + signals: + + void viewModeChanged(); + void searchStringChanged(); + void numThumbnailColsChanged(); + void rootIndexChanged(); + void sortAscendingChanged(); + void currentFilterTimeChanged(); + void currentFilterVersionChanged(); + void sortRoleChanged(); + void numMediaFilesChanged(); + void numRootFoldersChanged(); + + private: + + void connectToPythonPlugin(); + void doFiltering(); + void fullRebuild(); + void addDirectoryToModel( + const std::string &directory, + std::shared_ptr &scan_dir_results, + const bool is_root, + const bool full_rebuild = false + ); + void setEntryData(QModelIndex &index, const std::string &path, const nlohmann::json &data); + bool filter_check(nlohmann::json &data, const bool setting_filtered_status = false); + double timestamp_cutoff() const; + void schedule_sort_and_filter(); + + ScanResultsNode * + createNode(ScanResultsNode *parent); + + const ScanResultsNode * + get_entry(int row, int column, const ScanResultsNode *parent_entry) const; + + ScanResultsNode * get_entry(const QModelIndex &index) const; + + std::shared_ptr root_entry_; + std::shared_ptr old_root_; + + std::map data_model_role_id_to_name_; + std::map data_model_role_name_to_id_; + std::map> results_per_directory_; + std::map> path_entries_; + std::map node_map_; + quintptr next_node_id_ = 1; + quintptr root_node_id_ = 1; + int view_mode_ = 3; + int numCols_ = 4; + QString search_string_; + std::regex filter_regex_; + QModelIndex root_index_; + bool sort_ascending_ = true; + QString current_filter_time_ = "Any"; + double current_filter_time_seconds_ = 0; + QString current_filter_version_ = "All Versions"; + int current_filter_version_rank_ = -1; + utility::time_point sort_filter_time_point_; + int sort_role_ = ScanResultsModel::NAME; + int num_media_files_ = 0; + int num_root_folders_ = 0; +}; + +class ScanResultsFilterModel : public QSortFilterProxyModel { + + Q_OBJECT + + Q_PROPERTY( + QModelIndex rootIndex READ rootIndex NOTIFY rootIndexChanged) + + public: + + using super = QSortFilterProxyModel; + + ScanResultsFilterModel(QObject *parent = nullptr); + + [[nodiscard]] const QModelIndex &rootIndex() const { return root_index_; } + + [[nodiscard]] QHash roleNames() const override { + if (!sourceModel()) + return QHash(); + return sourceModel()->roleNames(); + } + + void setSourceModel(QAbstractItemModel *sourceModel) override { + super::setSourceModel(sourceModel); + source_model_ = dynamic_cast(sourceModel); + if (source_model_) { + /*connect(source_model_, &ScanResultsModel::rootIndexChanged, this, &ScanResultsFilterModel::sourceModelReset); + root_index_ = mapFromSource(source_model_->index(0, 0)); + emit rootIndexChanged();*/ + } + } + + Q_INVOKABLE void set(const QModelIndex &index, const QVariant &value, const QString &role_name) { + if (source_model_) { + source_model_->set(mapToSource(index), value, role_name); + } + } + Q_INVOKABLE QVariant get(const QModelIndex &index, const QString &role_name) const { + if (source_model_) { + return source_model_->get(mapToSource(index), role_name); + } + return QVariant(); + } + Q_INVOKABLE QModelIndex indexAtVisibleRow(int row) const { + if (source_model_) { + auto source_index = source_model_->indexAtVisibleRow(row); + if (source_index.isValid()) { + return mapFromSource(source_index); + } + } + return QModelIndex(); + } + + public slots: + + void sourceModelReset() { + /*if (source_model_) { + root_index_ = mapFromSource(source_model_->index(0, 0)); + emit rootIndexChanged(); + sort(0, Qt::DescendingOrder); + }*/ + } + + protected: + [[nodiscard]] bool + filterAcceptsRow(int source_row, const QModelIndex &source_parent) const override; + + signals: + void searchStringChanged(); + void rootIndexChanged(); + private: + QString search_string_; + ScanResultsModel *source_model_ = nullptr; + QModelIndex root_index_; +}; + +// We require this boiler-plate to register our custom class as a QML +// item: + +//![plugin] +class DemoPluginQML : public QQmlExtensionPlugin { + Q_OBJECT + + Q_PLUGIN_METADATA(IID "xstudio-project.demoplugin.ui") + void registerTypes(const char *uri) override { + qmlRegisterType( + "filesystembrowser.qml", 1, 0, "FileSystemBrowserScanResultsModel"); + qmlRegisterType( + "filesystembrowser.qml", 1, 0, "FileSystemBrowserScanResultsFilterModel"); + } +}; +//![plugin] + +} // namespace xstudio::demo_plugin diff --git a/src/plugin/video_output/video_renderer/src/video_render_worker.cpp b/src/plugin/video_output/video_renderer/src/video_render_worker.cpp index 902164118..4ba21b360 100644 --- a/src/plugin/video_output/video_renderer/src/video_render_worker.cpp +++ b/src/plugin/video_output/video_renderer/src/video_render_worker.cpp @@ -638,7 +638,7 @@ void VideoRenderWorker::continue_render_loop() { anon_mail(playhead::step_atom_v).send(caf::actor_cast(this)); int cp = int(round( - timebase::to_seconds(playhead_position_- start_position_) * 100.0 / + timebase::to_seconds(playhead_position_ - start_position_) * 100.0 / timebase::to_seconds(end_position_ - start_position_))); if (cp != percent_complete_) { percent_complete_ = cp; @@ -1032,12 +1032,11 @@ void VideoRenderWorker::render_step() { // num_audio_samples_delivered_ which has to keep up with // playhead_position_ according to soundcard sample rate audio_stream_position_ += frame_rate_; - auto microsecs = - std::chrono::duration_cast( - audio_stream_position_) - .count() - - (num_audio_samples_delivered_ * 1000000) / - int64_t(soundcard_sample_rate_); + auto microsecs = std::chrono::duration_cast( + audio_stream_position_) + .count() - + (num_audio_samples_delivered_ * 1000000) / + int64_t(soundcard_sample_rate_); const int64_t num_samples = microsecs * int64_t(soundcard_sample_rate_) / 1000000; @@ -1084,19 +1083,17 @@ void VideoRenderWorker::render_step() { // advance the playhead position to the next frame waiting_for_buffers_ = false; - playhead_position_ = new_playhead_position; + playhead_position_ = new_playhead_position; continue_render_loop(); } }, [=](caf::error &err) mutable { update_status( fmt::format( - "Failed with error: {} {}", - __PRETTY_FUNCTION__, - to_string(err)), + "Failed with error: {} {}", __PRETTY_FUNCTION__, to_string(err)), Failed); }); - }; + }; // we've asked ourselves to do a render step, but the last render @@ -1118,8 +1115,8 @@ void VideoRenderWorker::render_step() { .then( [=](timebase::flicks new_position) { if (!starting_render_loop_ && new_position <= playhead_position_) { - // this might happen if the playhead has gone all the - // way through the frame range and wrapped around + // this might happen if the playhead has gone all the + // way through the frame range and wrapped around // to the start frame. Therefore, force end of the // render loop playhead_position_ = end_position_ + timebase::flicks(1); @@ -1145,7 +1142,7 @@ void VideoRenderWorker::render_step() { .then( [=](bool) { // playhead position is incremented by frame_rate_ - render_to_image_step(playhead_position_+frame_rate_); + render_to_image_step(playhead_position_ + frame_rate_); }, [=](caf::error &err) mutable { update_status( @@ -1153,7 +1150,6 @@ void VideoRenderWorker::render_step() { "Failed with error: {} {}", __PRETTY_FUNCTION__, to_string(err)), Failed); }); - } } diff --git a/src/plugin/video_output/video_renderer/src/video_render_worker.hpp b/src/plugin/video_output/video_renderer/src/video_render_worker.hpp index 93f116d70..6fe117ef6 100644 --- a/src/plugin/video_output/video_renderer/src/video_render_worker.hpp +++ b/src/plugin/video_output/video_renderer/src/video_render_worker.hpp @@ -126,18 +126,18 @@ class VideoRenderWorker : public caf::event_based_actor { const std::string renderer_plugin_actor_address_; timebase::flicks start_position_, playhead_position_, end_position_; timebase::flicks audio_stream_position_ = timebase::k_flicks_zero_seconds; - int audio_bufs_in_flight_ = {0}; - int video_bufs_in_flight_ = {0}; - const int max_frames_in_flight_ = 32; - bool ffmpeg_process_running_ = {false}; - bool waiting_for_buffers_ = {false}; - bool completing_ = {false}; - bool starting_render_loop_ = {true}; - int soundcard_sample_rate_ = {48000}; - int percent_complete_ = {-1}; - int64_t num_audio_samples_delivered_ = {0}; - RenderStatus status_ = {Queued}; - int start_frame_ = {1}; + int audio_bufs_in_flight_ = {0}; + int video_bufs_in_flight_ = {0}; + const int max_frames_in_flight_ = 32; + bool ffmpeg_process_running_ = {false}; + bool waiting_for_buffers_ = {false}; + bool completing_ = {false}; + bool starting_render_loop_ = {true}; + int soundcard_sample_rate_ = {48000}; + int percent_complete_ = {-1}; + int64_t num_audio_samples_delivered_ = {0}; + RenderStatus status_ = {Queued}; + int start_frame_ = {1}; }; } // namespace xstudio::video_render_plugin_1_0 diff --git a/src/plugin/viewport_layout/composite_viewport_layout/src/composite_viewport_layout.cpp b/src/plugin/viewport_layout/composite_viewport_layout/src/composite_viewport_layout.cpp index d4e5063ef..f16f0ff38 100644 --- a/src/plugin/viewport_layout/composite_viewport_layout/src/composite_viewport_layout.cpp +++ b/src/plugin/viewport_layout/composite_viewport_layout/src/composite_viewport_layout.cpp @@ -244,7 +244,7 @@ void OpenGLViewportCompositeRenderer::render_difference( params["monochrome"] = mode_params.value("monochrome", true); params["boost"] = mode_params.value("boost", 0.0f); params["screen"] = mode_params.value("screen", false); - params["image_aspect"] = mode_params.value("image_aspect", 9.0f/16.0f); + params["image_aspect"] = mode_params.value("image_aspect", 9.0f / 16.0f); // set the active tex IDs for our texture targets glActiveTexture(GL_TEXTURE10); @@ -358,7 +358,7 @@ void CompositeViewportLayout::do_layout( layout_data.custom_layout_data_["screen"] = true; } layout_data.layout_aspect_ = image_layout_aspect(image_set->onscreen_image(wipeA)); - layout_data.custom_layout_data_["image_aspect"] = 1.0f/layout_data.layout_aspect_; + layout_data.custom_layout_data_["image_aspect"] = 1.0f / layout_data.layout_aspect_; } else { diff --git a/src/plugin/viewport_overlay/annotation_onion_skin/src/onion_skin_plugin.cpp b/src/plugin/viewport_overlay/annotation_onion_skin/src/onion_skin_plugin.cpp index 71b2148df..6ed9bfcfe 100644 --- a/src/plugin/viewport_overlay/annotation_onion_skin/src/onion_skin_plugin.cpp +++ b/src/plugin/viewport_overlay/annotation_onion_skin/src/onion_skin_plugin.cpp @@ -20,18 +20,15 @@ OnionSkinPlugin::OnionSkinPlugin( frames_before_ = add_integer_attribute("Frames Before", "Before", 3, 0, 20); add_hud_settings_attribute(frames_before_); - frames_before_->set_tool_tip( - "Maximum frame distance to look back for annotations"); + frames_before_->set_tool_tip("Maximum frame distance to look back for annotations"); frames_before_->set_redraw_viewport_on_change(true); frames_after_ = add_integer_attribute("Frames After", "After", 3, 0, 20); add_hud_settings_attribute(frames_after_); - frames_after_->set_tool_tip( - "Maximum frame distance to look ahead for annotations"); + frames_after_->set_tool_tip("Maximum frame distance to look ahead for annotations"); frames_after_->set_redraw_viewport_on_change(true); - base_opacity_ = - add_float_attribute("Base Opacity", "Opacity", 0.4f, 0.05f, 1.0f, 0.05f); + base_opacity_ = add_float_attribute("Base Opacity", "Opacity", 0.4f, 0.05f, 1.0f, 0.05f); add_hud_settings_attribute(base_opacity_); base_opacity_->set_tool_tip("Opacity of the nearest neighboring annotation"); base_opacity_->set_redraw_viewport_on_change(true); @@ -43,8 +40,8 @@ OnionSkinPlugin::OnionSkinPlugin( "Multiplier applied per frame step further from current frame"); opacity_falloff_->set_redraw_viewport_on_change(true); - use_original_colours_ = add_boolean_attribute( - "Use Original Colours", "Orig Colours", false); + use_original_colours_ = + add_boolean_attribute("Use Original Colours", "Orig Colours", false); add_hud_settings_attribute(use_original_colours_); use_original_colours_->set_tool_tip( "When enabled, keep annotation colours and only reduce opacity. " @@ -71,7 +68,8 @@ OnionSkinPlugin::OnionSkinPlugin( frames_after_->set_preference_path("/plugin/annotation_onion_skin/frames_after"); base_opacity_->set_preference_path("/plugin/annotation_onion_skin/base_opacity"); opacity_falloff_->set_preference_path("/plugin/annotation_onion_skin/opacity_falloff"); - use_original_colours_->set_preference_path("/plugin/annotation_onion_skin/use_original_colours"); + use_original_colours_->set_preference_path( + "/plugin/annotation_onion_skin/use_original_colours"); past_tint_->set_preference_path("/plugin/annotation_onion_skin/past_tint"); future_tint_->set_preference_path("/plugin/annotation_onion_skin/future_tint"); } @@ -91,14 +89,14 @@ utility::BlindDataObjectPtr OnionSkinPlugin::onscreen_render_data( if (!visible() || !image) return {}; - const int current_frame = image.playhead_logical_frame(); - const int range_before = static_cast(frames_before_->value()); - const int range_after = static_cast(frames_after_->value()); - const float base_opac = base_opacity_->value(); - const float falloff = opacity_falloff_->value(); - const bool orig_colours = use_original_colours_->value(); - const auto &prev_colour = past_tint_->value(); - const auto &next_colour = future_tint_->value(); + const int current_frame = image.playhead_logical_frame(); + const int range_before = static_cast(frames_before_->value()); + const int range_after = static_cast(frames_after_->value()); + const float base_opac = base_opacity_->value(); + const float falloff = opacity_falloff_->value(); + const bool orig_colours = use_original_colours_->value(); + const auto &prev_colour = past_tint_->value(); + const auto &next_colour = future_tint_->value(); if (range_before == 0 && range_after == 0) return {}; @@ -119,8 +117,7 @@ utility::BlindDataObjectPtr OnionSkinPlugin::onscreen_render_data( bool changed = (it->second.size() != frame_bookmarks.size()); if (!changed) { for (size_t i = 0; i < it->second.size(); ++i) { - if (it->second[i]->detail_.uuid_ != - frame_bookmarks[i]->detail_.uuid_) { + if (it->second[i]->detail_.uuid_ != frame_bookmarks[i]->detail_.uuid_) { changed = true; break; } @@ -152,7 +149,8 @@ utility::BlindDataObjectPtr OnionSkinPlugin::onscreen_render_data( return {c.r * tint.r, c.g * tint.g, c.b * tint.b}; }; - auto make_canvas_copy = [&](const ui::canvas::Canvas &src, float opacity, + auto make_canvas_copy = [&](const ui::canvas::Canvas &src, + float opacity, const utility::ColourTriplet &tint, bool keep_original) -> ui::canvas::Canvas { ui::canvas::Canvas out(src); @@ -236,14 +234,13 @@ utility::BlindDataObjectPtr OnionSkinPlugin::onscreen_render_data( for (const auto &bm : it->second) { if (!bm || !bm->annotation_ || !bm->annotation_->user_data()) continue; - const auto *canvas = static_cast( - bm->annotation_->user_data()); + const auto *canvas = + static_cast(bm->annotation_->user_data()); if (!canvas || canvas->empty()) continue; if (current_annotations.count(canvas)) continue; - candidates.push_back( - {canvas, dist, compute_opacity(dist), next_colour}); + candidates.push_back({canvas, dist, compute_opacity(dist), next_colour}); break; } ++it; @@ -255,8 +252,9 @@ utility::BlindDataObjectPtr OnionSkinPlugin::onscreen_render_data( return {}; // Render farthest first so closest onion skin draws on top. - std::sort(candidates.begin(), candidates.end(), - [](const auto &a, const auto &b) { return a.abs_distance > b.abs_distance; }); + std::sort(candidates.begin(), candidates.end(), [](const auto &a, const auto &b) { + return a.abs_distance > b.abs_distance; + }); std::vector canvases; canvases.reserve(candidates.size()); diff --git a/src/plugin/viewport_overlay/annotation_onion_skin/src/onion_skin_plugin.hpp b/src/plugin/viewport_overlay/annotation_onion_skin/src/onion_skin_plugin.hpp index ed1fc6fa4..2ff8bbf70 100644 --- a/src/plugin/viewport_overlay/annotation_onion_skin/src/onion_skin_plugin.hpp +++ b/src/plugin/viewport_overlay/annotation_onion_skin/src/onion_skin_plugin.hpp @@ -20,8 +20,7 @@ namespace ui { inline static const utility::Uuid PLUGIN_UUID{ "b7e3a1c0-5d4f-4e8b-9a2c-1f6d8e0b3c5a"}; - OnionSkinPlugin( - caf::actor_config &cfg, const utility::JsonStore &init_settings); + OnionSkinPlugin(caf::actor_config &cfg, const utility::JsonStore &init_settings); ~OnionSkinPlugin() override = default; diff --git a/src/plugin/viewport_overlay/annotation_onion_skin/src/onion_skin_renderer.cpp b/src/plugin/viewport_overlay/annotation_onion_skin/src/onion_skin_renderer.cpp index 932c04be2..ea3347274 100644 --- a/src/plugin/viewport_overlay/annotation_onion_skin/src/onion_skin_renderer.cpp +++ b/src/plugin/viewport_overlay/annotation_onion_skin/src/onion_skin_renderer.cpp @@ -16,9 +16,8 @@ void OnionSkinRenderer::render_image_overlay( const float device_pixel_ratio, const xstudio::media_reader::ImageBufPtr &frame) { - auto blind = frame.plugin_blind_data(OnionSkinPlugin::PLUGIN_UUID); - const auto *render_data = - dynamic_cast(blind.get()); + auto blind = frame.plugin_blind_data(OnionSkinPlugin::PLUGIN_UUID); + const auto *render_data = dynamic_cast(blind.get()); if (!render_data || render_data->canvases.empty()) return; diff --git a/src/plugin/viewport_overlay/annotations/src/annotation_opengl_renderer.cpp b/src/plugin/viewport_overlay/annotations/src/annotation_opengl_renderer.cpp index 3bad36efe..fd4a637b6 100644 --- a/src/plugin/viewport_overlay/annotations/src/annotation_opengl_renderer.cpp +++ b/src/plugin/viewport_overlay/annotations/src/annotation_opengl_renderer.cpp @@ -302,8 +302,9 @@ void AnnotationsRenderer::render_viewport_overlay( const float viewport_du_dpixel, const float device_pixel_ratio) { - if (hide_all_ || *hide_per_viewport_) - return; + // Laser strokes are intentionally drawn regardless of the Display Mode + // (Always / Only When Paused) and the user-visibility toggles — they + // are transient pointing aids and must always be visible while drawn. if (on_screen_frames) { diff --git a/src/plugin/viewport_overlay/annotations/src/annotation_undo_redo.hpp b/src/plugin/viewport_overlay/annotations/src/annotation_undo_redo.hpp index ebbc17d58..675c4ff7c 100644 --- a/src/plugin/viewport_overlay/annotations/src/annotation_undo_redo.hpp +++ b/src/plugin/viewport_overlay/annotations/src/annotation_undo_redo.hpp @@ -8,7 +8,7 @@ namespace xstudio::ui::viewport { class UndoableAction { public: - UndoableAction() = default; + UndoableAction() = default; virtual ~UndoableAction() = default; virtual bool redo(Annotation **annotation) = 0; diff --git a/src/plugin/viewport_overlay/annotations/src/annotations_core_plugin.cpp b/src/plugin/viewport_overlay/annotations/src/annotations_core_plugin.cpp index 2c51ed135..9f2ae157e 100644 --- a/src/plugin/viewport_overlay/annotations/src/annotations_core_plugin.cpp +++ b/src/plugin/viewport_overlay/annotations/src/annotations_core_plugin.cpp @@ -154,13 +154,23 @@ void AnnotationsCore::receive_annotation_data(const utility::JsonStore &d) { if (event == "PaintStart") { start_stroke_or_shape(payload, user_edit_data); modify_stroke_or_shape(payload, user_edit_data); - broadcast_live_stroke(user_edit_data, user_id); + if (user_edit_data->item_type == Canvas::ItemType::Laser) + broadcast_live_laser_stroke(user_id); + else + broadcast_live_stroke(user_edit_data, user_id); } else if (event == "PaintPoint") { modify_stroke_or_shape(payload, user_edit_data); - broadcast_live_stroke(user_edit_data, user_id); + if (user_edit_data->item_type == Canvas::ItemType::Laser) + broadcast_live_laser_stroke(user_id); + else + broadcast_live_stroke(user_edit_data, user_id); } else if (event == "PaintEnd") { - broadcast_live_stroke(user_edit_data, user_id, true); - push_live_edit_to_bookmark(user_edit_data); + if (user_edit_data->item_type == Canvas::ItemType::Laser) { + broadcast_live_laser_stroke(user_id, true); + } else { + broadcast_live_stroke(user_edit_data, user_id, true); + push_live_edit_to_bookmark(user_edit_data); + } user_edit_data->item_type = Canvas::ItemType::None; } else if (event == "CaptionStartEdit") { start_editing_existing_caption(payload, user_edit_data); @@ -181,8 +191,12 @@ void AnnotationsCore::receive_annotation_data(const utility::JsonStore &d) { } else if (event == "CaptionPointerHover") { caption_hover(payload, user_edit_data); } else if (event == "ToolChanged") { - dropper_active_ = (payload.value("tool", "") == "Colour Picker"); clear_live_caption(user_edit_data); + if (user_edit_data->item_type == Canvas::ItemType::Laser && + !user_edit_data->laser_strokes.empty()) { + broadcast_live_laser_stroke(user_id, true); + user_edit_data->item_type = Canvas::ItemType::None; + } } else if (event == "PaintUndo") { undo(user_edit_data); } else if (event == "PaintRedo") { @@ -193,22 +207,6 @@ void AnnotationsCore::receive_annotation_data(const utility::JsonStore &d) { hide_all_drawings_ = true; } else if (event == "ShowDrawings") { hide_all_drawings_ = false; - } else if (event == "HideViewportVisibility") { - const std::string vp = payload.value("viewport", ""); - if (!vp.empty()) { - user_hidden_per_viewport_[vp] = true; - if (hide_all_per_viewport_.find(vp) != hide_all_per_viewport_.end()) { - *(hide_all_per_viewport_[vp]) = true; - } - } - } else if (event == "ShowViewportVisibility") { - const std::string vp = payload.value("viewport", ""); - if (!vp.empty()) { - user_hidden_per_viewport_[vp] = false; - if (hide_all_per_viewport_.find(vp) != hide_all_per_viewport_.end()) { - *(hide_all_per_viewport_[vp]) = false; - } - } } else if (event == "SetDisplayMode") { if (payload.value("display_mode", "Only When Paused") == "Only When Paused") { @@ -354,6 +352,12 @@ void AnnotationsCore::start_stroke_or_shape( if (user_edit_data->live_stroke && payload.contains("id")) { user_edit_data->live_stroke->set_id(payload["id"].get()); + } else if ( + user_edit_data->item_type == Canvas::ItemType::Laser && + !user_edit_data->laser_strokes.empty()) { + const std::string id = payload.contains("id") ? payload["id"].get() + : to_string(utility::Uuid::generate()); + user_edit_data->laser_strokes.back()->set_id(id); } } @@ -1136,7 +1140,6 @@ void AnnotationsCore::images_going_on_screen( hide_all_per_viewport_[viewport_name] = new std::atomic_bool(false); } *(hide_all_per_viewport_[viewport_name]) = - (!dropper_active_ && user_hidden_per_viewport_[viewport_name]) || (show_annotations_during_playback_ ? false : playhead_playing); // what if a new image is going on screen, and we have an active edit going @@ -1145,8 +1148,12 @@ void AnnotationsCore::images_going_on_screen( bool images_went_off_the_screen = false; auto p = live_edit_data_.begin(); while (p != live_edit_data_.end()) { + // Keep entries that still own fading laser strokes — they are + // viewport overlays unrelated to the annotated image, and must + // outlive frame changes until fully faded. if (p->second->viewport_name == viewport_name && - p->second->item_type != Canvas::ItemType::Laser) { + p->second->item_type != Canvas::ItemType::Laser && + p->second->laser_strokes.empty()) { bool still_on_screen = false; for (int i = 0; i < images->num_onscreen_images(); ++i) { @@ -1293,6 +1300,29 @@ void AnnotationsCore::broadcast_live_stroke( .send(live_edit_event_group_); } +void AnnotationsCore::broadcast_live_laser_stroke( + const utility::Uuid &user_id, const bool stroke_completed) { + + auto p = live_edit_data_.find(user_id); + if (p == live_edit_data_.end()) + return; + const auto &user_edit_data = p->second; + if (user_edit_data->item_type != Canvas::ItemType::Laser || + user_edit_data->laser_strokes.empty()) + return; + + Annotation *anno = new Annotation(); + anno->canvas().append_item(*(user_edit_data->laser_strokes.back())); + + mail( + utility::event_atom_v, + laser_stroke_atom_v, + AnnotationBasePtr(anno), + user_id, + stroke_completed) + .send(live_edit_event_group_); +} + void AnnotationsCore::make_bookmark_for_annotations( const media::AVFrameID &frame_id, const utility::Uuid &bm_id) { @@ -1541,15 +1571,17 @@ void AnnotationsCore::fade_all_laser_strokes() { int n = 0; for (auto &p : live_edit_data_) { - auto q = p.second->laser_strokes.begin(); - while (q != p.second->laser_strokes.end()) { - - // we only erase old laser strokes if the user isn't holding the poiner - // down (in Laser mode) - bool erase = p.second->item_type != Canvas::ItemType::Laser; - erase &= (*q)->fade(0.01f); - if (erase) { - q = p.second->laser_strokes.erase(q); + bool drawing = p.second->item_type == Canvas::ItemType::Laser; + auto &strokes = p.second->laser_strokes; + auto q = strokes.begin(); + while (q != strokes.end()) { + bool fully_faded = (*q)->fade(0.01f); + // Only the back element while drawing must be preserved — it's + // the active stroke and erasing it would invalidate the back() + // reference used by modify_stroke_or_shape. + bool is_active = drawing && (std::next(q) == strokes.end()); + if (fully_faded && !is_active) { + q = strokes.erase(q); } else { n++; q++; diff --git a/src/plugin/viewport_overlay/annotations/src/annotations_core_plugin.hpp b/src/plugin/viewport_overlay/annotations/src/annotations_core_plugin.hpp index 1be5e80f0..68f197251 100644 --- a/src/plugin/viewport_overlay/annotations/src/annotations_core_plugin.hpp +++ b/src/plugin/viewport_overlay/annotations/src/annotations_core_plugin.hpp @@ -144,6 +144,9 @@ class AnnotationsCore : public plugin::StandardPlugin { const utility::Uuid &user_id, const bool stroke_completed = false); + void broadcast_live_laser_stroke( + const utility::Uuid &user_id, const bool stroke_completed = false); + void start_cursor_blink(); void fade_all_laser_strokes(); @@ -198,12 +201,10 @@ class AnnotationsCore : public plugin::StandardPlugin { bool laser_stroke_animation_{false}; bool cursor_blinking_{false}; bool show_annotations_during_playback_{false}; - bool dropper_active_{false}; std::atomic_bool hide_all_drawings_{false}; std::atomic_bool cursor_blink_; std::map hide_strokes_per_viewport_; std::map hide_all_per_viewport_; - std::map user_hidden_per_viewport_; caf::actor live_edit_event_group_; utility::Uuid current_edited_annotation_uuid_; diff --git a/src/plugin/viewport_overlay/annotations/src/annotations_ui_plugin.cpp b/src/plugin/viewport_overlay/annotations/src/annotations_ui_plugin.cpp index 89fb71485..9a93d0866 100644 --- a/src/plugin/viewport_overlay/annotations/src/annotations_ui_plugin.cpp +++ b/src/plugin/viewport_overlay/annotations/src/annotations_ui_plugin.cpp @@ -190,6 +190,11 @@ AnnotationsUI::AnnotationsUI(caf::actor_config &cfg, const utility::JsonStore &i display_mode_attribute_->expose_in_ui_attrs_group("annotations_tool_draw_mode"); display_mode_attribute_->set_preference_path("/plugin/annotations/display_mode"); + // keep laser tool active when toolbar is hidden preference + keep_laser_active_ = add_boolean_attribute( + "Keep Laser Active", "Keep Laser Active", false); + keep_laser_active_->set_preference_path("/plugin/annotations/keep_laser_active"); + // setting the active tool to -1 disables drawing via 'attribute_changed' attribute_changed(active_tool_->uuid(), module::Attribute::Value); @@ -311,14 +316,14 @@ void AnnotationsUI::attribute_changed(const utility::Uuid &attribute_uuid, const redo(viewport); } else if (action == "HideVisibility") { annotations_visible_->set_value(false); + colour_picker_hide_drawings_->set_value(true, false); utility::JsonStore payload; - payload["viewport"] = viewport; - send_event("HideViewportVisibility", payload); + send_event("HideDrawings", payload); } else if (action == "ShowVisibility") { annotations_visible_->set_value(true); + colour_picker_hide_drawings_->set_value(false, false); utility::JsonStore payload; - payload["viewport"] = viewport; - send_event("ShowViewportVisibility", payload); + send_event("ShowDrawings", payload); } action_attribute_->set_role_data( @@ -383,11 +388,13 @@ void AnnotationsUI::attribute_changed(const utility::Uuid &attribute_uuid, const if (colour_picker_hide_drawings_->value()) { + annotations_visible_->set_value(false, false); utility::JsonStore payload; send_event("HideDrawings", payload); } else { + annotations_visible_->set_value(true, false); utility::JsonStore payload; send_event("ShowDrawings", payload); } @@ -846,9 +853,7 @@ void AnnotationsUI::viewport_dockable_widget_deactivated(std::string &widget_nam if (widget_name == "Annotate") { // if the active tool is the Laser, then keep it active so // it can continue to be used without the widget visible - if (active_tool_->value() == tool_name(Laser)) { - last_tool_ = Laser; - } else { + if (!(keep_laser_active_->value() && active_tool_->value() == tool_name(Laser))) { active_tool_->set_value("None"); } } diff --git a/src/plugin/viewport_overlay/annotations/src/annotations_ui_plugin.hpp b/src/plugin/viewport_overlay/annotations/src/annotations_ui_plugin.hpp index 26a8e517c..ae71b34e7 100644 --- a/src/plugin/viewport_overlay/annotations/src/annotations_ui_plugin.hpp +++ b/src/plugin/viewport_overlay/annotations/src/annotations_ui_plugin.hpp @@ -170,6 +170,7 @@ class AnnotationsUI : public plugin::StandardPlugin { module::BooleanAttribute *colour_picker_take_average_{nullptr}; module::BooleanAttribute *colour_picker_take_show_magnifier_{nullptr}; module::BooleanAttribute *colour_picker_hide_drawings_{nullptr}; + module::BooleanAttribute *keep_laser_active_{nullptr}; module::IntegerAttribute *burn_softness_{nullptr}; module::IntegerAttribute *burn_size_{nullptr}; diff --git a/src/session/src/session_actor.cpp b/src/session/src/session_actor.cpp index 8fdf523ea..79c687c37 100644 --- a/src/session/src/session_actor.cpp +++ b/src/session/src/session_actor.cpp @@ -689,19 +689,17 @@ caf::message_handler SessionActor::message_handler() { }, [=](add_playlist_atom atom, const std::string name, const bool hidden) -> UuidActor { - // special 'hidden' playlist that isn't part of the session structure. // This can be used to make a temporary playlist to preview stuff in the // xSTUDIO viewport without adding to the full session const auto uuid = utility::Uuid::generate(); - auto actor = spawn( - name, uuid, caf::actor_cast(this)); + auto actor = + spawn(name, uuid, caf::actor_cast(this)); hidden_playlists_[uuid] = actor; anon_mail(media_rate_atom_v, base_.media_rate()).send(actor); anon_mail(playhead::playhead_rate_atom_v, base_.playhead_rate()).send(actor); link_to(actor); return UuidActor(uuid, actor); - }, [=](add_playlist_atom, caf::actor actor, const Uuid &uuid_before, const bool into) diff --git a/src/ui/qml/helper/src/thumbnail_provider_ui.cpp b/src/ui/qml/helper/src/thumbnail_provider_ui.cpp index 30ce6a701..ad946fab6 100644 --- a/src/ui/qml/helper/src/thumbnail_provider_ui.cpp +++ b/src/ui/qml/helper/src/thumbnail_provider_ui.cpp @@ -20,7 +20,7 @@ std::pair ThumbnailReader::run(JobControl &cjc) { if (id_.indexOf("http") == 0 || id_.indexOf("file") == 0) { auto thumbgen = system_.registry().get(thumbnail_manager_registry); int frame = 1; - auto hash = std::hash{}(id_.toStdString()); + auto hash = std::hash{}(id_.toStdString()); auto _uri = make_uri(id_.toStdString()); if (id_.indexOf("@") != -1) { @@ -30,9 +30,18 @@ std::pair ThumbnailReader::run(JobControl &cjc) { if (_uri) { - // here we make a frame id where we don't know about the media streams. The thumnail reader actor will automatically - // pick the first video stream to generate the thumbnail from. - AVFrameID frame_id(*_uri, frame, 0, FS_UNKNOWN, 0, 1.0f, utility::FrameRate(timebase::k_flicks_24fps), "auto video"); + // here we make a frame id where we don't know about the media streams. The + // thumnail reader actor will automatically pick the first video stream to + // generate the thumbnail from. + AVFrameID frame_id( + *_uri, + frame, + 0, + FS_UNKNOWN, + 0, + 1.0f, + utility::FrameRate(timebase::k_flicks_24fps), + "auto video"); scoped_actor sys{system_}; const auto tbp = request_receive( *sys, @@ -156,7 +165,6 @@ std::pair ThumbnailReader::run(JobControl &cjc) { QString()); } catch (const std::exception &err) { - std::cerr << "Error generating thumbnail for " << id_.toStdString() << ": " << err.what() << "\n"; if (cjc.shouldRun()) error = err.what(); } diff --git a/src/ui/qml/session/src/session_model_core_ui.cpp b/src/ui/qml/session/src/session_model_core_ui.cpp index 0f0f8b37f..2f042ab3b 100644 --- a/src/ui/qml/session/src/session_model_core_ui.cpp +++ b/src/ui/qml/session/src/session_model_core_ui.cpp @@ -1301,7 +1301,7 @@ bool SessionModel::setData(const QModelIndex &index, const QVariant &qvalue, int case thumbnailURLRole: if (j.count("thumbnail_url") and j.at("thumbnail_url") != value) { j["thumbnail_url"] = value; - result = true; + result = true; } break; diff --git a/src/ui/qml/session/src/session_model_timeline_ui.cpp b/src/ui/qml/session/src/session_model_timeline_ui.cpp index 19032d08f..1199eeb4c 100644 --- a/src/ui/qml/session/src/session_model_timeline_ui.cpp +++ b/src/ui/qml/session/src/session_model_timeline_ui.cpp @@ -659,7 +659,8 @@ QVariantList SessionModel::getAllContainerMediaFPS(const QModelIndex &tindex) { for (auto &m : media) { try { - auto rate = request_receive(*sys, m.actor(), utility::rate_atom_v, media::MT_IMAGE); + auto rate = request_receive( + *sys, m.actor(), utility::rate_atom_v, media::MT_IMAGE); result.push_back(rate.to_fps()); } catch (const std::exception &err) { result.push_back(QVariant()); diff --git a/src/ui/qml/session/src/session_model_ui.cpp b/src/ui/qml/session/src/session_model_ui.cpp index 19222f557..840c0da4d 100644 --- a/src/ui/qml/session/src/session_model_ui.cpp +++ b/src/ui/qml/session/src/session_model_ui.cpp @@ -797,8 +797,10 @@ void SessionModel::receivedData( // if media status has changed it may be because the MediaReference on a // MediaSourceActor has changed. We may need to re-generate the thumbnail // if we already have one. - if (auto type = j.value("type", ""); type == "Media" && j.count("actor") and not j.at("actor").is_null()) { - if (auto p = media_thumbnails_.find(QStringFromStd(j.at("actor"))); p != media_thumbnails_.end()) + if (auto type = j.value("type", ""); + type == "Media" && j.count("actor") and not j.at("actor").is_null()) { + if (auto p = media_thumbnails_.find(QStringFromStd(j.at("actor"))); + p != media_thumbnails_.end()) media_thumbnails_.erase(p); emit dataChanged( diff --git a/src/ui/qml/viewport/src/hotkey_ui.cpp b/src/ui/qml/viewport/src/hotkey_ui.cpp index ba3aefdf9..aa3130a18 100644 --- a/src/ui/qml/viewport/src/hotkey_ui.cpp +++ b/src/ui/qml/viewport/src/hotkey_ui.cpp @@ -417,14 +417,15 @@ void HotkeyUI::componentComplete() { void HotkeyUI::registerHotkey() { - // if (sequence_.isNull() || name_.isNull() || component_name_.isNull() || context_.isNull()) { + // if (sequence_.isNull() || name_.isNull() || component_name_.isNull() || + // context_.isNull()) { // // not ready, some properties not set (yet) // return; // } int key = 0; int mod = 0; - if (QKeySequence seq(sequence_) ; seq.count() == 1) { + if (QKeySequence seq(sequence_); seq.count() == 1) { mod = 0; if ((seq[0].toCombined() & Qt::ShiftModifier) == Qt::ShiftModifier) { mod |= KeyboardModifier::ShiftModifier; @@ -461,8 +462,7 @@ void HotkeyUI::registerHotkey() { emit uuidChanged(); - anon_mail(ui::keypress_monitor::register_hotkey_atom_v, hk) - .send(keypress_event_manager); + anon_mail(ui::keypress_monitor::register_hotkey_atom_v, hk).send(keypress_event_manager); } HotkeyReferenceUI::HotkeyReferenceUI(QObject *parent) : QMLActor(parent) { diff --git a/src/ui/qt/viewport_widget/src/offscreen_viewport.cpp b/src/ui/qt/viewport_widget/src/offscreen_viewport.cpp index 5f4182565..4973aad92 100644 --- a/src/ui/qt/viewport_widget/src/offscreen_viewport.cpp +++ b/src/ui/qt/viewport_widget/src/offscreen_viewport.cpp @@ -696,7 +696,18 @@ void OffscreenViewport::setPlayhead(const QString &playheadAddress) { try { scoped_actor sys{as_actor()->home_system()}; - auto playhead_actor = qml::actorFromQString(as_actor()->home_system(), playheadAddress); + caf::actor playhead_actor; + + if (!playheadAddress.isEmpty()) { + playhead_actor = qml::actorFromQString(as_actor()->home_system(), playheadAddress); + } else { + // use current active playhead + auto playhead_events_actor = + system().registry().template get(global_playhead_events_actor); + + playhead_actor = utility::request_receive( + *sys, playhead_events_actor, viewport_playhead_atom_v); + } if (playhead_actor) { xstudio_viewport_->set_playhead(playhead_actor); diff --git a/src/ui/viewport/src/viewport.cpp b/src/ui/viewport/src/viewport.cpp index f511a648f..a7df06597 100644 --- a/src/ui/viewport/src/viewport.cpp +++ b/src/ui/viewport/src/viewport.cpp @@ -941,14 +941,10 @@ void Viewport::revert_fit_zoom_to_previous(const bool synced) { void Viewport::switch_mirror_mode() { - if (mirror_mode_->value() == "Off") - mirror_mode_->set_value("Mirror Horizontally"); - else if (mirror_mode_->value() == "Mirror Horizontally") - mirror_mode_->set_value("Mirror Vertically"); - else if (mirror_mode_->value() == "Mirror Vertically") - mirror_mode_->set_value("Mirror Both"); - else + if (mirror_mode_->value() != "Off") mirror_mode_->set_value("Off"); + else + mirror_mode_->set_value("Mirror Horizontally"); } Imath::V4f Viewport::normalised_pointer_position() const { @@ -1231,7 +1227,8 @@ caf::message_handler Viewport::message_handler() { }, [=](utility::event_atom, viewport_playhead_atom, caf::actor playhead) { - // this comes from the global playhead actor when the global selected playhead changes. + // this comes from the global playhead actor when the global selected + // playhead changes. if (sync_to_main_viewport_->value()) { set_playhead(playhead); } diff --git a/src/ui/viewport/src/viewport_frame_queue_actor.cpp b/src/ui/viewport/src/viewport_frame_queue_actor.cpp index 1db2442fb..ebbf3b8b0 100644 --- a/src/ui/viewport/src/viewport_frame_queue_actor.cpp +++ b/src/ui/viewport/src/viewport_frame_queue_actor.cpp @@ -535,7 +535,7 @@ void ViewportFrameQueueActor::append_overlays_data( add_layout_data(image_set_with_colour_data); }, [=](caf::error &err) mutable { - spdlog::warn("B {} {}", __PRETTY_FUNCTION__, to_string(err)); + spdlog::debug("B {} {}", __PRETTY_FUNCTION__, to_string(err)); add_layout_data(foo); }); } diff --git a/src/utility/src/helpers.cpp b/src/utility/src/helpers.cpp index 40b500572..53ee2a9bc 100644 --- a/src/utility/src/helpers.cpp +++ b/src/utility/src/helpers.cpp @@ -50,11 +50,11 @@ namespace fs = std::filesystem; // } namespace { - std::set supported_extensions_; - std::mutex supported_extensions_mutex; - std::set warned_undefined_envvars_; - std::mutex warned_undefined_envvars_mutex; - static PathRemapper s_remapper; +std::set supported_extensions_; +std::mutex supported_extensions_mutex; +std::set warned_undefined_envvars_; +std::mutex warned_undefined_envvars_mutex; +static PathRemapper s_remapper; } // namespace void xstudio::utility::add_supported_extensions(const std::vector &values) { @@ -88,7 +88,7 @@ void xstudio::utility::add_remap_file_path(const std::string &from, const std::s s_remapper.add_path_mapping(from, to); } -std::string xstudio::utility::forward_remap_file_path (const std::string &path) { +std::string xstudio::utility::forward_remap_file_path(const std::string &path) { return s_remapper.forwards(path); } @@ -565,7 +565,8 @@ caf::uri xstudio::utility::parse_cli_posix_path( return uri; } -caf::uri xstudio::utility::posix_path_to_uri(const std::string &path, const bool abspath, const bool remap) { +caf::uri xstudio::utility::posix_path_to_uri( + const std::string &path, const bool abspath, const bool remap) { auto p = path; @@ -583,7 +584,7 @@ caf::uri xstudio::utility::posix_path_to_uri(const std::string &path, const bool #endif } - if(remap) + if (remap) p = reverse_remap_file_path(p); // spdlog::warn("posix_path_to_uri: {} -> {}", path, p); @@ -965,8 +966,7 @@ std::string xstudio::utility::xstudio_resources_dir(const std::string &append_pa #endif } -std::array -xstudio::utility::md5_hash(const void *data, std::size_t size) { +std::array xstudio::utility::md5_hash(const void *data, std::size_t size) { std::array hash{}; #if OPENSSL_VERSION_NUMBER >= 0x10100000L EVP_MD_CTX *ctx = EVP_MD_CTX_new(); diff --git a/src/utility/src/path_remapper.cpp b/src/utility/src/path_remapper.cpp index eb733eae8..9c2f501e9 100644 --- a/src/utility/src/path_remapper.cpp +++ b/src/utility/src/path_remapper.cpp @@ -5,60 +5,56 @@ namespace xstudio::utility { -std::string PathRemapper::forwards(const std::string &path) { - return remap(path, true); -} +std::string PathRemapper::forwards(const std::string &path) { return remap(path, true); } -std::string PathRemapper::backwards(const std::string &path) { - return remap(path, false); -} +std::string PathRemapper::backwards(const std::string &path) { return remap(path, false); } std::string PathRemapper::remap(const std::string &path, const bool forward) { - std::scoped_lock lock(mutex_); + std::scoped_lock lock(mutex_); - const auto &remap = (forward ? forward_regex_ : backward_regex_); - auto p = path; + const auto &remap = (forward ? forward_regex_ : backward_regex_); + auto p = path; - // are we in danger of flip / flopping forever ? - if(forward) { - auto it = forward_map_.find(p); - while(it != std::end(forward_map_) and p != it->second) { - p = it->second; - it = forward_map_.find(p); - } - } + // are we in danger of flip / flopping forever ? + if (forward) { + auto it = forward_map_.find(p); + while (it != std::end(forward_map_) and p != it->second) { + p = it->second; + it = forward_map_.find(p); + } + } for (const auto &r : remap) { p = std::regex_replace(p, r.first, r.second); } - // are we in danger of flip / flopping forever ? - if(not forward) { - auto it = backward_map_.find(p); - while(it != std::end(backward_map_) and p != it->second) { - p = it->second; - it = backward_map_.find(p); - } - } + // are we in danger of flip / flopping forever ? + if (not forward) { + auto it = backward_map_.find(p); + while (it != std::end(backward_map_) and p != it->second) { + p = it->second; + it = backward_map_.find(p); + } + } // if(p != path) { // spdlog::warn("remap forward {} {} -> {}", forward, path, p); // } - return p; + return p; } void PathRemapper::add_path_mapping(const std::string &from, const std::string &to) { - // spdlog::warn("add_path_mapping {} -> {}", from, to); - std::scoped_lock lock(mutex_); - forward_map_.emplace(std::make_pair(from, to)); - backward_map_.emplace(std::make_pair(to, from)); + // spdlog::warn("add_path_mapping {} -> {}", from, to); + std::scoped_lock lock(mutex_); + forward_map_.emplace(std::make_pair(from, to)); + backward_map_.emplace(std::make_pair(to, from)); } void PathRemapper::configure(const utility::JsonStore &jsn) { - std::scoped_lock lock(mutex_); + std::scoped_lock lock(mutex_); try { if (!jsn.is_object()) throw std::runtime_error("Json should be a dict"); @@ -98,5 +94,4 @@ void PathRemapper::configure(const utility::JsonStore &jsn) { } } -} - +} // namespace xstudio::utility diff --git a/src/utility/src/sequence.cpp b/src/utility/src/sequence.cpp index dd8671215..da3304ace 100644 --- a/src/utility/src/sequence.cpp +++ b/src/utility/src/sequence.cpp @@ -361,9 +361,10 @@ std::string escape_percentage(const std::string &str) { } static const std::set not_sequence_ext_set{ - ".BZ2", ".bz2", ".MOV", ".mov", ".AVI", ".avi", ".CINE", ".cine", ".R3D", ".r3d", ".AAF", - ".aaf", ".MXF", ".mxf", ".WAV", ".wav", ".AIFF", ".aiff", ".AIF", ".aif", ".HIP", ".hip", ".MB", ".mb", - ".MA", ".ma", ".NK", ".nk", ".mv4", ".MP4", ".mp4", ".mp3", ".MP3", ".WEBM", ".webm"}; + ".BZ2", ".bz2", ".MOV", ".mov", ".AVI", ".avi", ".CINE", ".cine", ".R3D", + ".r3d", ".AAF", ".aaf", ".MXF", ".mxf", ".WAV", ".wav", ".AIFF", ".aiff", + ".AIF", ".aif", ".HIP", ".hip", ".MB", ".mb", ".MA", ".ma", ".NK", + ".nk", ".mv4", ".MP4", ".mp4", ".mp3", ".MP3", ".WEBM", ".webm"}; bool default_is_sequence(const Entry &entry) { // things that are never sequences.. diff --git a/ui/qml/xstudio/application/auxillary/XsGlobalDragDropHandler.qml b/ui/qml/xstudio/application/auxillary/XsGlobalDragDropHandler.qml index 4f86d5840..787ed2459 100644 --- a/ui/qml/xstudio/application/auxillary/XsGlobalDragDropHandler.qml +++ b/ui/qml/xstudio/application/auxillary/XsGlobalDragDropHandler.qml @@ -40,7 +40,10 @@ DropArea { } else { if (dragItem) { drag_proxy.grabToImage(function(result) { - helpers.setOverrideCursor(result) + // extra check because grabToImage is async. + if (dragging) { + helpers.setOverrideCursor(result) + } }) } else { helpers.setOverrideCursor("Qt.DragMoveCursor") diff --git a/ui/qml/xstudio/application/dialogs/preferences/widgets/XsPreferenceInfoButton.qml b/ui/qml/xstudio/application/dialogs/preferences/widgets/XsPreferenceInfoButton.qml index 871a6298f..96081a8fb 100644 --- a/ui/qml/xstudio/application/dialogs/preferences/widgets/XsPreferenceInfoButton.qml +++ b/ui/qml/xstudio/application/dialogs/preferences/widgets/XsPreferenceInfoButton.qml @@ -1,44 +1,15 @@ // SPDX-License-Identifier: Apache-2.0 import QtQuick - import QtQuick.Layouts - - -import xstudio.qml.models 1.0 import xStudio 1.0 -Item { +XsInfoButton { Layout.fillWidth: true Layout.preferredWidth: 20 Layout.fillHeight: true - - XsIcon { - - anchors.verticalCenter: parent.verticalCenter - anchors.left: parent.left - anchors.leftMargin: 5 - width: height - height: parent.height -2 - source: "qrc:/icons/help.svg" - imgOverlayColor: ma.containsMouse ? XsStyleSheet.accentColor : XsStyleSheet.hintColor - antialiasing: true - smooth: true - - MouseArea { - id: ma - anchors.fill: parent - hoverEnabled: true - } - - } - - XsToolTip { - id: tooltip - text: descriptionRole - maxWidth: prefsLabelWidth*1.5 - visible: ma.containsMouse - } + tooltipText: descriptionRole + maxWidth: prefsLabelWidth*1.5 } diff --git a/ui/qml/xstudio/application/panels/media/widgets/XsMediaHotKeys.qml b/ui/qml/xstudio/application/panels/media/widgets/XsMediaHotKeys.qml index ea54f41de..b506e5f2b 100644 --- a/ui/qml/xstudio/application/panels/media/widgets/XsMediaHotKeys.qml +++ b/ui/qml/xstudio/application/panels/media/widgets/XsMediaHotKeys.qml @@ -248,7 +248,7 @@ XsHotkeyArea { XsHotkey { id: cycle_colour_hotkey context: hotkey_area.context - sequence: "SHIFT+C" + sequence: "Shift+C" name: "Cycle Media Colour" description: "Cycle Selected Media Colour" onActivated: media_list_functions.cycleColour(mediaSelectionModel.selectedIndexes) diff --git a/ui/qml/xstudio/application/panels/media/widgets/XsMediaListContextMenu.qml b/ui/qml/xstudio/application/panels/media/widgets/XsMediaListContextMenu.qml index 1484a5838..6f3e14766 100644 --- a/ui/qml/xstudio/application/panels/media/widgets/XsMediaListContextMenu.qml +++ b/ui/qml/xstudio/application/panels/media/widgets/XsMediaListContextMenu.qml @@ -190,8 +190,8 @@ XsPopupMenu { XsMenuModelItem { text: "Cycle Media Colour" - menuPath: "" - menuItemPosition: 2.1 + menuPath: "Media Settings" + menuItemPosition: -0.5 menuModelName: btnMenu.menu_model_name onActivated: media_list_functions.cycleColour(mediaSelectionModel.selectedIndexes) panelContext: btnMenu.panelContext diff --git a/ui/qml/xstudio/assets/icons/imagesmode.svg b/ui/qml/xstudio/assets/icons/imagesmode.svg new file mode 100644 index 000000000..7c94952d9 --- /dev/null +++ b/ui/qml/xstudio/assets/icons/imagesmode.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/ui/qml/xstudio/assets/icons/list_alt2.svg b/ui/qml/xstudio/assets/icons/list_alt2.svg new file mode 100644 index 000000000..92cdd17b3 --- /dev/null +++ b/ui/qml/xstudio/assets/icons/list_alt2.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/ui/qml/xstudio/qml.qrc b/ui/qml/xstudio/qml.qrc index 135d581fc..dd8fc79a5 100644 --- a/ui/qml/xstudio/qml.qrc +++ b/ui/qml/xstudio/qml.qrc @@ -369,6 +369,7 @@ assets/icons/home.svg assets/icons/history.svg assets/icons/image.svg + assets/icons/imagesmode.svg assets/icons/info.svg assets/icons/ink_pen.svg assets/icons/input.svg @@ -384,6 +385,7 @@ assets/icons/link.svg assets/icons/list.svg assets/icons/list_alt.svg + assets/icons/list_alt2.svg assets/icons/list_default.svg assets/icons/list_group.svg assets/icons/list_shotgun.svg