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
55 changes: 30 additions & 25 deletions src/trcc/core/device/lcd.py
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,7 @@ def initialize_pipeline(self, settings: Any) -> None:
settings.set_resolution(w, h)
self.set_resolution(w, h)
self.initialize(settings.user_data_dir)
self.restore_device_settings()
else:
self.log.warning("initialize_pipeline: skipped — resolution is %s", res)
# Issue #141 — seed overlay language from saved settings so a
Expand Down Expand Up @@ -382,16 +383,18 @@ def send_image(self, image_path: str) -> dict:
if not os.path.exists(image_path):
return {"success": False, "error": f"File not found: {image_path}"}
from ...services import ImageService
w, h = self.lcd_size
w, h = self.canvas_size
img = ImageService.open_and_resize(image_path, w, h)
self._device_svc.send_frame(img, w, h)
ea = self._display_svc._encode_angle() if self._display_svc else 0
self._device_svc.send_frame(img, w, h, encode_angle=ea)
return {"success": True, "image": img, "message": f"Sent {image_path}"}

def send_color(self, r: int, g: int, b: int) -> dict:
from ...services import ImageService
w, h = self.lcd_size
w, h = self.canvas_size
img = ImageService.solid_color(r, g, b, w, h)
self._device_svc.send_frame(img, w, h)
ea = self._display_svc._encode_angle() if self._display_svc else 0
self._device_svc.send_frame(img, w, h, encode_angle=ea)
return {"success": True, "image": img,
"message": f"Sent color #{r:02x}{g:02x}{b:02x}"}

Expand All @@ -400,14 +403,16 @@ def send(self, image: Any) -> dict:
self.log.debug("send: image=%s", type(image).__name__ if image else None)
if not self._device_svc.selected:
return {"success": False, "error": "No device selected"}
w, h = self.lcd_size
self._device_svc.send_frame_async(image, w, h)
w, h = self.canvas_size
ea = self._display_svc._encode_angle() if self._display_svc else 0
self._device_svc.send_frame_async(image, w, h, encode_angle=ea)
return {"success": True}

def send_frame(self, image: Any) -> bool:
"""Synchronously send image to LCD device."""
w, h = self.lcd_size
return self._device_svc.send_frame(image, w, h)
w, h = self.canvas_size
ea = self._display_svc._encode_angle() if self._display_svc else 0
return self._device_svc.send_frame(image, w, h, encode_angle=ea)

def send_async(self, image: Any, width: int, height: int) -> None:
if self._device_svc.is_busy:
Expand All @@ -423,9 +428,10 @@ def load_image(self, path: Any) -> dict:

def reset(self) -> dict:
from ...services import ImageService
w, h = self.lcd_size
w, h = self.canvas_size
img = ImageService.solid_color(255, 0, 0, w, h)
self._device_svc.send_frame(img, w, h)
ea = self._display_svc._encode_angle() if self._display_svc else 0
self._device_svc.send_frame(img, w, h, encode_angle=ea)
return {"success": True, "image": img, "message": "Device reset — RED"}

# ══════════════════════════════════════════════════════════════════════
Expand All @@ -451,6 +457,15 @@ def refresh_dirs(self) -> None:
"""Re-probe filesystem dirs and update config."""
if self._display_svc:
self._display_svc.refresh_dirs()
if self._theme_svc:
self._theme_svc.set_directories(
local_dir=self._display_svc.local_dir,
web_dir=self._display_svc.web_dir,
masks_dir=self._display_svc.masks_dir,
)
w, h = self.canvas_size
if w and h:
self._theme_svc.load_local_themes((w, h))
self._persist_dirs()

def restore_device_settings(self) -> None:
Expand Down Expand Up @@ -487,21 +502,11 @@ def set_rotation(self, degrees: int) -> dict:
image = svc.set_rotation(degrees)
self._persist('rotation', degrees)

