diff --git a/src/trcc/core/device/lcd.py b/src/trcc/core/device/lcd.py index 5e3653d1..bec7874e 100644 --- a/src/trcc/core/device/lcd.py +++ b/src/trcc/core/device/lcd.py @@ -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 @@ -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}"} @@ -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: @@ -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"} # ══════════════════════════════════════════════════════════════════════ @@ -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: @@ -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 diff --git a/src/trcc/core/device/lcd_theme_workflow.py b/src/trcc/core/device/lcd_theme_workflow.py index 149deeca..0c649a42 100644 --- a/src/trcc/core/device/lcd_theme_workflow.py +++ b/src/trcc/core/device/lcd_theme_workflow.py @@ -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 @@ -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}"} @@ -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": @@ -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 diff --git a/src/trcc/services/display.py b/src/trcc/services/display.py index f1cab1d7..236a2216 100644 --- a/src/trcc/services/display.py +++ b/src/trcc/services/display.py @@ -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, @@ -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]: diff --git a/src/trcc/ui/cli/_display.py b/src/trcc/ui/cli/_display.py index 55ec07b8..bce6cb63 100644 --- a/src/trcc/ui/cli/_display.py +++ b/src/trcc/ui/cli/_display.py @@ -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))) @@ -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)) diff --git a/src/trcc/ui/gui/lcd_handler.py b/src/trcc/ui/gui/lcd_handler.py index eb862457..75563d17 100644 --- a/src/trcc/ui/gui/lcd_handler.py +++ b/src/trcc/ui/gui/lcd_handler.py @@ -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") @@ -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.""" @@ -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