Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions src/blueprints/plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
from refresh_task import ManualRefresh, PlaylistRefresh
import json
import os
import io
import base64
import logging

logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -226,6 +228,45 @@ def display_plugin_instance():

return jsonify({"success": True, "message": "Display updated"}), 200

@plugin_bp.route('/preview_plugin', methods=['POST'])
def preview_plugin():
"""Generate a plugin image and return a preview without pushing it to the display."""
device_config = current_app.config['DEVICE_CONFIG']
display_manager = current_app.config['DISPLAY_MANAGER']
refresh_task = current_app.config['REFRESH_TASK']

try:
plugin_settings = parse_form(request.form)
plugin_settings.update(handle_request_files(request.files))
plugin_id = plugin_settings.pop("plugin_id")

Comment on lines +239 to +242
plugin_config = device_config.get_plugin(plugin_id)
if not plugin_config:
return jsonify({"error": f"Plugin '{plugin_id}' not found"}), 404

if refresh_task.running:
# Serialize rendering through the background refresh task to avoid running a
# second browser/screenshot process concurrently, which can hang the display.
image = refresh_task.preview(plugin_id, plugin_settings)
else:
# In development mode the refresh task may not be running, render directly.
plugin = get_plugin_instance(plugin_config)
image = plugin.generate_image(plugin_settings, device_config)
image = display_manager.process_image(image, plugin_config.get("image_settings", []))

if image is None:
return jsonify({"error": "Failed to generate preview image"}), 500

# Encode the processed image as a base64 data URL for the browser
buffer = io.BytesIO()
image.convert("RGB").save(buffer, format="PNG")
encoded = base64.b64encode(buffer.getvalue()).decode("utf-8")
except Exception as e:
logger.exception(f"Error in preview_plugin: {str(e)}")
return jsonify({"error": f"An error occurred: {str(e)}"}), 500
Comment on lines +264 to +266

return jsonify({"success": True, "image": f"data:image/png;base64,{encoded}"}), 200

@plugin_bp.route('/update_now', methods=['POST'])
def update_now():
device_config = current_app.config['DEVICE_CONFIG']
Expand Down
25 changes: 21 additions & 4 deletions src/display/display_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,26 @@ def __init__(self, device_config):
else:
raise ValueError(f"Unsupported display type: {display_type}")

def process_image(self, image, image_settings=[]):

"""
Applies the same orientation, resize and enhancement pipeline used when
rendering to the device, without pushing the result to any display.

Args:
image (PIL.Image): The image to be processed.
image_settings (list, optional): List of settings to modify image rendering.

Returns:
PIL.Image: The processed image as it would appear on the device.
"""

image = change_orientation(image, self.device_config.get_config("orientation"))
image = resize_image(image, self.device_config.get_resolution(), image_settings)
if self.device_config.get_config("inverted_image"): image = image.rotate(180)
image = apply_image_enhancement(image, self.device_config.get_config("image_settings"))
return image
Comment on lines +57 to +75

def display_image(self, image, image_settings=[]):

"""
Expand All @@ -75,10 +95,7 @@ def display_image(self, image, image_settings=[]):
image.save(self.device_config.current_image_file)

# Resize and adjust orientation
image = change_orientation(image, self.device_config.get_config("orientation"))
image = resize_image(image, self.device_config.get_resolution(), image_settings)
if self.device_config.get_config("inverted_image"): image = image.rotate(180)
image = apply_image_enhancement(image, self.device_config.get_config("image_settings"))
image = self.process_image(image, image_settings)

# Pass to the concrete instance to render to the device.
self.display.display_image(image, image_settings)
58 changes: 57 additions & 1 deletion src/refresh_task.py
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,16 @@ def _run(self):
continue
plugin = get_plugin_instance(plugin_config)
image = refresh_action.execute(plugin, self.device_config, current_dt)

if isinstance(refresh_action, PreviewRefresh):
# Preview only: process the image like the display would,
# but do not push it to the device or persist any state.
logger.info("Generating plugin preview (not updating display).")
self.refresh_result["preview_image"] = self.display_manager.process_image(
image, plugin_config.get("image_settings", [])
)
continue

image_hash = compute_image_hash(image)

refresh_info = refresh_action.get_refresh_info()
Expand Down Expand Up @@ -149,6 +159,28 @@ def manual_update(self, refresh_action):
else:
logger.warning("Background refresh task is not running, unable to do a manual update")

def preview(self, plugin_id, plugin_settings):
"""Generate a plugin image for preview via the background thread without updating the display.

Rendering is serialized through the single background worker (same as a manual update) to avoid
concurrent browser/screenshot processes, and returns the processed image without pushing it to
the device.
"""
if not self.running:
raise RuntimeError("Background refresh task is not running, unable to generate a preview")

with self.condition:
self.manual_update_request = PreviewRefresh(plugin_id, plugin_settings)
self.refresh_result = {}
self.refresh_event.clear()

self.condition.notify_all() # Wake the thread to process the preview request

self.refresh_event.wait()
if self.refresh_result.get("exception"):
raise self.refresh_result.get("exception")
return self.refresh_result.get("preview_image")
Comment on lines +179 to +182

def signal_config_change(self):
"""Notify the background thread that config has changed (e.g., interval updated)."""
if self.running:
Expand Down Expand Up @@ -219,7 +251,7 @@ def get_plugin_id(self):

class ManualRefresh(RefreshAction):
"""Performs a manual refresh based on a plugin's ID and its associated settings.

