diff --git a/CMakeLists.txt b/CMakeLists.txt index fd93193ff..7531eff9a 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -208,7 +208,7 @@ if (USE_VCPKG) message(FATAL_ERROR "Failed to ensurepip.") else() execute_process( - COMMAND "${Python_EXECUTABLE}" -m pip install setuptools sphinx breathe sphinx-rtd-theme OpenTimelineIO-Plugins importlib_metadata zipp + COMMAND "${Python_EXECUTABLE}" -m pip install setuptools sphinx breathe sphinx-rtd-theme OpenTimelineIO-Plugins importlib_metadata zipp numpy fileseq RESULT_VARIABLE PIP_RESULT ) if(PIP_RESULT) diff --git a/src/plugin/colour_pipeline/ocio/src/ocio_python_plugin/ocio_py_plugin.py b/src/plugin/colour_pipeline/ocio/src/ocio_python_plugin/ocio_py_plugin.py index 0a2803e8b..83852a6ff 100644 --- a/src/plugin/colour_pipeline/ocio/src/ocio_python_plugin/ocio_py_plugin.py +++ b/src/plugin/colour_pipeline/ocio/src/ocio_python_plugin/ocio_py_plugin.py @@ -255,6 +255,8 @@ def _plugin_metadata(self, media): metadata = media.media_source().get_metadata( "/colour_pipeline" ) + if not metadata: + metadata = {} return metadata.get("ocio_py_plugin", {}) def _set_plugin_metadata(self, media, plugin_metadata): diff --git a/src/plugin/media_metadata/ffprobe/src/ffprobe_lib.cpp b/src/plugin/media_metadata/ffprobe/src/ffprobe_lib.cpp index 3ae4e994f..0028d5549 100644 --- a/src/plugin/media_metadata/ffprobe/src/ffprobe_lib.cpp +++ b/src/plugin/media_metadata/ffprobe/src/ffprobe_lib.cpp @@ -444,7 +444,7 @@ nlohmann::json populate_stream(AVFormatContext *avfc, int index, MediaStream *is result["profile"] = nullptr; if (profile = avcodec_profile_name(par->codec_id, par->profile)) result["profile"] = profile; - else if (par->profile != FF_PROFILE_UNKNOWN) { + else if (par->profile != AV_PROFILE_UNKNOWN) { result["profile"] = std::to_string(par->profile); } diff --git a/src/plugin/python_plugins/filesystem_browser/README.md b/src/plugin/python_plugins/filesystem_browser/README.md new file mode 100644 index 000000000..b69d591ca --- /dev/null +++ b/src/plugin/python_plugins/filesystem_browser/README.md @@ -0,0 +1,68 @@ +# Filesystem Browser Plugin for xStudio + +A high-performance, multi-threaded filesystem browser for xStudio, designed to handle large directories and image sequences efficiently. + +## Features + +- **Fast Multi-threaded Scanning**: Uses a thread pool and BFS algorithm to scan directories quickly without freezing the UI. +- **Image Sequence Detection**: Automatically detects and groups file sequences (e.g., `shot_001.1001.exr` -> `shot_001.####.exr`). Supports exclusion of specific extensions (e.g., `.mov`, `.mp4`) via configuration. +- **Smart Filtering**: + - **Text Filter**: Supports "AND" logic (space-separated terms). E.g., `comp exr` finds files matchings both "comp" and "exr". + - **Time Filter**: Filter by modification time (Last 1 day, 1 week, etc.). + - **Version Filter**: Filter to show only the latest version or latest 2 versions of a shot. +- **Navigation**: + - Native Directory Picker integration. + - Path completion/suggestions. + - History tracking (via sticky attributes). +- **Playback Integration**: + - **Double-Click**: Loads media and immediately starts playback using the playlist's playhead logic. + - **Context Menu**: + - **Replace**: Replaces the currently viewed media with the selected item. + - **Compare with**: Loads the selected item and sets up an A/B comparison with the current media. + +## Usage + +1. **Open the Browser**: + - Go to `View` -> `Panels` -> `Filesystem Browser`. + - Or use the hotkey **'B'**. +2. **Navigation**: + - Enter a path in the text field or click the folder icon to browse. + - **Double-click** a folder to navigate into it. + - **Quick Access (▼)**: Click the arrow next to the path field to open the Quick Access list. + - **History**: Shows recently visited directories. + - **Pinned**: Shows your pinned locations for easy access. + - **Pinning**: Click the "Pin" icon (📌) next to any item to pin or unpin it. Pinned items appear at the top in gold. + +## Configuration + +### Environment Variables + +- `XSTUDIO_BROWSER_PINS`: Pre-define a list of pinned directories. + - Format: JSON list of objects or simple path string (colon-separated on Unix, semicolon on Windows). + - Example (JSON): `'[{"name": "Show", "path": "/jobs/show"}, "/home/user"]'` + - Example (Simple): `/jobs/show:/home/user` + +3. **Loading Media**: + - **Double-click** a file/sequence to load it into the current or new playlist. + - **Right-click** for advanced actions (Replace, Compare). + +## Logic & Performance + +- **Scanning**: The scanner runs in a background thread, reporting partial results to the UI to keep it responsive. +- **Sequences**: Uses the `fileseq` library (for robust sequence parsing. + +## Testing + +A benchmark script is included to test the scanner performance: + +```bash +python scanner_benchmark.py --threads 2 /shots/MYSHOW/MYSHOT +``` + +This allows you to test the scanning performance at different thread speeds for the specified directory. + +```bash +python test_scanner.py +``` +Unit test for scanner. + diff --git a/src/plugin/python_plugins/filesystem_browser/__init__.py b/src/plugin/python_plugins/filesystem_browser/__init__.py new file mode 100644 index 000000000..da8023aea --- /dev/null +++ b/src/plugin/python_plugins/filesystem_browser/__init__.py @@ -0,0 +1 @@ +from .filesystem_browser import create_plugin_instance diff --git a/src/plugin/python_plugins/filesystem_browser/config.json b/src/plugin/python_plugins/filesystem_browser/config.json new file mode 100644 index 000000000..3e549079e --- /dev/null +++ b/src/plugin/python_plugins/filesystem_browser/config.json @@ -0,0 +1,42 @@ +{ + "extensions": [ + ".mov", + ".mp4", + ".mkv", + ".exr", + ".jpg", + ".jpeg", + ".png", + ".dpx", + ".tiff", + ".tif", + ".wav", + ".mp3", + ".pdf" + ], + "ignore_dirs": [ + ".git", + ".quarantine", + "eryx_unreal_plugin", + ".DS_Store" + ], + "root_ignore_dirs": [ + "/Applications", + "/bin", + "/cores", + "/dev", + "/etc", + "/Library", + "/opt", + "/private", + "/sbin", + "/System", + "/usr", + "/var", + "/proc", + "/sys", + "/snap" + ], + "max_recursion_depth": 6, + "auto_scan_threshold": 4 +} \ No newline at end of file diff --git a/src/plugin/python_plugins/filesystem_browser/filesystem_browser.py b/src/plugin/python_plugins/filesystem_browser/filesystem_browser.py new file mode 100644 index 000000000..9b2bad637 --- /dev/null +++ b/src/plugin/python_plugins/filesystem_browser/filesystem_browser.py @@ -0,0 +1,1514 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (c) 2026 Sam Richards + +from xstudio.plugin import PluginBase +from xstudio.core import JsonStore, FrameList, add_media_atom, Uuid +import os +import sys +import json +import threading +import queue +import time +import subprocess +import shutil +import pathlib +import tempfile +import uuid as _uuid +import atexit +from collections import OrderedDict +from datetime import datetime + +# Try importing fileseq +try: + import fileseq + fileseq_available = True +except ImportError: + fileseq_available = False + print("Warning: fileseq module not found. Sequence detection will be disabled.") + +# File-based debug log (more reliable than print in xStudio's embedded Python) +_DEBUG_LOG = "/tmp/xstudio_thumb_debug.txt" +def _dbg(msg): + try: + with open(_DEBUG_LOG, "a") as _f: + _f.write(f"{msg}\n") + _f.flush() + except Exception: + pass + + +def _find_ffmpeg(): + """Find ffmpeg binary. Checks env var, xStudio app bundle, then system PATH.""" + # 1. Explicit override + env_path = os.environ.get("FFMPEG_PATH") + if env_path and os.path.isfile(env_path): + return env_path, None # (binary, dyld_lib_path) + + # 2. xStudio app bundle (same directory as the main binary) + exe = sys.argv[0] if sys.argv else "" + bundle_ffmpeg = os.path.join(os.path.dirname(exe), "ffmpeg") + if os.path.isfile(bundle_ffmpeg): + # Bundled ffmpeg needs Frameworks dir on DYLD_LIBRARY_PATH + frameworks = os.path.join(os.path.dirname(exe), "..", "Frameworks") + frameworks = os.path.normpath(frameworks) + return bundle_ffmpeg, frameworks + + # 3. System PATH + system_ffmpeg = shutil.which("ffmpeg") + if system_ffmpeg: + return system_ffmpeg, None + + return None, None + + +# PySide6 dependency removed +# from PySide6.QtCore import QObject, Signal, Qt +# from PySide6.QtWidgets import QApplication, QFileDialog + +# MainThreadExecutor removed. +# xstudio attributes .set_value() is generally thread-safe (posts to actor). +# For GUI dialogs, we need another approach or they are disabled without PySide. + + +class XStudioHostInterface: + """ + Concrete implementation of the host application interface for xStudio. + Handles loading, previewing, and comparing media, as well as playlist management. + To port to OpenRV or another app, create a new host interface mapping to these methods. + """ + def __init__(self, connection, plugin): + """ + Initializes the xStudio host interface. + + Args: + connection (RemoteConnection): The xStudio remote API connection object. + plugin (PluginBase): The parent plugin instance, which holds shared state + or helper functions if needed. + """ + self.connection = connection + self.plugin = plugin + self.playlist_path_cache = {} + self.last_used_playlist_uuid = None + self.original_playlist_uuid = None + self.preview_playlist_uuid = None + self.pending_preview_deletion_uuid = None + + def _resolve_active_playlist(self): + """ + Attempts to find an active (on-screen or selected) playlist that isn't the Preview playlist. + + Returns: + Playlist | None: The active playlist container, or None if no valid playlist is found. + """ + try: + viewed = self.connection.api.session.viewed_container + if hasattr(viewed, 'add_media') and viewed.name != "Preview": + return viewed + except Exception: pass + try: + selection = self.connection.api.session.selected_containers + if selection and hasattr(selection[0], 'add_media') and selection[0].name != "Preview": + return selection[0] + except Exception: pass + return None + + @staticmethod + def _format_sequence_path(path): + """ + 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 not fileseq_available: return None + try: + seq = fileseq.FileSequence(path) + 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()}" + except Exception: return None + + def _add_media_to_playlist(self, playlist, path): + """ + Adds a file or sequence to the given playlist. Formats the path as a sequence if applicable. + + Args: + playlist (Playlist): The target xStudio playlist object. + path (str): The filesystem path to load. + + Returns: + Media | None: The newly added media object, or None if it fails. + """ + try: + seq_path = self._format_sequence_path(path) + return playlist.add_media(seq_path if seq_path else path) + except Exception as e: + print(f"Add media error: {e}") + return None + + def _find_container_uuid(self, tree, target_value_uuid): + """ + Recursively searches the playlist tree to find the internal tree-node UUID + corresponding to a known actor value UUID. + + Args: + tree (TreeNode): The root node of the playlist tree sequence. + target_value_uuid (Uuid): The value_uuid to search for. + + Returns: + Uuid | None: The tree node UUID, or None if not found. + """ + if hasattr(tree, 'value_uuid') and str(tree.value_uuid) == str(target_value_uuid): + return tree.uuid + if hasattr(tree, 'children'): + for child in tree.children: + res = self._find_container_uuid(child, target_value_uuid) + if res: return res + return None + + def load_media(self, path): + """ + Loads the specified media into xStudio, creating a new playlist if necessary, or attaching it + to the currently active one. Handles sequence detection, local state caching, and playhead setup. + Will not add the file if it detects a duplicate already inside the playlist. + + Args: + path (str): The path to the file or sequence to load. + """ + try: + valid_playlist = None + try: + selection = self.connection.api.session.selected_containers + for item in selection: + if hasattr(item, 'add_media') and item.name != "Preview": + valid_playlist = item + self.last_used_playlist_uuid = item.uuid + break + except Exception: pass + + if not valid_playlist and self.last_used_playlist_uuid: + try: + target_uuid_str = str(self.last_used_playlist_uuid) + for p in self.connection.api.session.playlists: + if str(p.uuid) == target_uuid_str and p.name != "Preview": + valid_playlist = p + break + except Exception: pass + + if not valid_playlist: + try: + viewed = self.connection.api.session.viewed_container + if hasattr(viewed, 'add_media') and viewed.name != "Preview": + valid_playlist = viewed + self.last_used_playlist_uuid = viewed.uuid + except Exception: pass + + if not valid_playlist: + playlists = [p for p in self.connection.api.session.playlists if p.name != "Preview"] + if playlists: + valid_playlist = playlists[0] + else: + self.connection.api.session.create_playlist("Filesystem Import") + valid_playlist = [p for p in self.connection.api.session.playlists if p.name != "Preview"][0] + self.last_used_playlist_uuid = valid_playlist.uuid + + if self.preview_playlist_uuid is not None: + if self.original_playlist_uuid is not None: + orig_uuid_str = str(self.original_playlist_uuid) + for p in self.connection.api.session.playlists: + if str(p.uuid) == orig_uuid_str: + valid_playlist = p + break + self.pending_preview_deletion_uuid = self.preview_playlist_uuid + self.original_playlist_uuid = None + self.preview_playlist_uuid = None + + playlist = valid_playlist + pl_uuid = str(playlist.uuid) + if pl_uuid not in self.playlist_path_cache: + self.playlist_path_cache[pl_uuid] = set() + + existing_media = None + try: + current_media_list = playlist.media + tgt_path = os.path.normpath(os.path.abspath(path)) + for m in current_media_list: + try: + ms = m.media_source() + mr = ms.media_reference + if mr: + u = mr.uri() + mp = u.path() + if mp: + mp_norm = os.path.normpath(os.path.abspath(mp)) + if mp_norm == tgt_path: + existing_media = m + break + except Exception: continue + except Exception as e: print(f"Dup check error: {e}") + + if existing_media: + media = existing_media + elif tgt_path in self.playlist_path_cache[pl_uuid]: + return + else: + seq_path = self._format_sequence_path(path) if fileseq_available else None + if seq_path: + media = playlist.add_media(seq_path) + else: + media = playlist.add_media(path) + self.playlist_path_cache[pl_uuid].add(tgt_path) + + self.connection.api.session.set_on_screen_source(playlist) + try: self.connection.api.session.viewed_container = playlist + except Exception: pass + + if hasattr(playlist, 'playhead_selection'): + playlist.playhead_selection.set_selection([media.uuid]) + + try: playlist.playhead.playing = True + except Exception: pass + + if self.pending_preview_deletion_uuid: + try: + prev_uuid = self.pending_preview_deletion_uuid + self.pending_preview_deletion_uuid = None + tree = self.connection.api.session.playlist_tree + cuuid = self._find_container_uuid(tree, prev_uuid) + if cuuid: + self.connection.api.session.remove_container(cuuid) + else: + for p in self.connection.api.session.playlists: + if str(p.uuid) == str(prev_uuid): + self.connection.api.session.remove_container(p) + break + except Exception as e: _dbg(f"Final cleanup error: {e}") + + except Exception as e: + print(f"Error loading file: {e}") + + def replace_current_media(self, path): + """ + Replaces the currently selected/playing media in the active playlist with the new source. + + Args: + path (str): The new file path 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 + items_to_remove = [] + if hasattr(playlist, 'playhead_selection'): + current_selection = playlist.playhead_selection.selected_sources + if current_selection: items_to_remove = current_selection + + 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 + + if hasattr(playlist, 'playhead'): + playlist.playhead.playing = True + + except Exception as e: print(f"Replace error: {e}") + + def compare_with_current_media(self, path): + """ + Adds the specified media to the current selection, and puts the playhead into A/B compare mode. + + Args: + path (str): The new media file path 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) + if not new_media: return + new_selection = [] + if hasattr(playlist, 'playhead_selection'): + for m in playlist.playhead_selection.selected_sources: + new_selection.append(m.uuid) + new_selection.append(new_media.uuid) + if hasattr(playlist, 'playhead_selection'): + 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: print(f"Compare error: {e}") + + def append_media(self, path): + """ + Appends a piece of media to the currently active playlist without altering + the playhead's current playback mode or selection. + + Args: + path (str): The media file path to append. + """ + try: + playlist = self._resolve_active_playlist() + if not playlist: return + self._add_media_to_playlist(playlist, path) + except Exception as e: print(f"Append error: {e}") + + def preview_media(self, path): + """ + Creates or utilizes a transient 'Preview' playlist to temporarily view an item. + It safely captures the existing playlist and playhead frame so that closing the preview + can revert the interface to precisely what was observed before. + + Args: + path (str): The media file path to preview. + """ + try: + viewed = None + try: viewed = self.connection.api.session.viewed_container + except Exception: pass + + if self.preview_playlist_uuid is None: + self.original_playlist_uuid = None + if viewed and hasattr(viewed, 'add_media') and viewed.name != "Preview": + try: self.original_playlist_uuid = viewed.uuid + except Exception: pass + + current_frame = None + if viewed and hasattr(viewed, 'playhead'): + try: current_frame = viewed.playhead.position + except Exception: pass + + preview_playlist = None + try: + for p in self.connection.api.session.playlists: + try: + if p.name == "Preview": + preview_playlist = p + break + except Exception: continue + except Exception: pass + + if not preview_playlist: + try: _, preview_playlist = self.connection.api.session.create_playlist("Preview") + except Exception: pass + + if not preview_playlist: return + + try: self.preview_playlist_uuid = preview_playlist.uuid + except Exception: pass + + try: + media_list = list(preview_playlist.media) + if media_list: preview_playlist.remove_media(media_list) + except Exception: pass + + media = self._add_media_to_playlist(preview_playlist, path) + if not media: return + + try: self.connection.api.session.set_on_screen_source(preview_playlist) + except Exception: pass + + try: self.connection.api.session.viewed_container = preview_playlist + except Exception: pass + + try: + if hasattr(preview_playlist, 'playhead_selection'): + preview_playlist.playhead_selection.set_selection([media.uuid]) + + if hasattr(preview_playlist, 'playhead'): + if current_frame is not None: + try: preview_playlist.playhead.position = current_frame + except Exception: pass + try: preview_playlist.playhead.playing = False + except Exception: pass + except Exception: pass + + except Exception as e: print(f"FilesystemBrowser Preview error: {e}") + + +class FilesystemBrowserPlugin(PluginBase): + def __init__(self, connection): + PluginBase.__init__( + self, + connection, + "Filesystem Browser", + qml_folder="qml/FilesystemBrowser.1" + ) + # Initialize the host application interface (xStudio concrete implementation) + self.host = XStudioHostInterface(self.connection, self) + + # Load Configuration + self.config = self.load_config() + + # self.main_executor = MainThreadExecutor() + + # Attribute to communicate list of files to QML (as JSON string) + self.files_attr = self.add_attribute( + "file_list", + "[]", # Empty JSON list + {"title": "file_list"}, # Explicit title for QML lookup + register_as_preference=False + ) + self.files_attr.expose_in_ui_attrs_group("Filesystem Browser") + + # Attribute for current path + self.current_path_attr = self.add_attribute( + "current_path", + os.getcwd(), + {"title": "current_path"}, + register_as_preference=True + ) + self.current_path_attr.expose_in_ui_attrs_group("Filesystem Browser") + + # Attribute for commands from QML + self.command_attr = self.add_attribute( + "command_channel", + "", + {"title": "command_channel"}, + register_as_preference=False + ) + self.command_attr.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 + 0, # default_modifier + "Show Filesystem Browser", + "Toggles the Filesystem Browser panel", + False, # auto_repeat + "FilesystemBrowser", # component + "Window" # context + ) + + # Menu item triggers this action + # Removed manual callback to rely on hotkey_uuid linkage + # which should toggle the panel automatically if registered correctly. + self.insert_menu_item( + "main menu bar", + "Filesystem Browser", + "View|Panels", + 0.0, + hotkey_uuid=self.toggle_browser_action, + callback=self.toggle_browser_from_menu + ) + + # Add menu item to open as floating window + self.insert_menu_item( + "main menu bar", + "Browser Open", + "Plugins", + 0.1, + callback=self.open_floating_browser + ) + + # Register the panel, passing the action + self.register_ui_panel_qml( + "Filesystem Browser", + """ + FilesystemBrowser { + anchors.fill: parent + } + """, + 10.0, # Position in menu + "", # No icon = Standard Panel (Dockable) + -1.0, + self.toggle_browser_action # Pass the action UUID + ) + + # New: Completion attribute + self.completions_attr = self.add_attribute( + "completions_attribute", + "[]", + {"title": "completions_attr"}, + register_as_preference=False + ) + self.completions_attr.expose_in_ui_attrs_group("Filesystem Browser") + + # New: Search state attribute + self.searching_attr = self.add_attribute( + "searching", + False, + {"title": "searching"}, + register_as_preference=False + ) + self.searching_attr.expose_in_ui_attrs_group("Filesystem Browser") + + # New: Progress attribute + self.progress_attr = self.add_attribute( + "scan_progress", + "0", + {"title": "scan_progress"}, + register_as_preference=False + ) + self.progress_attr.expose_in_ui_attrs_group("Filesystem Browser") + + # New: Progress attribute + self.scanned_attr = self.add_attribute( + "scanned_count", + "0", + {"title": "scanned_count"}, + register_as_preference=False + ) + self.scanned_attr.expose_in_ui_attrs_group("Filesystem Browser") + + # New: Scanned directories list + self.scanned_dirs_attr = self.add_attribute( + "scanned_dirs", + "[]", + {"title": "scanned_dirs"}, + register_as_preference=False + ) + self.scanned_dirs_attr.expose_in_ui_attrs_group("Filesystem Browser") + + # New: Directory Query Result (for Tree View) + self.directory_query_result = self.add_attribute( + "directory_query_result", + "{}", + {"title": "directory_query_result"}, + register_as_preference=False + ) + self.directory_query_result.expose_in_ui_attrs_group("Filesystem Browser") + + self.depth_limit_attr = self.add_attribute( + "recursion_limit", + self.config.get("max_recursion_depth", 6), + {"title": "Recursion Limit", "category": "Filesystem Browser"}, + register_as_preference=True + ) + self.depth_limit_attr.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", + False, + {"title": "scan_required"}, + register_as_preference=False + ) + self.scan_required_attr.expose_in_ui_attrs_group("Filesystem Browser") + + self.auto_scan_threshold_attr = self.add_attribute( + "auto_scan_threshold", + self.config.get("auto_scan_threshold", 4), + {"title": "auto_scan_threshold", "category": "Filesystem Browser"}, + register_as_preference=True + ) + self.auto_scan_threshold_attr.expose_in_ui_attrs_group("Filesystem Browser Settings") + + # New: Filter attributes + self.filter_time_attr = self.add_attribute( + "filter_time", + "Any", + {"title": "filter_time", "values": ["Any", "Last 1 day", "Last 2 days", "Last 1 week", "Last 1 month"]}, + register_as_preference=True + ) + self.filter_time_attr.expose_in_ui_attrs_group("Filesystem Browser") + + self.filter_version_attr = self.add_attribute( + "filter_version", + "All Versions", + {"title": "filter_version", "values": ["All Versions", "Latest Version", "Latest 2 Versions"]}, + register_as_preference=True + ) + self.filter_version_attr.expose_in_ui_attrs_group("Filesystem Browser") + + # History and Pinned Attributes + self.history_attr = self.add_attribute( + "history_paths", + "[]", + {"title": "history_paths"}, # Must match QML attributeTitle + register_as_preference=True + ) + self.history_attr.expose_in_ui_attrs_group("Filesystem Browser") + + # Default pinned items + default_pins = [] + + # 1. Environment Variable Pre-defines (JSON list of dicts or paths) + env_pins = os.environ.get("XSTUDIO_BROWSER_PINS") + if env_pins: + try: + # Try parsing as JSON first + parsed = json.loads(env_pins) + if isinstance(parsed, list): + for item in parsed: + if isinstance(item, dict) and "path" in item: + default_pins.append(item) + elif isinstance(item, str): + default_pins.append({"name": os.path.basename(item), "path": item}) + except: + # Fallback to standard path separator (colon on Unix, semicolon on Win) + # We also normalize semicolons to os.pathsep to be lenient + normalized = env_pins + if os.pathsep == ":": + normalized = env_pins.replace(";", ":") + + paths = normalized.split(os.pathsep) + for p in paths: + p = p.strip() + if p: + default_pins.append({"name": os.path.basename(p), "path": p}) + + # 2. Standard Defaults + home = os.environ.get("HOME") + if home: + # Avoid duplicates + if not any(p["path"] == home for p in default_pins): + default_pins.append({"name": "Home", "path": home}) + + downloads = os.path.join(home, "Downloads") + if os.path.exists(downloads): + if not any(p["path"] == downloads for p in default_pins): + default_pins.append({"name": "Downloads", "path": downloads}) + + self.pinned_attr = self.add_attribute( + "pinned_paths", + json.dumps(default_pins), + {"title": "pinned_paths"}, # Must match QML attributeTitle + register_as_preference=True + ) + self.pinned_attr.expose_in_ui_attrs_group("Filesystem Browser") + + # ENFORCE: Merge default_pins (env vars + explicit defaults) into the actual attribute value + try: + current_val = self.pinned_attr.value() + + current_pins = [] + if current_val: + try: + current_pins = json.loads(current_val) + except Exception: + current_pins = [] + + # Merge + changed = False + existing_paths = set(p["path"] for p in current_pins) + + for pin in reversed(default_pins): + if pin["path"] not in existing_paths: + current_pins.insert(0, pin) + existing_paths.add(pin["path"]) + changed = True + + if changed or not current_val: + new_val = json.dumps(current_pins) + self.pinned_attr.set_value(new_val) + + except Exception as e: + print(f"FilesystemBrowser: Error merging pins: {e}") + + # Connect listeners + # Note: We need to register callbacks properly. + # attribute_changed method handles all. + + # Configuration preferences with fallbacks from config.json + self.extensions_attr = self.add_attribute( + "Media Extensions", + ", ".join(self.config.get("extensions", [])), + {"title": "Media Extensions", "category": "Filesystem Browser"}, + register_as_preference=True + ) + self.extensions_attr.expose_in_ui_attrs_group("Filesystem Browser Settings") + + self.ignore_dirs_attr = self.add_attribute( + "Ignore Directories", + ", ".join(self.config.get("ignore_dirs", [])), + {"title": "Ignore Directories", "category": "Filesystem Browser"}, + register_as_preference=True + ) + self.ignore_dirs_attr.expose_in_ui_attrs_group("Filesystem Browser Settings") + + self.root_ignore_dirs_attr = self.add_attribute( + "Root Ignore Directories", + ", ".join(self.config.get("root_ignore_dirs", [])), + {"title": "Root Ignore Directories", "category": "Filesystem Browser"}, + register_as_preference=True + ) + self.root_ignore_dirs_attr.expose_in_ui_attrs_group("Filesystem Browser Settings") + self.search_thread = None + self.cancel_search = False + self.results_lock = threading.Lock() # Protects current_scan_results + self.current_scan_results = [] + + # Thumbnail setup — ffmpeg-based, no xStudio actor system needed + self._ffmpeg_bin, self._ffmpeg_dyld = _find_ffmpeg() + if self._ffmpeg_bin: + print(f"FilesystemBrowser: using ffmpeg at {self._ffmpeg_bin}") + else: + print("FilesystemBrowser: WARNING — ffmpeg not found, thumbnails disabled") + self._temp_dir = tempfile.mkdtemp(prefix="xstudio_thumbs_") + self._thumbnail_cache = OrderedDict() # path -> file:///... thumb URI (LRU, capped) + self._thumbnail_cache_max = 500 + atexit.register(self._cleanup) + self._thumb_lock = threading.Lock() + self._thumb_pending = set() # paths currently in queue/processing + self._thumb_queue = queue.Queue() + # 4 worker threads — daemon so they die with the process + for _ in range(4): + t = threading.Thread(target=self._thumb_worker_loop, daemon=True) + t.start() + + # State tracking for Preview Mode + self.original_playlist_uuid = None + self.preview_playlist_uuid = None + + # Dedicated attribute for batch thumbnail requests from QML. + # QML writes a JSON array of paths; Python reads and queues them all at once. + self.thumbnail_request_attr = self.add_attribute( + "thumbnail_request", + "[]", + {"title": "thumbnail_request"}, + register_as_preference=False + ) + self.thumbnail_request_attr.expose_in_ui_attrs_group("Filesystem Browser") + + # Build the QML command dispatch table + self._command_handlers = self._build_command_handlers() + + # Initial search + self.start_search(self.current_path_attr.value()) + + @property + def extensions(self): + val = self.extensions_attr.value() + if not val: return [] + return [item.strip() for item in val.split(',') if item.strip()] + + @property + def ignore_dirs(self): + val = self.ignore_dirs_attr.value() + if not val: return set() + return set(item.strip() for item in val.split(',') if item.strip()) + + @property + def root_ignore_dirs(self): + val = self.root_ignore_dirs_attr.value() + if not val: return set() + return set(item.strip() for item in val.split(',') if item.strip()) + + def toggle_browser_from_menu(self, menu_item=None, user_data=None): + # Wrapper for menu callback + # Since we are now a standard dockable panel, the user should use View -> Panels -> Filesystem Browser + # or rely on the hotkey's default action if it maps to the view. + # We'll just log here. + print("Menu item clicked. The Filesystem Browser is available in the Panels menu.") + self.toggle_browser(None, "Menu Click") + + def open_floating_browser(self): + # Create a floating window containing the FilesystemBrowser component + qml = """ + import QtQuick.Window 2.15 + import QtQuick.Controls 2.15 + + Window { + width: 900 + height: 600 + visible: true + title: "Filesystem Browser" + + FilesystemBrowser { + anchors.fill: parent + } + } + """ + self.create_qml_item(qml) + + def toggle_browser(self, converting, context): + print(f"Toggling Filesystem Browser (Action Triggered). Context: {context}") + # We can also verify visibility here if possible, but the Model handles it. + + + def _open_browser_dialog(self, initial_path): + """Runs on main thread to show dialog.""" + try: + from PySide6.QtWidgets import QFileDialog + dir_path = QFileDialog.getExistingDirectory(None, "Select Directory", initial_path) + if dir_path: + self.current_path_attr.set_value(dir_path) + self.start_search(dir_path) + except ImportError: + print("PySide6 not available. Directory dialog disabled.") + except Exception as e: + print(f"Error opening dialog: {e}") + + + def _build_command_handlers(self): + """Build the QML command dispatch table. Called once from __init__.""" + def _cmd_change_path(data): + new_path = data.get("path") + if os.path.exists(new_path) and os.path.isdir(new_path): + self.current_path_attr.set_value(new_path) + self._add_to_history(new_path) + self.start_search(new_path) + else: + print(f"Invalid path: {new_path}") + + def _cmd_set_attribute(data): + attr_name = data.get("name") + attr_value = data.get("value") + if attr_name == "filter_time": + self.filter_time_attr.set_value(attr_value) + elif attr_name == "filter_version": + self.filter_version_attr.set_value(attr_value) + elif attr_name == "recursion_limit": + self.depth_limit_attr.set_value(attr_value) + + def _cmd_copy_path(data): + path = data.get("path") + if not path: + return + try: + if sys.platform == "darwin": + subprocess.run(["pbcopy"], input=path.encode(), check=True) + elif sys.platform == "win32": + subprocess.run(["clip"], input=path.encode(), check=True) + else: + subprocess.run(["xclip", "-selection", "clipboard"], + input=path.encode(), check=True) + except Exception as e: + _dbg(f"copy_path: Error: {e}") + + def _cmd_reveal_in_finder(data): + path = data.get("path") + if not path: + return + # Resolve sequence to a concrete first frame + if fileseq_available: + try: + seq = fileseq.FileSequence(path) + if seq: + path = str(seq[0]) + except Exception: + pass + try: + if sys.platform == "darwin": + subprocess.run(["open", "-R", path], check=True) + elif sys.platform == "win32": + subprocess.run(["explorer", "/select,", os.path.normpath(path)], check=True) + else: + subprocess.run(["open", os.path.dirname(path)], check=True) + except Exception as e: + _dbg(f"reveal_in_finder: Error: {e}") + + def _cmd_force_scan(data): + path = data.get("path") + if path: + self.current_path_attr.set_value(path) + self._add_to_history(path) + self.start_search(path, force=True, depth=20) + else: + self.start_search(self.current_path_attr.value(), force=True, depth=20) + + return { + "change_path": _cmd_change_path, + "load_file": lambda d: self.load_file(d.get("path")), + "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")), + "append_media": lambda d: self.host.append_media(d.get("path")), + "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, + "get_subdirs": lambda d: self._get_subdirs(d.get("path")), + "request_thumbnail": lambda d: self._request_thumbnail(d.get("path")), + } + + def attribute_changed(self, attribute, role): + from xstudio.core import AttributeRole + + if attribute.uuid == self.command_attr.uuid and role == AttributeRole.Value: + try: + val = self.command_attr.value() + except TypeError: + return + if not val: + return + try: + data = json.loads(val) + action = data.get("action") + handler = self._command_handlers.get(action) + if handler: + handler(data) + elif action: + print(f"FilesystemBrowser: Unknown command action: {action!r}") + # Clear command channel + self.command_attr.set_value("") + except Exception as e: + print(f"Command error: {e}") + import traceback + 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) + elif attribute.uuid == self.depth_limit_attr.uuid: + if role == AttributeRole.Value: + # Recursion limit changed, re-scan + current = self.current_path_attr.value() + self.start_search(current) + elif attribute.uuid == self.thumbnail_request_attr.uuid and role == AttributeRole.Value: + # QML has written a JSON array of paths to request thumbnails for. + # Handle here on the plugin's message thread, then clear the attribute. + try: + val = attribute.value() + if val and val not in ("", "[]"): + paths = json.loads(val) + _dbg(f"BATCH: received {len(paths)} paths") + for p in paths: + self._request_thumbnail(p) + self.thumbnail_request_attr.set_value("[]") + except Exception as e: + import traceback + _dbg(f"BATCH ERROR: {e}\n{traceback.format_exc()}") + + def start_search(self, start_path, force=False, depth=None): + """ + Start the file search in a separate thread. + If force=False and depth <= 4, skip auto-scan and ask user to confirm. + """ + if not start_path: + return + + # Check path depth + norm_path = os.path.normpath(start_path) + parts = norm_path.strip(os.sep).split(os.sep) + p_depth = len([p for p in parts if p]) + + threshold = self.config.get("auto_scan_threshold", 4) + + if not force and p_depth <= threshold: + print(f"FilesystemBrowser: Path '{start_path}' (depth {p_depth}) requires manual scan.") + self.scan_required_attr.set_value(True) + 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("[]") + + 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() + return + + self.scan_required_attr.set_value(False) + + if self.search_thread and self.search_thread.is_alive(): + self.cancel_search = True + if hasattr(self, 'scanner'): + self.scanner.stop() + self.scanner.shutdown() + self.search_thread.join() + + self.cancel_search = False + self.searching_attr.set_value(True) + self.search_thread = threading.Thread(target=self._search_worker, args=(start_path, depth)) + self.search_thread.daemon = True + self.search_thread.start() + + def _search_worker(self, start_path, custom_depth=None): + print(f"Starting search in {start_path} (depth={custom_depth if custom_depth is not None else 'default'})") + + from .scanner import FileScanner + + self.cached_filter_time = self.filter_time_attr.value() + self.cached_filter_version = self.filter_version_attr.value() + + max_depth = custom_depth if custom_depth is not None else self.depth_limit_attr.value() + config = { + "extensions": list(self.extensions), + "ignore_dirs": list(self.ignore_dirs), + "max_depth": max_depth + } + + self.scanner = FileScanner(config) + with self.results_lock: + self.current_scan_results = [] + self.pending_scan_results = [] + self.scanned_dirs_cache = [] + self.scanned_dirs_attr.set_value("[]") + self.last_update = 0 + + def progress_callback(results, info): + 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)) + + 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": + self.pending_scan_results.extend(results) + now = time.time() + if now - self.last_update > 5: + 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": + self.searching_attr.set_value(False) + + try: + results = self.scanner.scan(start_path, callback=progress_callback) + + if self.cancel_search: + return + + with self.results_lock: + self.current_scan_results = results + self.apply_filters() + + print(f"Search finished, found {len(results)} items") + + except Exception as e: + print(f"Search error: {e}") + import traceback + traceback.print_exc() + finally: + self.searching_attr.set_value(False) + + def compute_completions(self, partial_path): + """Minimal logic to find subdirectories matching partial path.""" + try: + # If empty, do nothing + if not partial_path: + self.completions_attr.set_value("[]") + return + + # Determine directory to scan + # Handle absolute paths vs relative correctly + if partial_path.endswith(os.path.sep): + directory = partial_path + base = "" + else: + directory = os.path.dirname(partial_path) + base = os.path.basename(partial_path) + + # If directory part is empty (e.g. user typed "home") + if not directory: + directory = "." + + if not os.path.exists(directory) or not os.path.isdir(directory): + self.completions_attr.set_value("[]") + return + + candidates = [] + try: + with os.scandir(directory) as it: + for entry in it: + if entry.name in self.ignore_dirs or entry.name.startswith('.'): + continue + + if entry.is_dir(): + # Filter by base case-insensitive + if entry.name.lower().startswith(base.lower()): + candidates.append(entry.path + os.path.sep) + except OSError: + pass + + # Sort and limit + candidates.sort() + self.completions_attr.set_value(json.dumps(candidates[:20])) + + except Exception as e: + print(f"Completion error: {e}") + self.completions_attr.set_value("[]") + + + + def load_config(self): + """Load configuration from config.json in the plugin directory.""" + config_path = os.path.join(os.path.dirname(__file__), "config.json") + default_config = { + "extensions": [".mov", ".mp4", ".mkv", ".exr", ".jpg", ".jpeg", ".png", + ".dpx", ".tiff", ".tif", ".wav", ".mp3"], + "ignore_dirs": [".git", ".quarantine", "eryx_unreal_plugin", ".DS_Store"], + "root_ignore_dirs": [], + "max_recursion_depth": 6, + "auto_scan_threshold": 4 + } + + if os.path.exists(config_path): + try: + with open(config_path, 'r') as f: + loaded_config = json.load(f) + # Merge with defaults + for key, value in loaded_config.items(): + default_config[key] = value + print(f"FilesystemBrowser: Loaded config from {config_path}") + except Exception as e: + print(f"FilesystemBrowser: Error loading config: {e}") + + return default_config + + 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): + dirs = [] + with os.scandir(path) as it: + for entry in it: + # Check ignore dirs (names) + if entry.name in self.ignore_dirs or entry.name.startswith('.'): + continue + + # Check root ignore dirs (paths) + if entry.path in self.root_ignore_dirs: + continue + + if entry.is_dir(): + dirs.append({ + "name": entry.name, + "path": entry.path + }) + # Sort alphabetically + dirs.sort(key=lambda x: x["name"].lower()) + result["dirs"] = dirs + except Exception as e: + print(f"Error getting subdirs for {path}: {e}") + + import time + result["timestamp"] = time.time() + + # Ensure we use JSON dumping + import json + self.directory_query_result.set_value(json.dumps(result)) + + def load_file(self, path): + """Handle directory navigation or delegating file loading to host interface.""" + if os.path.isdir(path): + self.current_path_attr.set_value(path) + self._add_to_history(path) + self.start_search(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: + print(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" + + print(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_str) + + 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()) + except: + current_history = [] + + # Remove if exists to bubble to top + try: + current_history.remove(path) + except ValueError: + pass + + current_history.insert(0, path) + # Limit history + if len(current_history) > 20: + current_history = current_history[:20] + + self.history_attr.set_value(json.dumps(current_history)) + + def _add_pin(self, name, path): + try: + pins = json.loads(self.pinned_attr.value()) + except: + pins = [] + + # Check if already pinned + for p in pins: + if p["path"] == path: + return # Already pinned + + pins.append({"name": name, "path": path}) + self.pinned_attr.set_value(json.dumps(pins)) + + def _remove_pin(self, path): + try: + pins = json.loads(self.pinned_attr.value()) + except: + pins = [] + + new_pins = [p for p in pins if p["path"] != path] + if len(new_pins) != len(pins): + self.pinned_attr.set_value(json.dumps(new_pins)) + + def _cleanup(self): + """atexit handler: shut down scanner thread pool and remove temp thumbnail dir.""" + if hasattr(self, 'scanner'): + try: + self.scanner.stop() + self.scanner.shutdown() + except Exception: + pass + try: + shutil.rmtree(self._temp_dir, ignore_errors=True) + except Exception: + pass + + def _request_thumbnail(self, path): + """Queue an async thumbnail fetch if not already cached or pending.""" + if path in self._thumbnail_cache: + # Already done — push the cached URI back to UI immediately + self._update_file_thumbnail(path, self._thumbnail_cache[path]) + return + with self._thumb_lock: + if path not in self._thumb_pending: + self._thumb_pending.add(path) + self._thumb_queue.put(path) + + def _thumb_worker_loop(self): + """Daemon worker pulling thumbnail requests from the queue.""" + while True: + path = self._thumb_queue.get() + try: + self._generate_thumbnail(path) + except Exception as e: + _dbg(f"WORKER_ERR: {e}") + finally: + with self._thumb_lock: + self._thumb_pending.discard(path) + self._thumb_queue.task_done() + + def _resolve_sequence_frame(self, path): + """Given a fileseq path string, return (concrete_file_path, frame_number). + For single files, returns (path, 0).""" + if not fileseq_available: + return path, 0 + try: + seq = fileseq.FileSequence(path) + frames = list(seq.frameSet()) + if len(frames) > 1: + mid = frames[len(frames) // 2] + return seq.frame(mid), mid + elif len(frames) == 1: + return seq.frame(frames[0]), frames[0] + except Exception: + pass + return path, 0 + + def _generate_thumbnail(self, path): + """Generate a thumbnail JPEG using ffmpeg subprocess.""" + if not self._ffmpeg_bin: + _dbg(f"GEN_SKIP (no ffmpeg): {path}") + return + + target_file, _frame = self._resolve_sequence_frame(path) + _dbg(f"GEN_START: {path} -> {target_file}") + + if not os.path.exists(target_file): + _dbg(f"GEN_MISSING: {target_file}") + return + + out_file = os.path.join(self._temp_dir, f"{_uuid.uuid4().hex}.jpg") + + env = os.environ.copy() + if self._ffmpeg_dyld: + existing = env.get("DYLD_LIBRARY_PATH", "") + env["DYLD_LIBRARY_PATH"] = ( + self._ffmpeg_dyld + (":" + existing if existing else "") + ) + + cmd = [ + self._ffmpeg_bin, + "-y", # overwrite output file + "-i", target_file, + "-vf", "scale=150:-1,format=rgb24", + "-frames:v", "1", + "-update", "1", # allow single-image output + out_file, + ] + + _dbg(f"GEN_CMD: {' '.join(cmd)}") + try: + result = subprocess.run( + cmd, env=env, + stdout=subprocess.DEVNULL, + stderr=subprocess.PIPE, + timeout=30 + ) + if result.returncode == 0 and os.path.exists(out_file): + thumb_uri = pathlib.Path(out_file).as_uri() + _dbg(f"GEN_OK: {thumb_uri}") + # LRU eviction: if cache is at capacity, remove the oldest entry + # and delete its temp file to reclaim disk space. + if len(self._thumbnail_cache) >= self._thumbnail_cache_max: + _, evicted_uri = self._thumbnail_cache.popitem(last=False) + try: + evicted_path = pathlib.Path(evicted_uri.replace("file://", "", 1)) + if evicted_path.exists() and evicted_path.parent.samefile(self._temp_dir): + evicted_path.unlink() + except Exception: + pass + self._thumbnail_cache[path] = thumb_uri + self._update_file_thumbnail(path, thumb_uri) + else: + stderr = result.stderr.decode("utf-8", errors="replace")[-500:] + _dbg(f"GEN_FAIL (rc={result.returncode}): {stderr}") + except subprocess.TimeoutExpired: + _dbg(f"GEN_TIMEOUT: {target_file}") + except Exception as exc: + _dbg(f"GEN_EXCEPTION: {exc}") + + def _update_file_thumbnail(self, path, thumb_uri): + """Update thumbnailSource in current_scan_results and push to files_attr + WITHOUT calling apply_filters() to avoid a full QML model rebuild.""" + with self.results_lock: + found = False + for r in self.current_scan_results: + if r.get("path") == path: + if r.get("thumbnailSource") == thumb_uri: + return # Already up to date; don't trigger another rebuild + r["thumbnailSource"] = thumb_uri + found = True + break + if not found: + return + # Serialise only what QML needs — same JSON format as apply_filters + serialised = json.dumps(self.current_scan_results) + + # Push the update; QML will merge thumbnailSource via the Image.source binding + self.files_attr.set_value(serialised) + +def create_plugin_instance(connection): + return FilesystemBrowserPlugin(connection) diff --git a/src/plugin/python_plugins/filesystem_browser/qml/FilesystemBrowser.1/DirectoryTree.qml b/src/plugin/python_plugins/filesystem_browser/qml/FilesystemBrowser.1/DirectoryTree.qml new file mode 100644 index 000000000..afb3f05d8 --- /dev/null +++ b/src/plugin/python_plugins/filesystem_browser/qml/FilesystemBrowser.1/DirectoryTree.qml @@ -0,0 +1,584 @@ +import QtQuick 2.15 +import QtQuick.Controls 2.15 +import QtQuick.Layouts 1.15 +import xStudio 1.0 + +Rectangle { + id: treeRoot + color: XsFileSystemStyle.backgroundColor + + + + // Properties to communicate with parent + property var pluginData: null + property var currentPath: "/" + property string baseRootPath: "/" + + signal sendCommand(var cmd) + onSendCommand: (cmd) => console.log("DirectoryTree: Sending command: " + JSON.stringify(cmd)) + + // Style constants to match FilesystemBrowser + property real rowHeight: XsFileSystemStyle.rowHeight + property color textColor: XsFileSystemStyle.textColor + property color hintColor: XsFileSystemStyle.hintColor + property real fontSize: XsFileSystemStyle.fontSize + property color selectionColor: XsFileSystemStyle.selectionColor + property color hoverColor: XsFileSystemStyle.hoverColor + property color backgroundColor: XsFileSystemStyle.backgroundColor + + // Auto-expand logic + property string pendingExpandPath: "" + property bool isSyncing: false + property int autoScanThreshold: 4 + + function getPathDepth(p) { + if (!p || p === "/") return 0; + var parts = p.split("/"); + var count = 0; + for(var i=0; i deepestLen || (np === "/" && deepestLen === 0)) { + deepestLen = np.length; + deepestIndex = i; + } + } + } + + if (deepestIndex !== -1) { + var node = treeModel.get(deepestIndex); + + if (node.path === pendingExpandPath) { + // We reached the target! + treeView.currentIndex = deepestIndex; + pendingExpandPath = ""; + isSyncing = false; + // Ensure visible + treeView.positionViewAtIndex(deepestIndex, ListView.Center); + // Also expand to show children as requested + if (!node.expanded) expandNode(deepestIndex); + } else { + // We need to go deeper. Expand this node if not expanded. + if (!node.expanded) { + expandNode(deepestIndex); + // wait for handleQueryResult to call us back + } else { + // It is expanded, but maybe children are not loaded yet? + // Or maybe we just expanded it and are waiting? + if (node.isLoading) { + // Waiting + } else { + // Children present but we didn't find a better match? + // This implies the next segment of path doesn't exist in the tree. + // Stop here. + pendingExpandPath = ""; + isSyncing = false; + } + } + } + } else { + // Should not happen if Root is present + isSyncing = false; + } + } + + // Attribute for directory query results + XsAttributeValue { + id: dir_query_attr + attributeTitle: "directory_query_result" + model: pluginData + role: "value" + + onValueChanged: { + try { + var val = value; + if (val && val !== "{}") { + var result = JSON.parse(val); + handleQueryResult(result); + } + } catch(e) { + console.log("DirectoryTree: Query result parse error: " + e); + } + } + } + + // Tree Model + // We'll use a ListModel and manually manage hierarchical indentation + ListModel { + id: treeModel + } + + onBaseRootPathChanged: { + treeModel.clear(); + var rootName = baseRootPath === "/" ? "Root" : (baseRootPath.split("/").pop() || baseRootPath); + treeModel.append({ + "name": rootName, + "path": baseRootPath, + "level": 0, + "expanded": false, + "hasChildren": true, + "isLoading": false + }); + expandNode(0); + + if (currentPath && currentPath.indexOf(baseRootPath) === 0 && currentPath !== baseRootPath) { + pendingExpandPath = currentPath; + isSyncing = true; + syncToPath(); + } + } + + Component.onCompleted: { + // Init with root + var rootName = baseRootPath === "/" ? "Root" : (baseRootPath.split("/").pop() || baseRootPath); + treeModel.append({ + "name": rootName, + "path": baseRootPath, + "level": 0, + "expanded": false, + "hasChildren": true, // Assume root has children + "isLoading": false + }); + // Immediately expand root + expandNode(0); + + if (currentPath && currentPath.indexOf(baseRootPath) === 0 && currentPath !== baseRootPath) { + pendingExpandPath = currentPath; + isSyncing = true; + syncToPath(); + } + } + + function expandNode(index) { + var node = treeModel.get(index); + + // If already expanded and not loading, we might still need to load if it has no children but should + // However, for now let's just allow re-requesting if isLoading is false or if explicitly called when collapsed + if (node.expanded && !node.isLoading) { + // Check if children already exist + var nextIndex = index + 1; + if (nextIndex < treeModel.count) { + var next = treeModel.get(nextIndex); + if (next.level > node.level) { + return; + } + } + // No children? Trigger load anyway + } else if (node.expanded) { + return; + } + + treeModel.setProperty(index, "expanded", true); + + if (node.isLoading) { + return; + } + + treeModel.setProperty(index, "isLoading", true); + + // Request subdirs + sendCommand({"action": "get_subdirs", "path": node.path}); + } + + function collapseNode(index) { + var node = treeModel.get(index); + + treeModel.setProperty(index, "expanded", false); + treeModel.setProperty(index, "isLoading", false); // Important: stop loading if collapsed + + // Remove children from model + // We need to remove all items following this node that have a level > node.level + // AND stop when we hit a node with level <= node.level + var currentLevel = node.level; + var i = index + 1; + var count = 0; + + while (i < treeModel.count) { + var child = treeModel.get(i); + if (child.level > currentLevel) { + count++; + i++; + } else { + break; + } + } + + if (count > 0) { + treeModel.remove(index + 1, count); + } + } + + function handleQueryResult(result) { + // Find which node requested this? + // We scan the model to find the node with matching path and isLoading=true + // Or just matching path and expanded=true but maybe no children yet? + + var path = result.path; + var dirs = result.dirs; + + var foundIndex = -1; + for(var i=0; i parentLevel) { + collapseNode(foundIndex); + treeModel.setProperty(foundIndex, "expanded", true); + } + } + + // Insert children + for(var j=0; j { + if (mouse.button === Qt.LeftButton) { + sendCommand({"action": "change_path", "path": model.path}); + } else if (mouse.button === Qt.RightButton) { + treeContextMenu.popup(); + } + } + } + + Menu { + id: treeContextMenu + background: Rectangle { + implicitWidth: 150 + implicitHeight: 75 + color: XsFileSystemStyle.panelBgColor + border.color: XsFileSystemStyle.borderColor + radius: 3 + } + + MenuItem { + id: setRootItem + text: "Set as Root" + onTriggered: { + treeRoot.baseRootPath = model.path; + } + + contentItem: Text { + text: setRootItem.text + color: "#e0e0e0" + font.pixelSize: 12 + horizontalAlignment: Text.AlignLeft + verticalAlignment: Text.AlignVCenter + leftPadding: 10 + } + + background: Rectangle { + implicitWidth: 150 + implicitHeight: 25 + color: setRootItem.highlighted ? "#555555" : "transparent" + } + } + + MenuItem { + id: revealItem + text: "Show in Finder" + onTriggered: { + // Using the same action name as the file context menu + treeRoot.sendCommand({"action": "reveal_in_finder", "path": model.path}) + } + + contentItem: Text { + text: revealItem.text + color: "#e0e0e0" + font.pixelSize: 12 + horizontalAlignment: Text.AlignLeft + verticalAlignment: Text.AlignVCenter + leftPadding: 10 + } + + background: Rectangle { + implicitWidth: 150 + implicitHeight: 25 + color: revealItem.highlighted ? "#555555" : "transparent" + } + } + } + + RowLayout { + anchors.fill: parent + spacing: 0 + + // Indentation + Item { + Layout.preferredWidth: model.level * 20 + 5 + Layout.fillHeight: true + } + + // Expander Arrow + Item { + Layout.preferredWidth: 20 + Layout.fillHeight: true + + Text { + anchors.centerIn: parent + text: model.hasChildren ? (model.expanded ? "▼" : "▶") : "" + color: treeRoot.hintColor + font.pixelSize: 10 + } + + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: { + if (model.expanded) { + collapseNode(index); + } else { + expandNode(index); + } + } + } + } + + // Folder Icon + Item { + Layout.preferredWidth: 20 + Layout.fillHeight: true + Text { + anchors.centerIn: parent + text: "📁" + font.pixelSize: treeRoot.fontSize + } + } + + // Name + Text { + text: model.name + color: treeRoot.textColor + font.pixelSize: treeRoot.fontSize + Layout.fillWidth: true + Layout.fillHeight: true + verticalAlignment: Text.AlignVCenter + elide: Text.ElideRight + leftPadding: 5 + } + + // Scan Button Container (Prevents layout jitter by taking space only if scan is possible) + Item { + Layout.preferredWidth: 56 + Layout.fillHeight: true + visible: treeRoot.getPathDepth(model.path) <= treeRoot.autoScanThreshold && !model.isLoading + + Rectangle { + anchors.centerIn: parent + visible: msgMouse.containsMouse || scanMouse.containsMouse + width: 46 + height: 18 + color: scanMouse.containsMouse ? XsFileSystemStyle.pressedColor : XsFileSystemStyle.panelBgColor + radius: 4 + border.color: XsFileSystemStyle.borderColor + border.width: 1 + + Text { + anchors.centerIn: parent + text: "SCAN" + color: XsFileSystemStyle.textColor + font.pixelSize: 8 + font.bold: true + } + + MouseArea { + id: scanMouse + anchors.fill: parent + hoverEnabled: true + onClicked: { + // Set path AND trigger scan in one atomic action + sendCommand({"action": "force_scan", "path": model.path}) + } + } + + ToolTip.visible: scanMouse.containsMouse + ToolTip.text: "Force media scan in this folder" + ToolTip.delay: 500 + } + } + + // Loading Indicator + Text { + text: "..." + color: treeRoot.hintColor + visible: model.isLoading + Layout.rightMargin: 5 + } + } + + + } + + ScrollBar.vertical: ScrollBar { + active: true + policy: ScrollBar.AsNeeded + width: 10 + background: Rectangle { color: XsFileSystemStyle.backgroundColor } + contentItem: Rectangle { + implicitWidth: 6 + implicitHeight: 100 + radius: 3 + color: treeView.active ? XsFileSystemStyle.hintColor : XsFileSystemStyle.secondaryTextColor + } + } + } + } +} diff --git a/src/plugin/python_plugins/filesystem_browser/qml/FilesystemBrowser.1/FilesystemBrowser.qml b/src/plugin/python_plugins/filesystem_browser/qml/FilesystemBrowser.1/FilesystemBrowser.qml new file mode 100644 index 000000000..5358a7fa5 --- /dev/null +++ b/src/plugin/python_plugins/filesystem_browser/qml/FilesystemBrowser.1/FilesystemBrowser.qml @@ -0,0 +1,2295 @@ +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 1.0 +import xstudio.qml.models 1.0 + +Rectangle { + id: root + color: XsFileSystemStyle.backgroundColor + anchors.fill: parent // Ensure it fills the panel + + + + // Access the attributes exposed by the plugin + property string currentFilterTime: "Any" + property string currentFilterVersion: "All Versions" + + XsModuleData { + id: pluginData + modelDataName: "Filesystem Browser" + } + + // Reusable Styled MenuItem component for consistent dark theme + component StyledItem : MenuItem { + id: inner + contentItem: Text { + text: inner.text + color: "#e0e0e0" + font.pixelSize: 12 + horizontalAlignment: Text.AlignLeft + verticalAlignment: Text.AlignVCenter + elide: Text.ElideRight + leftPadding: 10 + } + + background: Rectangle { + implicitWidth: 150 + implicitHeight: 25 + color: inner.highlighted ? "#555555" : "transparent" + } + } + + // Reusable Context Menu for files in both Table and Icon views + component FileContextMenu : Menu { + property string itemPath: "" + + background: Rectangle { + implicitWidth: 150 + implicitHeight: 100 // 4 items (25 each) + color: XsFileSystemStyle.panelBgColor + border.color: XsFileSystemStyle.borderColor + radius: 3 + } + + StyledItem { + text: "Replace" + onTriggered: sendCommand({"action": "replace_current_media", "path": itemPath}) + } + StyledItem { + text: "Compare with" + onTriggered: sendCommand({"action": "compare_with_current_media", "path": itemPath}) + } + StyledItem { + text: "Append to Playlist" + onTriggered: sendCommand({"action": "append_media", "path": itemPath}) + } + StyledItem { + text: "Copy Path" + onTriggered: sendCommand({"action": "copy_path", "path": itemPath}) + } + StyledItem { + text: "Show in Finder" + onTriggered: sendCommand({"action": "reveal_in_finder", "path": itemPath}) + } + } + + // State for Preview Mode + property bool isPreviewMode: false + property string pendingPreviewPath: "" + + Timer { + id: previewTimer + interval: 200 // Wait for double click + repeat: false + onTriggered: { + if (pendingPreviewPath !== "") { + isPreviewMode = true + sendCommand({"action": "preview_file", "path": pendingPreviewPath}) + pendingPreviewPath = "" + } + } + } + + // 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: [] + + 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 0 && pathField.activeFocus) { + completionPopup.open() + } else { + completionPopup.close() + } + } catch(e) { + completionList = [] + } + } + } + } + + XsAttributeValue { + id: scan_required_attr + attributeTitle: "scan_required" + model: pluginData + role: "value" + } + + // Dedicated attribute for sending batch thumbnail requests to Python. + // We write a JSON array of paths; Python queues them all into the ffmpeg worker pool. + XsAttributeValue { + id: thumbnail_request_attr + attributeTitle: "thumbnail_request" + model: pluginData + role: "value" + } + + XsAttributeValue { + id: auto_scan_threshold_attr + attributeTitle: "auto_scan_threshold" + model: pluginData + role: "value" + } + + XsAttributeValue { + id: searching_attr + attributeTitle: "searching" + model: pluginData + } + + XsAttributeValue { + id: scanned_dirs_attr + attributeTitle: "scanned_dirs" + model: pluginData + role: "value" + onValueChanged: { + try { + var val = value + if (val && val !== "[]") { + scannedDirsList = JSON.parse(val) + } else { + scannedDirsList = [] + } + } catch(e) { } + } + } + + XsAttributeValue { + id: depth_limit_attr + attributeTitle: "recursion_limit" + model: pluginData + role: "value" + } + + property var scannedDirsList: [] + + function sendCommand(cmd) { + command_attr.value = JSON.stringify(cmd) + } + + // Local property to hold the parsed JSON file list + property var fileList: [] + onFileListChanged: buildTree() + property var completionList: [] + + // Sorting State + property string sortColumn: "name" + property int sortOrder: 1 // 1 for asc, -1 for desc + + // View Mode: 0=List, 1=Tree, 2=Grouped + property int viewMode: 2 + onViewModeChanged: buildTree() + + + // Column Widths (Default values) + property real minWidthName: 250 + property real colWidthName: 250 // kept for legacy reference or init + property real colWidthOwner: 80 + property real colWidthVersion: 60 + property real colWidthDate: 140 + property real colWidthSize: 80 + property real colWidthFrames: 120 + + // Width Calculations + readonly property real fixedColumnsWidth: colWidthVersion + colWidthOwner + colWidthDate + colWidthSize + colWidthFrames + 20 // +20 spacer + property real totalContentWidth: Math.max(fileListView.width, minWidthName + fixedColumnsWidth + 10) // +10 margin/padding + + + // tree logic + property var treeRoots: [] + property var visibleTreeList: [] + property var collapsedPaths: ({}) + // Flat list of *file* items only — used for thumbnail request calculations. + property var thumbnailFileList: [] + // Complete mixed flat model (all groups + files). Not assigned to Repeater directly. + property var fullFlatModel: [] + // Paginated slice of fullFlatModel actually shown in the Repeater. + property var flatThumbnailModel: [] + // How many items from fullFlatModel are currently rendered. + property int thumbRenderCount: 0 + readonly property int thumbPageSize: 150 // initial page + readonly property int thumbPageStep: 100 // items added per scroll-load + onThumbnailFileListChanged: { + if (root.viewMode === 3) + Qt.callLater(requestVisibleThumbnails) + } + // Re-request thumbnails when the rendered page extends + onFlatThumbnailModelChanged: { + if (root.viewMode === 3) + Qt.callLater(requestVisibleThumbnails) + } + + // Only request thumbnails for items currently visible in the Flow. + // Estimates Y positions mathematically from scroll position, cell size, and Flow width. + function requestVisibleThumbnails() { + if (root.viewMode !== 3 || flatThumbnailModel.length === 0) return + + var scrollY = thumbFlickable.contentY + var viewH = thumbFlickable.height + var cellW = 160 + var headerH = 24 + var cellH = 160 + var cols = Math.max(1, Math.floor(Math.max(1, thumbFlickable.width) / cellW)) + + // One cell row above/below as prefetch buffer + var topY = Math.max(0, scrollY - cellH) + var bottomY = scrollY + viewH + cellH + + var pending = [] + var y = 0 + var col = 0 + + for (var i = 0; i < flatThumbnailModel.length; i++) { + var item = flatThumbnailModel[i] + if (item.type === "header") { + if (col > 0) { y += cellH; col = 0 } + y += headerH + } else { + if (col >= cols) { col = 0; y += cellH } + if (y + cellH >= topY && y <= bottomY) { + if (!item.thumbnailSource) pending.push(item.path) + } + col++ + } + } + + if (pending.length > 0) { + console.log("QML: requesting " + pending.length + " visible thumbnails") + thumbnail_request_attr.value = JSON.stringify(pending) + } + } + + function isVisible(data) { + if (!data) return true; + + // Text Filter + var filterText = filterField.text.trim(); + if (filterText !== "") { + if (data.name.toLowerCase().indexOf(filterText.toLowerCase()) === -1) return false; + } + + // Time Filter + var t_val = currentFilterTime; + if (t_val !== "Any" && data.date) { + var now = Date.now() / 1000.0; + var diff = now - data.date; + var day = 86400; + var timeMatch = true; + if (t_val === "Last 1 day") timeMatch = diff <= day; + else if (t_val === "Last 2 days") timeMatch = diff <= 2*day; + else if (t_val === "Last 1 week") timeMatch = diff <= 7*day; + else if (t_val === "Last 1 month") timeMatch = diff <= 30*day; + + if (!timeMatch) return false; + } + + // Version Filter + var v_val = currentFilterVersion; + if (v_val === "Latest Version") { + if (data.is_latest_version !== true) return false; + } else if (v_val === "Latest 2 Versions") { + if (data.version_rank !== undefined && data.version_rank > 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 + }) + thumbnailFileList = thumbList + + // 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 }) + } + + // Paginate: only render the first page to avoid freezing on large dirs + fullFlatModel = flat + thumbRenderCount = Math.min(thumbPageSize, flat.length) + flatThumbnailModel = flat.slice(0, thumbRenderCount) + 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 = [] + function traverse(nodes, depth) { + for(var i=0; i root.sendCommand(cmd) + + property int autoScanThreshold: auto_scan_threshold_attr.value || 4 + } + } + } + + // Main Content Side + ColumnLayout { + SplitView.fillWidth: true + anchors.margins: 10 + spacing: 5 + + // Path Input Row + RowLayout { + Layout.fillWidth: true + Layout.preferredHeight: rowHeight + spacing: 5 + + Text { + text: "Path:" + color: textColor + verticalAlignment: Text.AlignVCenter + } + + TextField { + id: pathField + Layout.fillWidth: true + Layout.preferredHeight: rowHeight + text: current_path_attr.value || "/" + color: textColor + font.pixelSize: fontSize + selectionColor: XsFileSystemStyle.selectionColor + selectedTextColor: XsFileSystemStyle.backgroundColor + onTextChanged: { + // This ensures that even if user is typing, a programmatic update + // to current_path_attr.value (e.g. from SCAN button) can force a refresh if needed. + // However, standard QML binding `text: ...` usually breaks if user edits. + // We'll add a listener to the attribute to force it back if it changes externally. + } + + Connections { + target: current_path_attr + function onValueChanged() { + pathField.text = current_path_attr.value || "/" + } + } + + background: Rectangle { + color: XsFileSystemStyle.panelBgColor + border.color: XsFileSystemStyle.borderColor + border.width: 1 + } + focus: true + selectByMouse: true + + TapHandler { + acceptedButtons: Qt.RightButton + onTapped: pathContextMenu.popup() + } + + Menu { + id: pathContextMenu + + background: Rectangle { + implicitWidth: 120 + implicitHeight: 75 // 3 items + color: XsFileSystemStyle.panelBgColor + border.color: XsFileSystemStyle.borderColor + radius: 3 + } + + delegate: StyledItem {} // Using the shared component from the top! + + StyledItem { + text: "Copy" + onTriggered: pathField.copy() + } + StyledItem { + text: "Paste" + onTriggered: pathField.paste() + } + StyledItem { + text: "Clear" + onTriggered: pathField.clear() + } + } + + onAccepted: { + sendCommand({"action": "change_path", "path": text}) + } + + onTextEdited: { + sendCommand({"action": "complete_path", "path": text}) + } + + // Keys handling for completion (omitted for brevity, assume similar to before) + // Keys handling for completion + Keys.priority: Keys.BeforeItem + Keys.onPressed: (event) => { + // TAB + if (event.key === Qt.Key_Tab) { + event.accepted = true; + + var hasCompleted = false; + + // 1. Try Single Match or Common Prefix Completion first + if (completionList.length > 0) { + var prefix = getCommonPrefix(completionList); + // If we have a single match, prefix is the match itself. + + // If the calculated prefix is longer than what we currently have, utilize it. + // This covers both "Single Match" and "Partial Shell Completion" + if (prefix.length > text.length) { + text = prefix; + hasCompleted = true; + sendCommand({"action": "complete_path", "path": text}); + } + } + + // 2. If we didn't extend the text (ambiguous state), then Cycle through the list + if (!hasCompleted && completionPopup.opened && completionListView.count > 0) { + if (event.modifiers & Qt.ShiftModifier) { + completionListView.currentIndex = (completionListView.currentIndex - 1 + completionListView.count) % completionListView.count; + } else { + completionListView.currentIndex = (completionListView.currentIndex + 1) % completionListView.count; + } + } + } + // UP / DOWN + else if (event.key === Qt.Key_Up) { + if (completionPopup.opened && completionList.length > 0) { + event.accepted = true; + completionListView.decrementCurrentIndex(); + } + } + else if (event.key === Qt.Key_Down) { + if (completionPopup.opened && completionList.length > 0) { + event.accepted = true; + completionListView.incrementCurrentIndex(); + } + } + // RIGHT + else if (event.key === Qt.Key_Right) { + if (completionPopup.opened && completionListView.currentItem) { + // Drill Down + event.accepted = true; + text = completionList[completionListView.currentIndex]; + // Reset selection + completionListView.currentIndex = 0; + } + } + // ENTER / RETURN + else if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) { + event.accepted = true; + // Always submit the current text, regardless of completion popup + sendCommand({"action": "change_path", "path": text}); + completionPopup.close(); + } + // ESC + else if (event.key === Qt.Key_Escape) { + event.accepted = true; + completionPopup.close(); + } + // CTRL+BACKSPACE + else if (event.key === Qt.Key_Backspace) { + if (event.modifiers & Qt.ControlModifier || event.modifiers & Qt.AltModifier) { + event.accepted = true; + // Directory Delete + var txt = text; + if (txt.endsWith("/")) txt = txt.slice(0, -1); + var lastSlash = txt.lastIndexOf("/"); + if (lastSlash !== -1) { + text = txt.substring(0, lastSlash + 1); + } else { + text = ""; + } + } + } + } + + // Keep completion popup + Popup { + id: completionPopup + width: parent.width + height: 200 + y: parent.height + 2 // Offset slightly + background: Rectangle { color: XsFileSystemStyle.panelBgColor; border.color: XsFileSystemStyle.borderColor } + contentItem: ListView { + id: completionListView + model: completionList + clip: true + highlight: Rectangle { color: XsFileSystemStyle.hoverColor } + highlightMoveDuration: 0 + delegate: Item { + width: parent.width + height: 25 + Rectangle { anchors.fill: parent; color: "transparent" } + Text { + text: modelData + color: "#ffffff" + anchors.fill: parent + verticalAlignment: Text.AlignVCenter + leftPadding: 5 + font.pixelSize: fontSize + } + MouseArea { + anchors.fill: parent + hoverEnabled: true + onClicked: { + pathField.text = modelData + completionPopup.close() + pathField.forceActiveFocus() + sendCommand({"action": "complete_path", "path": pathField.text}) + } + } + } + } + } + } + + Button { + id: refreshBtn + Layout.preferredHeight: rowHeight + Layout.preferredWidth: rowHeight + text: "↻" + font.pixelSize: 16 + flat: true + onClicked: sendCommand({"action": "force_scan"}) + ToolTip.visible: hovered + ToolTip.text: "Refresh directory scan" + + background: Rectangle { + color: parent.down ? XsFileSystemStyle.pressedColor : (parent.hovered ? XsFileSystemStyle.hoverColor : "transparent") + } + contentItem: Text { + text: parent.text + color: "white" + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + } + } + + Button { + id: historyBtn + Layout.preferredHeight: rowHeight + Layout.preferredWidth: rowHeight + + // Using a down arrow character for simplicity if icon not available, + // but user asked for "Down Triangle". + text: "▼" + font.pixelSize: 10 + + contentItem: Text { + text: parent.text + font: parent.font + color: "#e0e0e0" + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + } + + background: Rectangle { + color: parent.down || pathPopup.opened ? XsFileSystemStyle.pressedColor : (parent.hovered ? XsFileSystemStyle.hoverColor : "transparent") + border.width: 0 + } + + property var lastCloseTime: 0 + + onClicked: { + var timeSinceClose = Date.now() - lastCloseTime + if (timeSinceClose > 100) { + pathPopup.open() + } + } + + Popup { + id: pathPopup + y: parent.height + x: parent.width - width // Right align with button + width: 500 + height: 300 + padding: 0 + + onClosed: { + historyBtn.lastCloseTime = Date.now() + } + + closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside + + background: Rectangle { + color: XsFileSystemStyle.headerBgColor + border.color: XsFileSystemStyle.borderColor + } + + ColumnLayout { + anchors.fill: parent + anchors.margins: 1 + spacing: 0 + + // Header + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: 25 + color: XsFileSystemStyle.panelBgColor + Label { + text: "QUICK ACCESS" + color: "#ffffff" + font.pixelSize: fontSize + font.bold: true + anchors.centerIn: parent + } + } + + ListView { + id: combinedView + Layout.fillWidth: true + Layout.fillHeight: true + clip: true + model: combinedList + + delegate: Rectangle { + width: ListView.view.width + height: 25 + color: mouseArea.containsMouse ? XsFileSystemStyle.hoverColor : "transparent" + + MouseArea { + id: mouseArea + anchors.fill: parent + hoverEnabled: true + onClicked: { + sendCommand({"action": "change_path", "path": modelData.path}) + pathPopup.close() + } + } + + RowLayout { + anchors.fill: parent + spacing: 5 + + // Pin Toggle Button + Button { + Layout.preferredWidth: 25 + Layout.preferredHeight: 25 + + background: Rectangle { + color: "transparent" + } + + contentItem: Shape { + anchors.centerIn: parent + width: 14 + height: 14 + + // Scale the 24x24 SVG path to our 14x14 box + scale: 14/24.0 + transformOrigin: Item.Center + + ShapePath { + strokeWidth: 0 + strokeColor: "transparent" + fillColor: modelData.isPinned ? "#ffffff" : "#444444" + + // M16 12V4h1V2H7v2h1v8l-2 5v2h6v3.5l1 1 1-1V19h6v-2l-2-5z (Standard Pin) + // Coordinate system is roughly 24x24 + PathSvg { + path: "M16 12V4h1V2H7v2h1v8l-2 5v2h6v3.5l1 1 1-1V19h6v-2l-2-5z" + } + } + } + + onClicked: { + if (modelData.isPinned) { + sendCommand({"action": "remove_pin", "path": modelData.path}) + } else { + sendCommand({"action": "add_pin", "name": modelData.name, "path": modelData.path}) + } + } + } + + // Path Name + Text { + text: modelData.name + color: "#ffffff" + font.pixelSize: fontSize + Layout.fillWidth: true + elide: Text.ElideMiddle + verticalAlignment: Text.AlignVCenter + } + + // Path Hint (Right aligned, faded) + Text { + text: modelData.path + color: "#ffffff" + font.pixelSize: fontSize + Layout.preferredWidth: parent.width * 0.4 + elide: Text.ElideRight + verticalAlignment: Text.AlignVCenter + visible: parent.width > 300 + } + + Item { Layout.preferredWidth: 5 } + } + } + ScrollBar.vertical: ScrollBar {} + } + } + } + } + } + + + + // Filter Row + RowLayout { + Layout.fillWidth: true + Layout.preferredHeight: rowHeight + spacing: 5 + + ComboBox { + id: filterTimeCombo + ToolTip.visible: hovered + ToolTip.text: "Filter files by modification time" + + contentItem: Text { + text: filterTimeCombo.displayText + font.pixelSize: XsFileSystemStyle.fontSize + color: XsFileSystemStyle.textColor + verticalAlignment: Text.AlignVCenter + leftPadding: 10 + } + + background: Rectangle { + implicitWidth: 120 + implicitHeight: rowHeight + color: XsFileSystemStyle.panelBgColor + border.color: XsFileSystemStyle.borderColor + radius: 2 + } + model: ["Any", "Last 1 day", "Last 2 days", "Last 1 week", "Last 1 month"] + Layout.preferredWidth: 120 + Layout.preferredHeight: rowHeight + currentIndex: model.indexOf(currentFilterTime) + onActivated: { + console.log("Time Filter Changed to: " + currentText) + // Send command to update backend + sendCommand({"action": "set_attribute", "name": "filter_time", "value": currentText}) + // Optimistically update local state (backend update will confirm it via onValueChanged) + currentFilterTime = currentText + fileListView.forceLayout() + } + delegate: ItemDelegate { + width: ListView.view.width + contentItem: Text { + text: modelData + color: XsFileSystemStyle.textColor + font.pixelSize: XsFileSystemStyle.fontSize + elide: Text.ElideRight + verticalAlignment: Text.AlignVCenter + } + background: Rectangle { + color: parent.highlighted ? XsFileSystemStyle.hoverColor : XsFileSystemStyle.backgroundColor + } + highlighted: filterTimeCombo.highlightedIndex === index + } + + popup: Popup { + y: parent.height - 1 + width: parent.width + implicitHeight: contentItem.implicitHeight + padding: 1 + + contentItem: ListView { + clip: true + implicitHeight: contentHeight + model: filterTimeCombo.popup.visible ? filterTimeCombo.delegateModel : null + currentIndex: filterTimeCombo.highlightedIndex + ScrollIndicator.vertical: ScrollIndicator { } + } + + background: Rectangle { + border.color: XsFileSystemStyle.borderColor + color: XsFileSystemStyle.backgroundColor + } + } + } + + ComboBox { + id: filterVersionCombo + ToolTip.visible: hovered + ToolTip.text: "Filter files by version (e.g. v001, v002)" + + contentItem: Text { + text: filterVersionCombo.displayText + font.pixelSize: XsFileSystemStyle.fontSize + color: XsFileSystemStyle.textColor + verticalAlignment: Text.AlignVCenter + leftPadding: 10 + } + + background: Rectangle { + implicitWidth: 140 + implicitHeight: rowHeight + color: XsFileSystemStyle.panelBgColor + border.color: XsFileSystemStyle.borderColor + radius: 2 + } + model: ["All Versions", "Latest Version", "Latest 2 Versions"] + Layout.preferredWidth: 140 + Layout.preferredHeight: rowHeight + currentIndex: model.indexOf(currentFilterVersion) + onActivated: { + sendCommand({"action": "set_attribute", "name": "filter_version", "value": currentText}) + currentFilterVersion = currentText + fileListView.forceLayout() + } + delegate: ItemDelegate { + width: ListView.view.width + contentItem: Text { + text: modelData + color: XsFileSystemStyle.textColor + font.pixelSize: XsFileSystemStyle.fontSize + elide: Text.ElideRight + verticalAlignment: Text.AlignVCenter + } + background: Rectangle { + color: parent.highlighted ? XsFileSystemStyle.hoverColor : XsFileSystemStyle.backgroundColor + } + highlighted: filterVersionCombo.highlightedIndex === index + } + + popup: Popup { + y: parent.height - 1 + width: parent.width + implicitHeight: contentItem.implicitHeight + padding: 1 + + contentItem: ListView { + clip: true + implicitHeight: contentHeight + model: filterVersionCombo.popup.visible ? filterVersionCombo.delegateModel : null + currentIndex: filterVersionCombo.highlightedIndex + ScrollIndicator.vertical: ScrollIndicator { } + } + + background: Rectangle { + border.color: XsFileSystemStyle.borderColor + color: XsFileSystemStyle.backgroundColor + } + } + } + + + + // Text Filter + TextField { + id: filterField + Layout.fillWidth: true + Layout.preferredHeight: rowHeight + placeholderText: "Filter String..." + placeholderTextColor: XsFileSystemStyle.hintColor + color: XsFileSystemStyle.textColor + font.pixelSize: XsFileSystemStyle.fontSize + leftPadding: 5 + background: Rectangle { color: XsFileSystemStyle.panelBgColor; border.color: XsFileSystemStyle.borderColor } + onTextEdited: refreshFiltering() + } + } + + + + // Table Header + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: rowHeight + color: XsFileSystemStyle.headerBgColor + + Item { + anchors.fill: parent + clip: true + RowLayout { + x: -fileListView.contentX + width: Math.max(parent.width, totalContentWidth) + height: parent.height + spacing: 0 + + // Helper to create columns + component HeaderColumn: Rectangle { + property string title + property string colId + property alias colWidth: rect.width + property bool resizable: true + id: rect + Layout.fillHeight: true + color: "transparent" + Layout.preferredWidth: width + Text { + text: title + (sortColumn === colId ? (sortOrder === 1 ? " ▲" : " ▼") : "") + anchors.fill: parent + verticalAlignment: Text.AlignVCenter + leftPadding: 5 + color: root.XsFileSystemStyle.textColor + font.pixelSize: root.XsFileSystemStyle.fontSize + font.weight: Font.DemiBold + elide: Text.ElideRight + } + MouseArea { + anchors.fill: parent + onClicked: sortFiles(colId) + cursorShape: Qt.PointingHandCursor + } + Rectangle { + visible: resizable + width: 5; height: parent.height + anchors.right: parent.right + color: "transparent" + MouseArea { + anchors.fill: parent; cursorShape: Qt.SplitHCursor + drag.target: rect; drag.axis: Drag.XAxis + property real startX + onPressed: startX = mouseX + onPositionChanged: if(pressed) { var d=mouseX-startX; if(rect.width+d>30) rect.width+=d } + } + } + } + + HeaderColumn { title: "Name"; colId: "name"; Layout.fillWidth: true; Layout.minimumWidth: minWidthName; resizable: false } + HeaderColumn { title: "Version"; colId: "version"; width: colWidthVersion; onWidthChanged: colWidthVersion=width } + HeaderColumn { title: "Frames"; colId: "frames"; width: colWidthFrames; onWidthChanged: colWidthFrames=width } + HeaderColumn { title: "Owner"; colId: "owner"; width: colWidthOwner; onWidthChanged: colWidthOwner=width } + HeaderColumn { title: "Date"; colId: "date"; width: colWidthDate; onWidthChanged: colWidthDate=width } + HeaderColumn { title: "Size"; colId: "size_str"; width: colWidthSize; onWidthChanged: colWidthSize=width } + Item { width: 20 } // Spacer at end + } + } + } + + // File List + Item { + Layout.fillWidth: true + Layout.fillHeight: true + + Rectangle { anchors.fill: parent; color: XsFileSystemStyle.backgroundColor } + + ListView { + id: fileListView + anchors.fill: parent + anchors.rightMargin: 12 + visible: root.viewMode !== 3 + focus: visible + onVisibleChanged: { if (visible) forceActiveFocus() } + + 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 { + isPreviewMode = false + sendCommand({"action": "load_file", "path": md.path}) + } + } + } + event.accepted = true + } + + onCurrentIndexChanged: { + if (activeFocus && currentItem) { + if (!currentItem.isItemFolder) { + root.pendingPreviewPath = currentItem.itemPath + previewTimer.restart() + } + } + } + + // Nothing found message + Text { + anchors.centerIn: parent + text: "Nothing found" + color: XsFileSystemStyle.hintColor + font.pixelSize: XsFileSystemStyle.fontSize + 6 + visible: fileListView.count === 0 && !searching_attr.value && !scan_required_attr.value + } + clip: true + model: visibleTreeList + + contentWidth: totalContentWidth + flickableDirection: Flickable.HorizontalAndVerticalFlick + boundsBehavior: Flickable.StopAtBounds + + // Manual Scan Overlay + Rectangle { + anchors.centerIn: parent + width: 200 + height: 100 + color: "#333333" + visible: scan_required_attr.value === true + z: 100 // Ensure it's on top + + ColumnLayout { + anchors.centerIn: parent + spacing: 10 + + Text { + text: "Manual Scan Required" + color: "#aaaaaa" + font.pixelSize: 14 + Layout.alignment: Qt.AlignHCenter + } + + Button { + text: "Scan Directory" + Layout.alignment: Qt.AlignHCenter + onClicked: sendCommand({"action": "force_scan"}) + + background: Rectangle { + color: parent.down ? "#444444" : "#555555" + radius: 3 + } + contentItem: Text { + text: parent.text + color: "white" + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + font.pixelSize: 12 + } + } + } + } + + delegate: Rectangle { + id: delegate + width: totalContentWidth + height: rowHeight + + property bool isSelected: ListView.isCurrentItem + property bool isHovered: false + property string itemPath: modelData.path + property bool isItemFolder: modelData.isFolder + + Rectangle { + anchors.fill: parent + color: isSelected ? XsFileSystemStyle.selectionColor : (isHovered ? XsFileSystemStyle.hoverColor : (index % 2 == 0 ? XsFileSystemStyle.backgroundColor : XsFileSystemStyle.alternateBgColor)) + } + + MouseArea { + anchors.fill: parent + hoverEnabled: true + onEntered: isHovered = true + onExited: isHovered = false + acceptedButtons: Qt.LeftButton | Qt.RightButton + onClicked: (mouse) => { + fileListView.currentIndex = index + fileListView.forceActiveFocus() + if (mouse.button === Qt.RightButton) { + contextMenu.popup() + } else if (mouse.button === Qt.LeftButton) { + if (!modelData.isFolder) { + root.pendingPreviewPath = modelData.path + previewTimer.restart() + } + } + } + onDoubleClicked: (mouse) => { + if (mouse.button === Qt.LeftButton) { + previewTimer.stop() + fileListView.currentIndex = index + if (modelData.isFolder) { + sendCommand({"action": "change_path", "path": modelData.path}) + } else { + isPreviewMode = false + sendCommand({"action": "load_file", "path": modelData.path}) + } + } + } + } + + RowLayout { + anchors.fill: parent + spacing: 0 + + // Cells + component Cell: Text { + property real w + property int elideMode: Text.ElideRight + Layout.preferredWidth: w + Layout.fillHeight: true + verticalAlignment: Text.AlignVCenter + elide: elideMode + leftPadding: 5 + color: isSelected ? root.XsFileSystemStyle.textColor : root.XsFileSystemStyle.secondaryTextColor + font.pixelSize: fontSize + } + + // Indentation + Item { + Layout.preferredWidth: (modelData.depth||0) * 20 + Layout.fillHeight: true + } + + // Expander + Item { + Layout.preferredWidth: 20 + Layout.fillHeight: true + Text { + anchors.centerIn: parent + text: (root.viewMode !== 0 && root.viewMode !== 3 && modelData.isFolder) ? (modelData.expanded ? "▼" : "▶") : "" + color: XsFileSystemStyle.hintColor + font.pixelSize: 10 + } + MouseArea { + anchors.fill: parent + onClicked: toggleExpand(index) + } + } + + Cell { text: modelData.name || ""; Layout.fillWidth: true; Layout.minimumWidth: minWidthName; elideMode: Text.ElideMiddle } + Cell { text: (modelData.data && modelData.data.version) ? "v"+modelData.data.version : ""; w: colWidthVersion; color: isSelected ? root.XsFileSystemStyle.textColor : root.XsFileSystemStyle.secondaryTextColor } + Cell { text: (modelData.data && modelData.data.frames) || ""; w: colWidthFrames; color: isSelected ? root.XsFileSystemStyle.textColor : root.XsFileSystemStyle.secondaryTextColor } + Cell { text: (modelData.data && modelData.data.owner) || ""; w: colWidthOwner; color: isSelected ? root.XsFileSystemStyle.textColor : root.XsFileSystemStyle.secondaryTextColor } + Cell { text: modelData.data ? formatDate(modelData.data.date) : ""; w: colWidthDate; color: isSelected ? root.XsFileSystemStyle.textColor : root.XsFileSystemStyle.secondaryTextColor } + Cell { text: (modelData.data && modelData.data.size_str) || ""; w: colWidthSize; horizontalAlignment: Text.AlignRight; rightPadding: 5 } + Item { width: 20 } // Spacer at end + } + + FileContextMenu { + id: contextMenu + itemPath: modelData.path + } + } + } + + // 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() } + + property int thumbCurrentIndex: -1 + + Keys.onLeftPressed: (event) => { + var newIdx = thumbCurrentIndex + do { + if (newIdx > 0) newIdx-- + else break + } while (flatThumbnailModel[newIdx] && flatThumbnailModel[newIdx].type === "header") + if (newIdx !== thumbCurrentIndex) { + thumbCurrentIndex = newIdx + _handleThumbKeyPreview() + } + event.accepted = true + } + Keys.onRightPressed: (event) => { + var newIdx = thumbCurrentIndex + var maxIdx = flatThumbnailModel.length - 1 + do { + if (newIdx < maxIdx) newIdx++ + else break + } while (flatThumbnailModel[newIdx] && flatThumbnailModel[newIdx].type === "header") + if (newIdx !== thumbCurrentIndex) { + thumbCurrentIndex = newIdx + _handleThumbKeyPreview() + } + event.accepted = true + } + Keys.onUpPressed: (event) => { + var cols = Math.max(1, Math.floor(thumbFlow.width / 160)) + var newIdx = thumbCurrentIndex - cols + if (newIdx >= 0) { + while (newIdx > 0 && flatThumbnailModel[newIdx] && flatThumbnailModel[newIdx].type === "header") newIdx-- + thumbCurrentIndex = 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 = thumbCurrentIndex + cols + if (newIdx <= maxIdx) { + while (newIdx < maxIdx && flatThumbnailModel[newIdx] && flatThumbnailModel[newIdx].type === "header") newIdx++ + thumbCurrentIndex = newIdx + _handleThumbKeyPreview() + } + event.accepted = true + } + Keys.onReturnPressed: (event) => _handleThumbReturn(event) + Keys.onEnterPressed: (event) => _handleThumbReturn(event) + + function _handleThumbReturn(event) { + if (thumbCurrentIndex >= 0 && thumbCurrentIndex < flatThumbnailModel.length) { + var md = flatThumbnailModel[thumbCurrentIndex] + 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() + } + } + } + + onContentYChanged: { + // Extend the rendered page when the user scrolls near the bottom + var remaining = contentHeight - contentY - height + if (remaining < 600 && thumbRenderCount < fullFlatModel.length) { + thumbRenderCount = Math.min(thumbRenderCount + thumbPageStep, fullFlatModel.length) + flatThumbnailModel = fullFlatModel.slice(0, thumbRenderCount) + } + Qt.callLater(requestVisibleThumbnails) + } + + ScrollBar.vertical: ScrollBar { + policy: ScrollBar.AsNeeded + } + + Flow { + id: thumbFlow + width: thumbFlickable.contentWidth + spacing: 0 + + Repeater { + model: flatThumbnailModel + + delegate: Item { + id: flatDelegate + width: modelData.type === "header" ? thumbFlow.width : 160 + height: modelData.type === "header" ? 24 : 160 + + // ── Folder path header (spans full row) ──────────── + Rectangle { + anchors.fill: parent + visible: modelData.type === "header" + color: "#1a1a1a" + Rectangle { width: 3; height: parent.height; color: "#4a9eff" } + Text { + anchors.verticalCenter: parent.verticalCenter + anchors.left: parent.left; anchors.leftMargin: 10 + text: modelData.type === "header" ? modelData.path : "" + color: "#7aacce"; font.pixelSize: 11; font.bold: true + elide: Text.ElideLeft + width: parent.width - 16 + } + } + + // ── Thumbnail cell ────────────────────────────────── + property bool isSelected: (index === thumbFlickable.thumbCurrentIndex) + + Rectangle { + anchors.fill: parent; anchors.margins: 5 + visible: modelData.type === "file" + color: (isSelected) ? "#555555" : (cellMouse.containsMouse ? "#333333" : "#2a2a2a") + radius: 4 + border.color: (isSelected || cellMouse.containsMouse) ? "#777777" : "transparent" + border.width: isSelected ? 2 : (cellMouse.containsMouse ? 1 : 0) + } + + ColumnLayout { + anchors.fill: parent; anchors.margins: 10 + spacing: 4 + visible: modelData.type === "file" + + Item { + Layout.fillWidth: true; Layout.fillHeight: true + BusyIndicator { + anchors.centerIn: parent; width: 30; height: 30 + running: !modelData.thumbnailSource && modelData.type === "file" + visible: running + } + Image { + anchors.fill: parent + source: modelData.thumbnailSource || "" + fillMode: Image.PreserveAspectFit + asynchronous: true + visible: !!modelData.thumbnailSource + } + } + + 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 || "" + + Text { + 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 + } + Text { + anchors.bottom: parent.bottom; anchors.left: parent.left + text: parent.ext; color: "#888888"; font.pixelSize: 10 + visible: parent.ext !== "" + } + Text { + anchors.bottom: parent.bottom; anchors.right: parent.right + text: parent.frameRange; color: "#888888"; font.pixelSize: 10 + visible: parent.frameRange !== "" + } + } + } + + MouseArea { + id: cellMouse + anchors.fill: parent; hoverEnabled: true + visible: modelData.type === "file" + acceptedButtons: Qt.LeftButton | Qt.RightButton + onClicked: (mouse) => { + if (mouse.button === Qt.LeftButton) { + thumbFlickable.forceActiveFocus() + thumbFlickable.thumbCurrentIndex = index + root.pendingPreviewPath = modelData.path + previewTimer.restart() + } else if (mouse.button === Qt.RightButton) { + thumbContextMenu.popup() + } + } + onDoubleClicked: (mouse) => { + if (mouse.button === Qt.LeftButton) { + previewTimer.stop() + isPreviewMode = false + sendCommand({"action": "load_file", "path": modelData.path}) + } + } + + FileContextMenu { + id: thumbContextMenu + itemPath: modelData.path + } + + ToolTip { + delay: 500 + visible: cellMouse.containsMouse && modelData.type === "file" + + contentItem: Text { + text: { + 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 + } + color: "#e0e0e0" + font.pixelSize: 11 + } + + background: Rectangle { + color: XsFileSystemStyle.panelBgColor + radius: 3 + border.color: XsFileSystemStyle.borderColor + } + } + } + } // delegate + } // Repeater + } // Flow + } // Flickable + + // Nothing found message + Text { + anchors.centerIn: parent + text: "Nothing found" + color: "#666666" + font.pixelSize: 18 + visible: root.viewMode === 3 && flatThumbnailModel.length === 0 && !searching_attr.value && !scan_required_attr.value + } + + // Manual Scan Overlay + Rectangle { + anchors.centerIn: parent + width: 200 + height: 100 + color: "#333333" + visible: root.viewMode === 3 && scan_required_attr.value === true + z: 100 // Ensure it's on top + + ColumnLayout { + anchors.centerIn: parent + spacing: 10 + + Text { + text: "Manual Scan Required" + color: "#aaaaaa" + font.pixelSize: 14 + Layout.alignment: Qt.AlignHCenter + } + + Button { + text: "Scan Directory" + Layout.alignment: Qt.AlignHCenter + onClicked: sendCommand({"action": "force_scan"}) + + background: Rectangle { + color: parent.down ? "#444444" : "#555555" + radius: 3 + } + contentItem: Text { + text: parent.text + color: "white" + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + font.pixelSize: 12 + } + } + } + } + + ScrollBar { + anchors.right: parent.right + anchors.top: parent.top + anchors.bottom: parent.bottom + active: true + // The ScrollView (thumbnail mode) has its own built-in scrollbar + visible: root.viewMode !== 3 + policy: ScrollBar.AsNeeded + size: fileListView.visibleArea.heightRatio + position: fileListView.visibleArea.yPosition + onPositionChanged: if(pressed) { + fileListView.contentY = position * fileListView.contentHeight + } + } + } + + // 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: Text { + text: modelData + color: "#888888" + font.pixelSize: 10 + width: ListView.view.width + elide: Text.ElideMiddle + } + + // Auto-scroll to bottom + onCountChanged: { + positionViewAtEnd() + } + } + } + + // Bottom Footer: Progress + View Modes + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: 24 + color: "transparent" + + RowLayout { + anchors.fill: parent + spacing: 10 + + // Progress Bar (Left - fills remaining space) + ProgressBar { + id: scanProgress + Layout.fillWidth: true + Layout.preferredHeight: 6 + Layout.alignment: Qt.AlignVCenter + + // Only visible when scanning + visible: searching_attr.value === true + + from: 0 + to: 100 + value: progress_attr.value + indeterminate: true + + background: Rectangle { + implicitWidth: 200 + implicitHeight: 6 + color: "#444444" + radius: 3 + } + contentItem: Item { + implicitWidth: 200 + implicitHeight: 4 + Rectangle { + width: scanProgress.visualPosition * parent.width + height: parent.height + radius: 2 + color: "#17a81a" + } + } + } + + // If not scanning, we need a spacer to push buttons to right + Item { + Layout.fillWidth: true + visible: !scanProgress.visible + } + + // Preview Indicator + Rectangle { + Layout.preferredWidth: 60 + Layout.preferredHeight: 18 + Layout.alignment: Qt.AlignVCenter + color: "transparent" + + Text { + anchors.centerIn: parent + text: "Preview" + color: isPreviewMode ? "#66ff66" : "#444444" + font.pixelSize: 10 + font.bold: isPreviewMode + } + } + + // Divider (Vertical line) + Rectangle { + Layout.preferredWidth: 1 + Layout.preferredHeight: 14 + color: "#444444" + Layout.alignment: Qt.AlignVCenter + } + + + // View Mode Selector (Right) + RowLayout { + spacing: 0 + Layout.alignment: Qt.AlignVCenter + + Repeater { + model: ["List", "Tree", "Grouped", "Thumbnails"] + delegate: Rectangle { + width: 60 + height: 18 + color: (viewMode === index) ? "#444444" : "transparent" + border.color: XsFileSystemStyle.borderColor + border.width: 1 + + // Connecting borders + anchors.leftMargin: index > 0 ? -1 : 0 + + Text { + anchors.centerIn: parent + text: modelData + color: (viewMode === index) ? "#ffffff" : "#888888" + font.pixelSize: 10 + } + + MouseArea { + anchors.fill: parent + onClicked: viewMode = index + hoverEnabled: true + onEntered: parent.color = (viewMode === index) ? "#555555" : "#333333" + onExited: parent.color = (viewMode === index) ? "#444444" : "transparent" + } + } + } + } + + Item { Layout.preferredWidth: 5 } // Right margin + } + } + } +} +} diff --git a/src/plugin/python_plugins/filesystem_browser/qml/FilesystemBrowser.1/XsFileSystemStyle.qml b/src/plugin/python_plugins/filesystem_browser/qml/FilesystemBrowser.1/XsFileSystemStyle.qml new file mode 100644 index 000000000..20e88532e --- /dev/null +++ b/src/plugin/python_plugins/filesystem_browser/qml/FilesystemBrowser.1/XsFileSystemStyle.qml @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma Singleton +import QtQuick 2.15 +import xStudio 1.0 + +QtObject { + + // Backgrounds + property color backgroundColor: (typeof XsStyleSheet !== 'undefined' && XsStyleSheet.panelBgColor !== undefined) ? XsStyleSheet.panelBgColor : "#333333" + property color panelBgColor: (typeof XsStyleSheet !== 'undefined' && XsStyleSheet.panelBgColor !== undefined) ? XsStyleSheet.panelBgColor : "#333333" + property color headerBgColor: (typeof XsStyleSheet !== 'undefined' && XsStyleSheet.panelTitleBarColor !== undefined) ? XsStyleSheet.panelTitleBarColor : "#474747" + property color alternateBgColor: (typeof XsStyleSheet !== 'undefined' && XsStyleSheet.panelBgColor !== undefined) ? Qt.darker(XsStyleSheet.panelBgColor, 1.1) : "#2d2d2d" + + // Text Colors + property color textColor: (typeof XsStyleSheet !== 'undefined' && XsStyleSheet.primaryTextColor !== undefined) ? XsStyleSheet.primaryTextColor : "#F1F1F1" + property color secondaryTextColor: (typeof XsStyleSheet !== 'undefined' && XsStyleSheet.secondaryTextColor !== undefined) ? XsStyleSheet.secondaryTextColor : "#C1C1C1" + property color hintColor: (typeof XsStyleSheet !== 'undefined' && XsStyleSheet.hintColor !== undefined) ? XsStyleSheet.hintColor : "#959595" + property color accentColor: (typeof XsStyleSheet !== 'undefined' && XsStyleSheet.accentColor !== undefined) ? XsStyleSheet.accentColor : "#D17000" + + // Interaction Colors + property color selectionColor: (typeof XsStyleSheet !== 'undefined' && XsStyleSheet.panelBgFlatColor !== undefined) ? Qt.lighter(XsStyleSheet.panelBgFlatColor, 1.35) : "#7a7a7a" + property color hoverColor: (typeof XsStyleSheet !== 'undefined' && XsStyleSheet.panelBgColor !== undefined) ? Qt.lighter(XsStyleSheet.panelBgColor, 1.15) : "#3D3D3D" + property color pressedColor: (typeof XsStyleSheet !== 'undefined' && XsStyleSheet.panelBgColor !== undefined) ? Qt.darker(XsStyleSheet.panelBgColor, 1.1) : "#2A2A2A" + + // Borders / Dividers + property color borderColor: (typeof XsStyleSheet !== 'undefined') ? XsStyleSheet.menuBorderColor : "#858585" + property color dividerColor: (typeof XsStyleSheet !== 'undefined' && XsStyleSheet.menuDividerColor !== undefined) ? XsStyleSheet.menuDividerColor : (typeof XsStyleSheet !== 'undefined' && XsStyleSheet.menuBorderColor !== undefined) ? XsStyleSheet.menuBorderColor : "#858585" + + // Fonts + property string fontFamily: (typeof XsStyleSheet !== 'undefined') ? XsStyleSheet.fontFamily : "Inter" + property real fontSize: (typeof XsStyleSheet !== 'undefined') ? XsStyleSheet.fontSize : 12 + property string fixedWidthFontFamily: (typeof XsStyleSheet !== 'undefined') ? XsStyleSheet.fixedWidthFontFamily : "Monospace" + + // Dimensions + property real rowHeight: 28 + property real headerHeight: 30 + property real padding: (typeof XsStyleSheet !== 'undefined') ? XsStyleSheet.panelPadding : 4 + property real widgetHeight: (typeof XsStyleSheet !== 'undefined') ? XsStyleSheet.widgetStdHeight : 24 +} diff --git a/src/plugin/python_plugins/filesystem_browser/qml/FilesystemBrowser.1/icons/folder_closed.svg b/src/plugin/python_plugins/filesystem_browser/qml/FilesystemBrowser.1/icons/folder_closed.svg new file mode 100644 index 000000000..281be32d9 --- /dev/null +++ b/src/plugin/python_plugins/filesystem_browser/qml/FilesystemBrowser.1/icons/folder_closed.svg @@ -0,0 +1,38 @@ + + + + + + diff --git a/src/plugin/python_plugins/filesystem_browser/qml/FilesystemBrowser.1/qmldir b/src/plugin/python_plugins/filesystem_browser/qml/FilesystemBrowser.1/qmldir new file mode 100644 index 000000000..2bc1bc631 --- /dev/null +++ b/src/plugin/python_plugins/filesystem_browser/qml/FilesystemBrowser.1/qmldir @@ -0,0 +1,3 @@ +module FilesystemBrowser +FilesystemBrowser 1.0 FilesystemBrowser.qml +singleton XsFileSystemStyle 1.0 XsFileSystemStyle.qml diff --git a/src/plugin/python_plugins/filesystem_browser/scanner.py b/src/plugin/python_plugins/filesystem_browser/scanner.py new file mode 100644 index 000000000..5fb2bb4a9 --- /dev/null +++ b/src/plugin/python_plugins/filesystem_browser/scanner.py @@ -0,0 +1,416 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (c) 2026 Sam Richards + +import os +import re +import threading +import queue +import time +import pwd +import json +from concurrent.futures import ThreadPoolExecutor + +try: + import fileseq +except ImportError: + fileseq = None + +class FileScanner: + def __init__(self, config=None): + self.config = config or {} + self.extensions = set(self.config.get("extensions", [".mov", ".exr", ".png", ".mp4", ".jpg", ".jpeg", ".dpx", ".tiff", ".tif"])) + self.ignore_dirs = set(self.config.get("ignore_dirs", [".git", ".svn", "__pycache__"])) + self.non_sequence_extensions = set(self.config.get("non_sequence_extensions", [".mov", ".mp4"])) + self.version_regex = re.compile(self.config.get("version_regex", r"_v(\d+)")) + self.max_workers = self.config.get("thread_count", 4) + self.max_depth = self.config.get("max_depth", 6) + + self.cancel_event = threading.Event() + self.executor = ThreadPoolExecutor(max_workers=self.max_workers) + + + + def get_owner(self, uid): + try: + return pwd.getpwuid(uid).pw_name + except KeyError: + return str(uid) + + def format_size_str(self, size_bytes): + if size_bytes == 0: + return "0 B" + + size_name = ("B", "KB", "MB", "GB", "TB", "PB") + i = 0 + p = float(size_bytes) + + while i < len(size_name) - 1 and p >= 1024: + p /= 1024.0 + i += 1 + + return f"{p:.2f} {size_name[i]}" + + def scan(self, start_path, callback=None): + """ + Scans from start_path using BFS and weighted progress. + callback(results, 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)]) + + # Futures set + futures = set() + + # Results accumulator + all_items = [] + + # Progress tracking + total_progress = 0.0 + scanned_count = 0 + last_update = time.time() + + # Scanned paths tracking + recent_scanned_dirs = [] + + # Helper to schedule + def schedule_next(): + while queue and len(futures) < self.max_workers: + path, weight, depth = queue.popleft() + # Submit task + futures.add(self.executor.submit(self._scan_and_process_worker, path, start_path, weight, depth)) + + 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() + + # Accumulate results + if items: + all_items.extend(items) + scanned_count += len(items) + + recent_scanned_dirs.append(scanned_path) + + if callback and items: + # 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": []}) + + # Distribute weight or complete it + if subdirs and depth < self.max_depth: + if len(subdirs) > 0: + child_weight = weight / len(subdirs) + for d in subdirs: + queue.append((d, child_weight, depth + 1)) + else: + # Leaf node (in terms of dirs or recursion limit), this weight is done + total_progress += weight + + except Exception as e: + print(f"Scan error: {e}") + + # Schedule more + schedule_next() + + # Periodic Progress update + if time.time() - last_update > 0.2: + if callback: + callback([], { + "scanned": scanned_count, + "progress": min(100, int(total_progress * 100)), + "phase": "scanning", + "scanned_dirs": list(recent_scanned_dirs) + }) + recent_scanned_dirs = [] + last_update = time.time() + + if self.cancel_event.is_set(): + for f in futures: + f.cancel() + return all_items # Return what we have + + # Final update + if callback: + 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): + """ + Scans a directory, processes files therein, returns (subdirs, items, weight, depth, path). + """ + subdirs = [] + raw_files = [] + + if self.cancel_event.is_set(): + return [], [], weight, depth, path + + try: + with os.scandir(path) as entries: + for entry in entries: + if self.cancel_event.is_set(): + break + + if entry.is_dir(follow_symlinks=False): + if entry.name not in self.ignore_dirs and not entry.name.startswith('.'): + subdirs.append(entry.path) + # Also add directory as an item + try: + raw_files.append((entry.path, entry.name, entry.stat(), True)) # True for is_dir + except OSError: + pass + elif entry.is_file(): + ext = os.path.splitext(entry.name)[1].lower() + if ext in self.extensions: + try: + raw_files.append((entry.path, entry.name, entry.stat(), False)) # False for is_dir + except OSError: + pass + except OSError: + pass + + # Process files immediately + items = self._process_files(raw_files, root_path) + return subdirs, items, weight, depth, path + + def _process_files(self, raw_files, start_path): + """ + raw_files: list of (full_path, basename, stat_obj, is_dir) + """ + path_map = {f[0]: (f[1], f[2]) for f in raw_files} # path -> (name, stat) + + final_items = [] + sequence_candidate_paths = [] + + # 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)) + 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)) + else: + sequence_candidate_paths.append(p) + + # Use fileseq to find sequences among candidates + # Import moved to top level or check self.HAS_FILESEQ? + # The file has 'try: import fileseq ...' at top level + + sequences = [] + if fileseq and sequence_candidate_paths: + try: + sequences = fileseq.findSequencesInList(sequence_candidate_paths) + except Exception as e: + sequences = [] # Fallback? + + if not fileseq and sequence_candidate_paths: + # Fallback: Treat all as singles + 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)) + + for seq in sequences: + # Check if we should explode this sequence (if it's actually versioned files matching config) + explode = False + + # If length is 1, it's virtually a single file, but fileseq wraps it. + # If length > 1, check if it matches version regex but shouldn't? + # Existing logic: + if len(seq) > 1: + try: + # If the basename doesn't match version regex, but one file does?? + # This logic seems to prevent detecting a sequence if the naming is ambiguous? + # Let's keep existing logic but careful. + # Actually, if len > 1, it IS a sequence usually. + pass + except Exception as e: + pass + + if len(seq) == 1: + # Treat as single file + 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)) + continue + + # It's a sequence + max_mtime = 0 + total_size = 0 + valid_seq = True + + # Calculate stats + for p in seq: + info = path_map.get(str(p)) + if info: + st = info[1] + if st.st_mtime > max_mtime: + max_mtime = st.st_mtime + total_size += st.st_size + else: + # Should not happen as we built candidates from map + pass + + # Retrieve owner from first + first_path = str(seq[0]) + first_info = path_map.get(first_path) + owner = self.get_owner(first_info[1].st_uid) if first_info else "?" + + # Format name + try: + pad = seq.padding() + if pad: + pad_len = pad.count('#') * 4 + pad.count('@') + pad = "@" * pad_len + else: + pad = "@@@@" # Default? + except: + pad = "@@@@" + + name = f"{seq.basename()}{pad}{seq.extension()}" + + # Create item + # Use abspath for seq path? + # fileseq string representation might be relative if input was relative? + # input was 'p' from raw_files which is full path. + + # fileseq.FileSequence string conversion gives the sequence string (path-#.ext). + # We want that as 'path'? + # xstudio expects 'path' to be loadable. + + item = { + "name": name, + "path": str(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. + # But we want the sequence to appear in the folder. + # So we should use relation of the FOLDER containing the sequence. + # relpath logic in QML splits by /. + # If path is /foo/bar/seq.####.exr. relpath = bar/seq.####.exr. + # parts = [bar, seq...]. + # This works. + "type": "Sequence", + "frames": str(seq.frameRange()), + "size": total_size, + "size_str": self.format_size_str(total_size), + "date": max_mtime, + "owner": owner, + "extension": seq.extension(), + "is_sequence": True, + "is_folder": False + } + # Fix relpath to be based on the abstract sequence path if possible? + # actually `str(seq)` gives the sequence path. + # `os.path.relpath(str(seq), start_path)` should work. + item["relpath"] = os.path.relpath(str(seq), start_path) + + final_items.append(item) + + return self._group_versions(final_items) + + def _make_item(self, path, name, st, start_path, is_directory=False): + return { + "name": name, + "path": path, + "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, + "owner": self.get_owner(st.st_uid), + "extension": "" if is_directory else os.path.splitext(name)[1], + "is_sequence": False, + "is_folder": is_directory + } + + def _group_versions(self, items): + # items is a list of dicts. + # We want to identify items that are versions of the same thing. + # Regex: _v(\d+) + + # Key: (prefix, suffix) -> [item1, item2, ...] + groups = {} + ungrouped = [] + + for item in items: + name = item["name"] + # Apply regex + match = self.version_regex.search(name) + if match: + # Found a version + v_str = match.group(1) + v_num = int(v_str) + + # remove the version string from name to get the key + # e.g. shot_v01.exr -> shot_.exr (or similar) + # We replace the FULL match _v01 with a placeholder or empty + + # We need to handle where it is. + # If we have shot_v1.exr and shot_v2.exr -> Key: shot_.exr + span = match.span() + prefix = name[:span[0]] + suffix = name[span[1]:] + key = (prefix, suffix) + + if key not in groups: + groups[key] = [] + + # Attach version info to item + item["version"] = v_num + groups[key].append(item) + else: + ungrouped.append(item) + + # Now process groups + # If config says to group, we return a hybrid list + # We assume we just annotate them for now, or do we structure them? + # The user said: "group files of a similar basename... by removing version string" + # "Filter only the highest version" + + # If we just adding metadata, we can just return the flat list but with "version_group_id" or something. + # But for the UI to show "Latest Version", it needs to know which ones are older. + + # Let's add "latest_in_group" flag to items? + # And "group_key". + + final_output = list(ungrouped) + + for key, group_items in groups.items(): + # Sort by version + group_items.sort(key=lambda x: x["version"], reverse=True) + + # Highest version + for i, item in enumerate(group_items): + item["is_latest_version"] = (i == 0) + item["version_rank"] = i # 0-indexed rank (0 is latest) + item["version_group"] = str(key) + final_output.append(item) + + # Sort by name + final_output.sort(key=lambda x: x["name"]) + return final_output + + def stop(self): + self.cancel_event.set() + + def shutdown(self): + """Release the ThreadPoolExecutor. Call after stop() when the scanner is no longer needed.""" + self.executor.shutdown(wait=False) diff --git a/src/plugin/python_plugins/filesystem_browser/scanner_benchmark.py b/src/plugin/python_plugins/filesystem_browser/scanner_benchmark.py new file mode 100644 index 000000000..c4b3936f0 --- /dev/null +++ b/src/plugin/python_plugins/filesystem_browser/scanner_benchmark.py @@ -0,0 +1,29 @@ +# This is a test of the scanner. Its not part of the plugin, but +# its used to test the performance of the scanner against real world data. +# SPDX-License-Identifier: Apache-2.0 +# Copyright (c) 2026 Sam Richards + +import os +import shutil +import tempfile +import unittest +import json +from scanner import FileScanner +import argparse + +parser = argparse.ArgumentParser() +parser.add_argument("path", help="Path to scan") +parser.add_argument("--threads", type=int, default=4, help="Number of threads to use") +args = parser.parse_args() + +if not os.path.exists(args.path): + print(f"Path {args.path} does not exist") + exit(1) + +scanner = FileScanner(config={"thread_count": args.threads}) + +def callback(results, progress_info): + print(progress_info, len(results)) + +scanner.scan(args.path, callback=callback) + diff --git a/src/plugin/python_plugins/filesystem_browser/test_scanner.py b/src/plugin/python_plugins/filesystem_browser/test_scanner.py new file mode 100644 index 000000000..83ec80143 --- /dev/null +++ b/src/plugin/python_plugins/filesystem_browser/test_scanner.py @@ -0,0 +1,116 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright (c) 2026 Sam Richards + +import os +import shutil +import tempfile +import unittest +import json +from scanner import FileScanner + +class TestFileScanner(unittest.TestCase): + def setUp(self): + self.test_dir = tempfile.mkdtemp() + + def tearDown(self): + shutil.rmtree(self.test_dir) + + def create_file(self, filename, content="test"): + path = os.path.join(self.test_dir, filename) + with open(path, "w") as f: + f.write(content) + return path + + def test_basic_scan(self): + self.create_file("test.mov") + self.create_file("test.png") + self.create_file("ignore.txt") # not in extensions + + scanner = FileScanner() + results = scanner.scan(self.test_dir) + + names = [r["name"] for r in results] + self.assertIn("test.mov", names) + self.assertIn("test.png", names) + self.assertNotIn("ignore.txt", names) + + def test_version_grouping(self): + # Create versions + self.create_file("shot_v01.mov") + self.create_file("shot_v02.mov") # Newer + self.create_file("other_v01.mov") + + scanner = FileScanner() + results = scanner.scan(self.test_dir) + + names = [r["name"] for r in results] + + # Check flags + shot_v02 = next((r for r in results if r["name"] == "shot_v02.mov"), None) + shot_v01 = next((r for r in results if r["name"] == "shot_v01.mov"), None) + + if not shot_v02: + self.fail(f"shot_v02.mov not found in {names}") + + self.assertTrue(shot_v02.get("is_latest_version")) + self.assertFalse(shot_v01.get("is_latest_version")) + + self.assertEqual(shot_v02.get("version"), 2) + self.assertEqual(shot_v01.get("version"), 1) + self.assertEqual(shot_v02.get("version_group"), shot_v01.get("version_group")) + + def test_ignore_dirs(self): + os.makedirs(os.path.join(self.test_dir, ".git")) + self.create_file(".git/config") + + scanner = FileScanner() + results = scanner.scan(self.test_dir) + self.assertEqual(len(results), 0) + + def test_callback(self): + self.create_file("test.mov") + os.makedirs(os.path.join(self.test_dir, "subdir")) + self.create_file("subdir/test2.mov") + + scanner = FileScanner() + + callback_data = [] + def cb(items, progress): + callback_data.append((items, progress)) + + results = scanner.scan(self.test_dir, callback=cb) + + self.assertTrue(len(callback_data) > 0) + # Check if we got progress updates + progress_values = [d[1]["progress"] for d in callback_data] + self.assertIn(100, progress_values) + self.assertEqual(len(results), 2) + + def test_exclusion(self): + # Create files that LOOK like a sequence but have excluded extension + self.create_file("clip.1001.mov") + self.create_file("clip.1002.mov") + + # And some that SHOULD be a sequence + self.create_file("render.1001.exr") + self.create_file("render.1002.exr") + + # Configure scanner to exclude .mov (default) + scanner = FileScanner() # Defaults include .mov in non_sequence_extensions + results = scanner.scan(self.test_dir) + + names = [r["name"] for r in results] + + # .mov should be individual + self.assertIn("clip.1001.mov", names) + self.assertIn("clip.1002.mov", names) + + # .exr should be a sequence + # The name might be "render.####.exr" or similar depending on how fileseq formats it + # Let's check for the sequence type or name pattern + seq_items = [r for r in results if r["type"] == "Sequence"] + self.assertTrue(any("render" in r["name"] for r in seq_items)) + + mov_items = [r for r in results if "clip" in r["name"]] + for item in mov_items: + self.assertEqual(item["type"], "File") diff --git a/src/plugin/video_output/bmd_decklink/src/decklink_audio_device.cpp b/src/plugin/video_output/bmd_decklink/src/decklink_audio_device.cpp index c17cca57d..28a4a8a1f 100644 --- a/src/plugin/video_output/bmd_decklink/src/decklink_audio_device.cpp +++ b/src/plugin/video_output/bmd_decklink/src/decklink_audio_device.cpp @@ -29,10 +29,16 @@ long DecklinkAudioOutputDevice::desired_samples() { } long DecklinkAudioOutputDevice::latency_microseconds() { + if (!bmd_output_) { + return 0; + } return (bmd_output_->num_samples_in_buffer() * 1000000) / sample_rate_; } bool DecklinkAudioOutputDevice::push_samples(const void *sample_data, const long num_samples) { + if (!bmd_output_) { + return false; + } bmd_output_->receive_samples_from_xstudio((int16_t *)sample_data, num_samples); return true; -} \ No newline at end of file +} diff --git a/src/plugin/video_output/bmd_decklink/src/decklink_output.cpp b/src/plugin/video_output/bmd_decklink/src/decklink_output.cpp index d5fc70717..e61005c18 100644 --- a/src/plugin/video_output/bmd_decklink/src/decklink_output.cpp +++ b/src/plugin/video_output/bmd_decklink/src/decklink_output.cpp @@ -113,7 +113,7 @@ DecklinkOutput::DecklinkOutput(BMDecklinkPlugin *decklink_xstudio_plugin) decklink_output_interface_(NULL), decklink_xstudio_plugin_(decklink_xstudio_plugin) { - init_decklink(); + is_available_ = init_decklink(); } DecklinkOutput::~DecklinkOutput() { @@ -436,6 +436,10 @@ bool DecklinkOutput::start_sdi_output() { try { + if (!decklink_output_interface_) { + throw std::runtime_error("No DeckLink device is available."); + } + bool mode_matched = false; // Get first avaliable video mode for Output if (decklink_output_interface_->GetDisplayModeIterator(&display_mode_iterator) == @@ -532,9 +536,11 @@ bool DecklinkOutput::stop_sdi_output(const std::string &error_message) { spdlog::info("Stopping Decklink output loop. {}", error_message); - decklink_output_interface_->StopScheduledPlayback(0, NULL, 0); - decklink_output_interface_->DisableVideoOutput(); - decklink_output_interface_->DisableAudioOutput(); + if (decklink_output_interface_) { + decklink_output_interface_->StopScheduledPlayback(0, NULL, 0); + decklink_output_interface_->DisableVideoOutput(); + decklink_output_interface_->DisableAudioOutput(); + } mutex_.lock(); @@ -908,6 +914,9 @@ long DecklinkOutput::num_samples_in_buffer() { // note this method is called by the xstudio audio output thread // Have to assume that GetBufferedAudioSampleFrameCount is not thread safe. BMD SDK // does not tell us otherwise + if (!decklink_output_interface_) { + return 0; + } std::unique_lock lk0(bmd_mutex_); uint32_t prerollAudioSampleCount; if (decklink_output_interface_->GetBufferedAudioSampleFrameCount( @@ -920,6 +929,12 @@ long DecklinkOutput::num_samples_in_buffer() { // Note, I have not yet understood the significance of the preroll flag void DecklinkOutput::copy_audio_samples_to_decklink_buffer(const bool /*preroll*/) { + if (!decklink_output_interface_) { + fetch_more_samples_from_xstudio_ = true; + audio_samples_cv_.notify_one(); + return; + } + std::unique_lock lk0(bmd_mutex_); // How many samples are sitting on the SDI card ready to be played? diff --git a/src/plugin/video_output/bmd_decklink/src/decklink_output.hpp b/src/plugin/video_output/bmd_decklink/src/decklink_output.hpp index e08f547ad..4ce9a2715 100644 --- a/src/plugin/video_output/bmd_decklink/src/decklink_output.hpp +++ b/src/plugin/video_output/bmd_decklink/src/decklink_output.hpp @@ -142,6 +142,8 @@ namespace bm_decklink_plugin_1_0 { hdr_metadata_mutex_.unlock(); } + [[nodiscard]] bool is_available() const { return is_available_; } + private: AVOutputCallback *output_callback_; std::mutex mutex_; @@ -195,6 +197,7 @@ namespace bm_decklink_plugin_1_0 { HDRMetadata hdr_metadata_; std::mutex hdr_metadata_mutex_; + bool is_available_ = {false}; }; class AVOutputCallback : public IDeckLinkVideoOutputCallback, @@ -234,4 +237,4 @@ namespace bm_decklink_plugin_1_0 { }; } // namespace bm_decklink_plugin_1_0 -} // namespace xstudio \ No newline at end of file +} // namespace xstudio diff --git a/src/plugin/video_output/bmd_decklink/src/decklink_plugin.cpp b/src/plugin/video_output/bmd_decklink/src/decklink_plugin.cpp index 4d31c17e0..7d147f4fc 100644 --- a/src/plugin/video_output/bmd_decklink/src/decklink_plugin.cpp +++ b/src/plugin/video_output/bmd_decklink/src/decklink_plugin.cpp @@ -284,6 +284,9 @@ void BMDecklinkPlugin::attribute_changed(const utility::Uuid &attribute_uuid, co audio::AudioOutputDevice * BMDecklinkPlugin::make_audio_output_device(const utility::JsonStore &prefs) { + if (!dcl_output_ || !dcl_output_->is_available()) { + return nullptr; + } return static_cast( new DecklinkAudioOutputDevice(prefs, dcl_output_)); } @@ -295,6 +298,13 @@ void BMDecklinkPlugin::initialise() { dcl_output_ = new DecklinkOutput(this); set_hdr_mode_and_metadata(); + if (!dcl_output_->is_available()) { + status_message_->set_value("No DeckLink device detected."); + is_in_error_->set_value(true); + spdlog::warn("Decklink drivers found, but no DeckLink device is available."); + return; + } + resolutions_->set_role_data( module::Attribute::StringChoices, dcl_output_->output_resolution_names());