new_theme_dir = svc.theme_dir
# ThemeDir is a value object without __eq__, so compare paths.
old_path = old_theme_dir.path if old_theme_dir else None
new_path = new_theme_dir.path if new_theme_dir else None
theme_dir_changed = (old_path != new_path)
if old_canvas != svc.canvas_size and theme_dir_changed:
self.log.info("set_rotation: theme dir changed %s→%s, reloading",
old_theme_dir.path if old_theme_dir else None,
new_theme_dir.path if new_theme_dir else None)
reloaded = self._reload_theme_for_rotation()
if reloaded is not None:
image = reloaded
elif old_canvas != svc.canvas_size:
self.log.info("set_rotation: canvas changed %s→%s but theme dir "
"unchanged — pixel-rotating only",
if old_canvas != svc.canvas_size:
self.refresh_dirs()

if old_canvas != svc.canvas_size:
self.log.info("set_rotation: canvas changed %s→%s, dirs refreshed",
old_canvas, svc.canvas_size)

w, h = self.lcd_size
Expand Down
8 changes: 4 additions & 4 deletions src/trcc/core/device/lcd_theme_workflow.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ def select(self, theme: Any) -> dict:

def load_by_name(self, name: str, width: int = 0, height: int = 0) -> dict:
d = self._device
w, h = (width, height) if width and height else d.lcd_size
w, h = (width, height) if width and height else d.canvas_size
td = d.theme_dir
theme_dir = td.path if td else Path(resolve_theme_dir(w, h))
utd = d.user_theme_dir
Expand Down Expand Up @@ -131,7 +131,7 @@ def set_mask_from_path(self, path: Any) -> dict:
from ...services.image import ImageService
from ...services.overlay import OverlayService
r = ImageService.renderer()
w, h = d.lcd_size
w, h = d.canvas_size
mask_img = OverlayService.load_mask_from_path(p, r, w, h)
if mask_img is None:
return {"success": False, "error": f"Failed to load mask: {path}"}
Expand Down Expand Up @@ -197,7 +197,7 @@ def _resolve_restore_theme(self, cfg: dict) -> dict:
if not theme_name:
return {"error": "No saved theme", "success": False}

w, h = d.lcd_size
w, h = d.canvas_size
svc = d._display_svc

if theme_type == "cloud":
Expand Down Expand Up @@ -309,7 +309,7 @@ def reload_theme_for_rotation(self) -> Any | None:
return None
theme_name = current.name
svc = d._display_svc
for base in (svc.local_dir, svc.web_dir):
for base in (svc.user_theme_dir, svc.local_dir, svc.web_dir):
if not base:
continue
candidate = Path(base) / theme_name
Expand Down
5 changes: 3 additions & 2 deletions src/trcc/services/display.py
Original file line number Diff line number Diff line change
Expand Up @@ -744,7 +744,7 @@ def save_theme(self, name: str) -> tuple[bool, str]:
else:
return False, "No data directory configured"
ok, msg = ThemePersistence.save(
name, data_dir, self.lcd_size,
name, data_dir, self.canvas_size,
current_image=self._clean_background or self.current_image,
overlay=self.overlay,
mask_source_dir=self._mask_source_dir,
Expand All @@ -755,7 +755,8 @@ def save_theme(self, name: str) -> tuple[bool, str]:
if ok:
safe_name = f'Custom_{name}' if not name.startswith('Custom_') else name
from ..core.paths import theme_dir_name
self.current_theme_path = data_dir / theme_dir_name(self.lcd_width, self.lcd_height) / safe_name
cw, ch = self.canvas_size
self.current_theme_path = data_dir / theme_dir_name(cw, ch) / safe_name
return ok, msg

def export_config(self, export_path: Path) -> tuple[bool, str]:
Expand Down
10 changes: 10 additions & 0 deletions src/trcc/ui/cli/_display.py
Original file line number Diff line number Diff line change
Expand Up @@ -297,6 +297,8 @@ def screencast(builder=None, *, device=None, x=0, y=0, w=0, h=0, fps=10, preview
def send_image(image_path, *, lcd: int = 0, device=None, preview=False):
"""Send image to LCD."""
from pathlib import Path
if (rc := _connect_or_fail(device)):
return rc
return _emit(trcc().lcd.send_image(lcd, Path(image_path)))


Expand All @@ -305,22 +307,30 @@ def send_color(hex_color, *, lcd: int = 0, device=None, preview=False):
if not (rgb := _parse_hex(hex_color)):
typer.echo("Error: Invalid hex color. Use format: ff0000", err=True)
return 1
if (rc := _connect_or_fail(device)):
return rc
r, g, b = rgb
return _emit(trcc().lcd.send_color(lcd, r, g, b))


def set_brightness(level, *, lcd: int = 0, device=None):
"""Set display brightness level (1=25%, 2=50%, 3=100%)."""
if (rc := _connect_or_fail(device)):
return rc
return _emit(trcc().lcd.set_brightness(lcd, level))


def set_rotation(degrees, *, lcd: int = 0, device=None):
"""Set display rotation (0, 90, 180, 270)."""
if (rc := _connect_or_fail(device)):
return rc
return _emit(trcc().lcd.set_rotation(lcd, degrees))


def set_split_mode(mode, *, lcd: int = 0, device=None, preview=False):
"""Set split mode (Dynamic Island) for widescreen displays."""
if (rc := _connect_or_fail(device)):
return rc
return _emit(trcc().lcd.set_split_mode(lcd, mode))


Expand Down
69 changes: 69 additions & 0 deletions src/trcc/ui/gui/lcd_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -353,6 +353,7 @@ def _select_theme_from_path(self, path: Path, persist: bool = True,
Settings.save_device_settings(
self._device_key,
theme_name=path.name, theme_type='local', mask_id='')
self._save_rotation_theme(path.name, 'local')
elif persist and not self._device_key:
self.log.warning("_select_theme_from_path: not persisting — device_key is empty")

Expand All @@ -375,6 +376,7 @@ def select_cloud_theme(self, theme_info: Any) -> None:
Settings.save_device_settings(
self._device_key,
theme_name=video_path.stem, theme_type='cloud')
self._save_rotation_theme(video_path.stem, 'cloud')

def apply_mask(self, mask_info: Any) -> None:
"""Apply mask overlay on top of current content."""
Expand Down Expand Up @@ -671,6 +673,73 @@ def set_rotation(self, degrees: int) -> None:
self._w['preview'].set_image(image)
self._update_theme_directories()
self._reload_cloud_theme_for_rotation()
# Restore last theme used at this rotation (per-rotation theme memory)
self._restore_rotation_theme(degrees)

def _save_rotation_theme(self, theme_name: str, theme_type: str) -> None:
"""Save current theme under its rotation key for per-rotation memory."""
if not self._device_key:
return
rotation = self._lcd.rotation
cfg = Settings.get_device_config(self._device_key) or {}
rotation_themes = dict(cfg.get('rotation_themes', {}))
rotation_themes[str(rotation)] = {'theme_name': theme_name, 'theme_type': theme_type}
Settings.save_device_settings(self._device_key, rotation_themes=rotation_themes)
self.log.debug("_save_rotation_theme: rotation=%d theme=%s type=%s",
rotation, theme_name, theme_type)

def _restore_rotation_theme(self, degrees: int) -> None:
"""Load the last theme used at this rotation, if saved."""
if not self._device_key:
return
cfg = Settings.get_device_config(self._device_key) or {}
rotation_themes = cfg.get('rotation_themes', {})
saved = rotation_themes.get(str(degrees))
if not saved:
return
theme_name = saved.get('theme_name')
theme_type = saved.get('theme_type', 'local')
if not theme_name:
self._load_first_available_theme()
return
self.log.info("_restore_rotation_theme: rotation=%d theme=%s type=%s",
degrees, theme_name, theme_type)
lcd = self._lcd
if theme_type == 'cloud':
web_dir = lcd.web_dir
if web_dir:
mp4 = web_dir / f"{theme_name}.mp4"
png = web_dir / f"{theme_name}.png"
if mp4.exists():
from trcc.services.theme import ThemeInfo
theme = ThemeInfo.from_video(mp4, png if png.exists() else None)
self._select_theme(theme)
return
else:
for base in (lcd._display_svc.user_theme_dir, lcd._display_svc.local_dir):
if not base:
continue
candidate = base / theme_name
if candidate.exists():
self._select_theme_from_path(candidate, persist=False)
return
# Fallback: load first available theme for this rotation
self._load_first_available_theme()

def _load_first_available_theme(self) -> None:
"""Load the first available theme for the current rotation as a fallback."""
lcd = self._lcd
for base in (lcd._display_svc.user_theme_dir, lcd._display_svc.local_dir):
if not base:
continue
try:
for item in sorted(base.iterdir()):
if item.is_dir() and (item / '00.png').exists():
self.log.info("_load_first_available_theme: loading %s", item)
self._select_theme_from_path(item, persist=False)
return
except Exception:
pass

def _reload_cloud_theme_for_rotation(self) -> None:
"""If a cloud video is active on a non-square device, load the
Expand Down