Attributes:
plugin_id (str): The ID of the plugin to refresh.
plugin_settings (dict): The settings for the manual refresh.
Expand All @@ -241,6 +273,30 @@ def get_plugin_id(self):
"""Return the plugin ID associated with this refresh."""
return self.plugin_id

class PreviewRefresh(RefreshAction):
"""Generates a plugin image for preview without updating the display.

Attributes:
plugin_id (str): The ID of the plugin to preview.
plugin_settings (dict): The settings to render the preview with.
"""

def __init__(self, plugin_id: str, plugin_settings: dict):
self.plugin_id = plugin_id
self.plugin_settings = plugin_settings

def execute(self, plugin, device_config, current_dt: datetime):
"""Generates the plugin image using the stored plugin ID and settings."""
return plugin.generate_image(self.plugin_settings, device_config)

def get_refresh_info(self):
"""Return refresh metadata as a dictionary."""
return {"refresh_type": "Preview", "plugin_id": self.plugin_id}

def get_plugin_id(self):
"""Return the plugin ID associated with this refresh."""
return self.plugin_id

class PlaylistRefresh(RefreshAction):
"""Performs a refresh using a plugin instance within a playlist context.

Expand Down
24 changes: 24 additions & 0 deletions src/static/styles/main.css
Original file line number Diff line number Diff line change
Expand Up @@ -665,6 +665,30 @@ html[data-theme="dark"] .settings-button.dark-mode-toggle::before {
transition: color 0.3s ease;
}

.preview-hint {
font-size: 0.85rem;
color: var(--text-secondary);
text-align: center;
margin-bottom: 12px;
}

.preview-image-container {
display: flex;
justify-content: center;
align-items: center;
}

.preview-image-container img {
max-width: 100%;
height: auto;
border: 1px solid var(--shadow-medium);
border-radius: 4px;
}

#previewModal .modal-content {
max-width: 700px;
}

.close-button {
color: var(--close-btn-color);
float: right;
Expand Down
55 changes: 55 additions & 0 deletions src/templates/plugin.html
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,42 @@
}
}

async function handlePreview() {
const loadingIndicator = document.getElementById('loadingIndicator');
loadingIndicator.style.display = 'block';

const form = document.getElementById('settingsForm');
const formData = new FormData(form);

// Add uploaded files to the form under its key
Object.keys(uploadedFiles).forEach(key => {
if (uploadedFiles[key].length > 0) {
uploadedFiles[key].forEach(file => {
formData.append(key, file);
});
}
});

try {
const response = await fetch('{{ url_for("plugin.preview_plugin") }}', {
method: 'POST',
body: formData
});
const result = await response.json();
if (response.ok) {
document.getElementById('previewImage').src = result.image;
openModal('previewModal');
} else {
showResponseModal('failure', `Error! ${result.error}`);
}
} catch (error) {
console.error('Error:', error);
alert('An error occurred while generating the preview.');
} finally {
loadingIndicator.style.display = 'none';
}
}

function openModal(modal_id) {
// Handle refresh settings modal specially with the manager
if (modal_id === 'refreshSettingsModal' && editRefreshManager) {
Expand All @@ -155,6 +191,10 @@
if (event.target === modal) {
modal.style.display = 'none';
}
const previewModal = document.getElementById('previewModal');
if (event.target === previewModal) {
previewModal.style.display = 'none';
}
};

function toggleCollapsible(button) {
Expand Down Expand Up @@ -404,9 +444,11 @@ <h1 class="app-title">{{ plugin.display_name }}</h1>

<div class="buttons-container">
{% if plugin_instance %}
<button type="button" onclick="handlePreview()" class="action-button">Preview</button>
<button type="button" onclick="handleAction('update_instance')" class="action-button">Save</button>
<button type="button" onclick="openModal('scheduleModal')" class="action-button right">Save As...</button>
{% else %}
<button type="button" onclick="handlePreview()" class="action-button left">Preview</button>
<button type="button" onclick="handleAction()" class="action-button left">Update Now</button>
<button type="button" onclick="openModal('scheduleModal')" class="action-button right">Add to Playlist</button>
{% endif %}
Expand Down Expand Up @@ -464,5 +506,18 @@ <h2>Add to Playlist</h2>
</div>
</div>
</div>

<!-- Preview Modal -->
<div id="previewModal" class="modal">
<div class="modal-content">
<span class="close-button" onclick="closeModal('previewModal')">×</span>
<h2>Preview</h2>
<div class="separator"></div>
<p class="preview-hint">This is how the plugin will look on your display. It has not been sent to the device.</p>
<div class="preview-image-container">
<img id="previewImage" alt="Plugin preview" />
</div>
</div>
</div>
</body>
</html>