diff --git a/config/config.template.json b/config/config.template.json index 07a72b60..b3f63589 100644 --- a/config/config.template.json +++ b/config/config.template.json @@ -121,6 +121,7 @@ "axis": "horizontal" }, "display_durations": {}, + "plugin_rotation_order": [], "use_short_date_format": true, "vegas_scroll": { "enabled": false, diff --git a/src/display_controller.py b/src/display_controller.py index ace55b58..21fd79cf 100644 --- a/src/display_controller.py +++ b/src/display_controller.py @@ -381,6 +381,10 @@ class DisplayController: logger.debug("%d plugin(s) disabled in config", disabled_count) logger.info("Plugin system initialized in %.3f seconds", time.time() - plugin_time) + # Parallel loading appends modes in load-completion order, which + # varies between restarts; apply the user's configured rotation + # order (no-op when not configured). + self._apply_plugin_rotation_order() logger.info("Total available modes: %d", len(self.available_modes)) logger.info("Available modes: %s", self.available_modes) @@ -2843,11 +2847,44 @@ class DisplayController: except Exception as e: logger.error("Plugin reconcile: error enabling %s: %s", plugin_id, e, exc_info=True) + # Newly enabled plugins were appended at the end; put them in the + # configured rotation slot before resyncing the index. + self._apply_plugin_rotation_order() self._resync_mode_index_after_change(previous_mode) logger.info("Plugin reconcile complete: +%s -%s (%d modes)", sorted(to_add), sorted(to_remove), len(self.available_modes)) return True + def _apply_plugin_rotation_order(self) -> None: + """Reorder available_modes to follow display.plugin_rotation_order. + + The configured value is a list of plugin ids; their modes rotate in + that order (each plugin's own modes keep their declared order), with + any enabled-but-unlisted plugins appended afterwards in their current + relative order. An empty/missing list leaves available_modes exactly + as built (today's behavior). Mirrors vegas_mode/config.py's + get_ordered_plugins() semantics for the primary rotation. + """ + configured = (self.config.get("display", {}) or {}).get("plugin_rotation_order", []) or [] + if not configured or not self.available_modes: + return + + ordered_ids = [p for p in configured if p in self.plugin_display_modes] + new_modes: List[str] = [] + for plugin_id in ordered_ids: + for mode in self.plugin_display_modes[plugin_id]: + if mode in self.available_modes and mode not in new_modes: + new_modes.append(mode) + # Unlisted plugins' modes (and any mode not attributable to a plugin) + # follow in their existing relative order. + for mode in self.available_modes: + if mode not in new_modes: + new_modes.append(mode) + if new_modes != self.available_modes: + self.available_modes = new_modes + logger.info("Applied plugin rotation order %s -> modes: %s", + configured, self.available_modes) + def _resync_mode_index_after_change(self, previous_mode: Optional[str]) -> None: """Clamp rotation state after available_modes changed. Stays on the previous mode if it survived, otherwise restarts cleanly within range.""" diff --git a/web_interface/blueprints/api_v3.py b/web_interface/blueprints/api_v3.py index 60025c2b..ec12be1b 100644 --- a/web_interface/blueprints/api_v3.py +++ b/web_interface/blueprints/api_v3.py @@ -961,6 +961,22 @@ def save_main_config(): return jsonify({"status": "error", "message": "sync_follower_position must be left or right"}), 400 current_config["sync"]["follower_position"] = pos_val + # Handle primary rotation order (JSON array of plugin ids; same + # parse/validate pattern as vegas_plugin_order above) + if 'plugin_rotation_order' in data: + try: + if isinstance(data['plugin_rotation_order'], str): + parsed = json.loads(data['plugin_rotation_order']) + else: + parsed = data['plugin_rotation_order'] + if 'display' not in current_config: + current_config['display'] = {} + current_config['display']['plugin_rotation_order'] = ( + list(parsed) if isinstance(parsed, (list, tuple)) else [] + ) + except (json.JSONDecodeError, TypeError, ValueError): + logger.warning("Malformed plugin_rotation_order ignored") + # Handle display durations duration_fields = [k for k in data.keys() if k.endswith('_duration') or k in ['default_duration', 'transition_duration']] if duration_fields: diff --git a/web_interface/static/v3/js/widgets/plugin-order-list.js b/web_interface/static/v3/js/widgets/plugin-order-list.js new file mode 100644 index 00000000..dd3a1ab6 --- /dev/null +++ b/web_interface/static/v3/js/widgets/plugin-order-list.js @@ -0,0 +1,183 @@ +/** + * Plugin Order List — shared drag-and-drop reorder list of enabled plugins. + * + * Factored out of the Vegas Scroll section of display.html so both Vegas mode + * and the primary rotation (Durations tab) use one implementation. Renders + * one draggable row per enabled plugin into a container and keeps a hidden + * input's value in sync as a JSON array of plugin ids in display order. + * + * Usage: + * PluginOrderList.init({ + * containerId: 'vegas_plugin_order', // rows render here + * orderInputId: 'vegas_plugin_order_value', // hidden input, JSON array of ids + * excludedInputId: 'vegas_excluded_plugins_value', // optional: adds an + * // include-checkbox per row; unchecked ids collect here (JSON array) + * showVegasModeBadge: true // optional: Scroll/Fixed/Static badge + * }); + * + * The container re-renders from /api/v3/plugins/installed each init; the + * hidden input(s) must already hold the saved order/exclusions (JSON). + */ +(function() { + 'use strict'; + + function escapeHtml(text) { + const div = document.createElement('div'); + div.textContent = text == null ? '' : String(text); + return div.innerHTML; + } + + function escapeAttr(text) { + return escapeHtml(text).replace(/"/g, '"').replace(/'/g, '''); + } + + const MODE_LABELS = { + 'scroll': { label: 'Scroll', icon: 'fa-scroll', color: 'text-blue-600' }, + 'fixed': { label: 'Fixed', icon: 'fa-square', color: 'text-green-600' }, + 'static': { label: 'Static', icon: 'fa-pause', color: 'text-orange-600' } + }; + + function init(options) { + const container = document.getElementById(options.containerId); + const orderInput = document.getElementById(options.orderInputId); + const excludedInput = options.excludedInputId ? document.getElementById(options.excludedInputId) : null; + if (!container || !orderInput) return; + + function syncInputs() { + const order = []; + const excluded = []; + container.querySelectorAll('.plugin-order-item').forEach(item => { + const pluginId = item.dataset.pluginId; + order.push(pluginId); + const checkbox = item.querySelector('.plugin-order-include'); + if (checkbox && !checkbox.checked) excluded.push(pluginId); + }); + orderInput.value = JSON.stringify(order); + if (excludedInput) excludedInput.value = JSON.stringify(excluded); + } + + function setupDragAndDrop() { + let draggedItem = null; + container.querySelectorAll('.plugin-order-item').forEach(item => { + item.addEventListener('dragstart', function(e) { + draggedItem = this; + this.style.opacity = '0.5'; + e.dataTransfer.effectAllowed = 'move'; + }); + item.addEventListener('dragend', function() { + this.style.opacity = '1'; + draggedItem = null; + syncInputs(); + }); + item.addEventListener('dragover', function(e) { + e.preventDefault(); + e.dataTransfer.dropEffect = 'move'; + const rect = this.getBoundingClientRect(); + const midY = rect.top + rect.height / 2; + if (e.clientY < midY) { + this.style.borderTop = '2px solid #3b82f6'; + this.style.borderBottom = ''; + } else { + this.style.borderBottom = '2px solid #3b82f6'; + this.style.borderTop = ''; + } + }); + item.addEventListener('dragleave', function() { + this.style.borderTop = ''; + this.style.borderBottom = ''; + }); + item.addEventListener('drop', function(e) { + e.preventDefault(); + this.style.borderTop = ''; + this.style.borderBottom = ''; + if (draggedItem && draggedItem !== this) { + const rect = this.getBoundingClientRect(); + const midY = rect.top + rect.height / 2; + if (e.clientY < midY) { + container.insertBefore(draggedItem, this); + } else { + container.insertBefore(draggedItem, this.nextSibling); + } + } + }); + }); + } + + fetch('/api/v3/plugins/installed') + .then(response => response.json()) + .then(data => { + const allPlugins = (data.data && data.data.plugins) || data.plugins || []; + const plugins = allPlugins.filter(p => p.enabled); + if (plugins.length === 0) { + container.innerHTML = '

No enabled plugins

'; + return; + } + + let currentOrder = []; + let excluded = []; + try { + currentOrder = JSON.parse(orderInput.value || '[]'); + if (excludedInput) excluded = JSON.parse(excludedInput.value || '[]'); + } catch (e) { + console.error('Error parsing saved plugin order:', e); + } + + // Saved order first, then any newly enabled plugins. + const orderedPlugins = []; + currentOrder.forEach(id => { + const plugin = plugins.find(p => p.id === id); + if (plugin) orderedPlugins.push(plugin); + }); + plugins.forEach(plugin => { + if (!orderedPlugins.find(p => p.id === plugin.id)) orderedPlugins.push(plugin); + }); + + let html = ''; + orderedPlugins.forEach(plugin => { + const safePluginId = escapeAttr(plugin.id); + const safePluginName = escapeHtml(plugin.name || plugin.id); + let rowInner; + if (excludedInput) { + const isExcluded = excluded.includes(plugin.id); + rowInner = ` + `; + } else { + rowInner = `${safePluginName}`; + } + let badge = ''; + if (options.showVegasModeBadge) { + const vegasMode = plugin.vegas_mode || plugin.vegas_content_type || 'fixed'; + const modeInfo = MODE_LABELS[vegasMode] || MODE_LABELS['fixed']; + badge = ` + + ${modeInfo.label} + `; + } + html += ` +
+ + ${rowInner}${badge} +
`; + }); + container.innerHTML = html; + + setupDragAndDrop(); + container.querySelectorAll('.plugin-order-include').forEach(checkbox => { + checkbox.addEventListener('change', syncInputs); + }); + syncInputs(); + }) + .catch(error => { + console.error('Error fetching plugins:', error); + container.innerHTML = '

Error loading plugins

'; + }); + } + + window.PluginOrderList = { init: init }; +})(); diff --git a/web_interface/templates/v3/base.html b/web_interface/templates/v3/base.html index adb8b732..2b371994 100644 --- a/web_interface/templates/v3/base.html +++ b/web_interface/templates/v3/base.html @@ -4480,6 +4480,7 @@ + diff --git a/web_interface/templates/v3/partials/display.html b/web_interface/templates/v3/partials/display.html index 66a3d4ee..4f38c92c 100644 --- a/web_interface/templates/v3/partials/display.html +++ b/web_interface/templates/v3/partials/display.html @@ -638,182 +638,25 @@ if (typeof window.fixInvalidNumberInputs !== 'function') { }); } - // Initialize plugin order list + // Initialize plugin order list via the shared drag-and-drop module + // (static/v3/js/widgets/plugin-order-list.js) — the same component the + // Durations tab uses for the primary rotation order. function initPluginOrderList() { - const container = document.getElementById('vegas_plugin_order'); - if (!container) return; - - // Fetch available plugins - fetch('/api/v3/plugins/installed') - .then(response => response.json()) - .then(data => { - // Handle both {data: {plugins: []}} and {plugins: []} response formats - const allPlugins = data.data?.plugins || data.plugins || []; - if (!allPlugins || allPlugins.length === 0) { - container.innerHTML = '

No plugins available

'; - return; - } - - // Get current order and exclusions - const orderInput = document.getElementById('vegas_plugin_order_value'); - const excludedInput = document.getElementById('vegas_excluded_plugins_value'); - let currentOrder = []; - let excluded = []; - - try { - currentOrder = JSON.parse(orderInput.value || '[]'); - excluded = JSON.parse(excludedInput.value || '[]'); - } catch (e) { - console.error('Error parsing vegas config:', e); - } - - // Build ordered plugin list (only enabled plugins) - const plugins = allPlugins.filter(p => p.enabled); - const orderedPlugins = []; - - // First add plugins in current order - currentOrder.forEach(id => { - const plugin = plugins.find(p => p.id === id); - if (plugin) orderedPlugins.push(plugin); - }); - - // Then add remaining plugins - plugins.forEach(plugin => { - if (!orderedPlugins.find(p => p.id === plugin.id)) { - orderedPlugins.push(plugin); - } - }); - - // Build HTML with display mode indicators - let html = ''; - orderedPlugins.forEach((plugin, index) => { - const isExcluded = excluded.includes(plugin.id); - // Determine display mode (from plugin config or default) - const vegasMode = plugin.vegas_mode || plugin.vegas_content_type || 'fixed'; - const modeLabels = { - 'scroll': { label: 'Scroll', icon: 'fa-scroll', color: 'text-blue-600' }, - 'fixed': { label: 'Fixed', icon: 'fa-square', color: 'text-green-600' }, - 'static': { label: 'Static', icon: 'fa-pause', color: 'text-orange-600' } - }; - const modeInfo = modeLabels[vegasMode] || modeLabels['fixed']; - // Escape plugin metadata to prevent XSS - const safePluginId = escapeAttr(plugin.id); - const safePluginName = escapeHtml(plugin.name || plugin.id); - html += ` -
- - - - ${modeInfo.label} - -
- `; - }); - - container.innerHTML = html || '

No enabled plugins

'; - - // Setup drag and drop - setupDragAndDrop(container); - - // Setup checkbox handlers - container.querySelectorAll('.vegas-plugin-include').forEach(checkbox => { - checkbox.addEventListener('change', updatePluginConfig); - }); - - // Initialize hidden inputs with current state - updatePluginConfig(); - }) - .catch(error => { - console.error('Error fetching plugins:', error); - container.innerHTML = '

Error loading plugins

'; - }); - } - - function setupDragAndDrop(container) { - let draggedItem = null; - - container.querySelectorAll('.vegas-plugin-item').forEach(item => { - item.addEventListener('dragstart', function(e) { - draggedItem = this; - this.style.opacity = '0.5'; - e.dataTransfer.effectAllowed = 'move'; - }); - - item.addEventListener('dragend', function() { - this.style.opacity = '1'; - draggedItem = null; - updatePluginConfig(); - }); - - item.addEventListener('dragover', function(e) { - e.preventDefault(); - e.dataTransfer.dropEffect = 'move'; - - const rect = this.getBoundingClientRect(); - const midY = rect.top + rect.height / 2; - - if (e.clientY < midY) { - this.style.borderTop = '2px solid #3b82f6'; - this.style.borderBottom = ''; - } else { - this.style.borderBottom = '2px solid #3b82f6'; - this.style.borderTop = ''; - } - }); - - item.addEventListener('dragleave', function() { - this.style.borderTop = ''; - this.style.borderBottom = ''; - }); - - item.addEventListener('drop', function(e) { - e.preventDefault(); - this.style.borderTop = ''; - this.style.borderBottom = ''; - - if (draggedItem && draggedItem !== this) { - const rect = this.getBoundingClientRect(); - const midY = rect.top + rect.height / 2; - - if (e.clientY < midY) { - container.insertBefore(draggedItem, this); - } else { - container.insertBefore(draggedItem, this.nextSibling); - } - } - }); + if (!document.getElementById('vegas_plugin_order')) return; + if (!window.PluginOrderList) { + // Widget script is deferred; retry briefly if this partial's + // inline script runs first. + setTimeout(initPluginOrderList, 100); + return; + } + window.PluginOrderList.init({ + containerId: 'vegas_plugin_order', + orderInputId: 'vegas_plugin_order_value', + excludedInputId: 'vegas_excluded_plugins_value', + showVegasModeBadge: true }); } - function updatePluginConfig() { - const container = document.getElementById('vegas_plugin_order'); - const orderInput = document.getElementById('vegas_plugin_order_value'); - const excludedInput = document.getElementById('vegas_excluded_plugins_value'); - - if (!container || !orderInput || !excludedInput) return; - - const order = []; - const excluded = []; - - container.querySelectorAll('.vegas-plugin-item').forEach(item => { - const pluginId = item.dataset.pluginId; - const checkbox = item.querySelector('.vegas-plugin-include'); - - order.push(pluginId); - if (checkbox && !checkbox.checked) { - excluded.push(pluginId); - } - }); - - orderInput.value = JSON.stringify(order); - excludedInput.value = JSON.stringify(excluded); - } // Initialize on DOM ready if (document.readyState === 'loading') { diff --git a/web_interface/templates/v3/partials/durations.html b/web_interface/templates/v3/partials/durations.html index 0a1f05aa..096f6b48 100644 --- a/web_interface/templates/v3/partials/durations.html +++ b/web_interface/templates/v3/partials/durations.html @@ -16,6 +16,20 @@ novalidate onsubmit="fixInvalidNumberInputs(this); return true;"> + +
+

Rotation Order

+

Drag plugins to set the order they rotate on the display. Each plugin's screens keep their own order within its turn. Takes effect after saving and restarting the display.

+
+

Loading plugins…

+
+ +
+
{% for key, value in main_config.display.display_durations.items() %}
@@ -43,3 +57,26 @@
+ +