diff --git a/docs/widgets.md b/docs/widgets.md index cad7c96..ba36973 100644 --- a/docs/widgets.md +++ b/docs/widgets.md @@ -54,6 +54,13 @@ a button press does. `PhotoWidgetConfig` mirrors `app/photo_queue.py`'s attribute names exactly, so that module's advance/back/queue logic ports across widget instances unchanged. + `PhotoWidgetConfig.locked` (migration 27) freezes `current_asset_id` + against both the timer-elapsed auto-advance + (`photo_queue.get_current`) and the advance/back button actions + (`app/widgets/photos.py`'s `ACTIONS`) until unlocked -- toggled via a + "Lock this photo" button in the widget's own dialog + (`POST .../widgets/{id}/lock`), shown as a lock badge on the widget's + box on the Layout tab canvas. `TaskWidgetConfig` used to be a handful of `tasks_*` columns bolted onto `CalendarWidgetConfig` (a week-view-only, single-list task list); split into its own widget type (migration 17) so a task list can be placed diff --git a/server/app/migration.py b/server/app/migration.py index 15a6c18..2eebf17 100644 --- a/server/app/migration.py +++ b/server/app/migration.py @@ -705,6 +705,26 @@ def _migration_26(conn) -> None: conn.execute(text("ALTER TABLE widgets ADD COLUMN border_color_index INTEGER NOT NULL DEFAULT 0")) +def _migration_27(conn) -> None: + """Per-photo-widget lock (models.PhotoWidgetConfig.locked) -- freezes + current_asset_id against both the timer-elapsed auto-advance + (photo_queue.get_current) and the advance/back button actions + (app/widgets/photos.py's ACTIONS) until unlocked. Defaults to + unlocked so existing widgets keep rotating exactly as before. + + Guarded per-column, same reasoning as migration 26's own comment: + photo_widget_configs isn't touched by test_migrations.py's simulated + pre-widget-system replays (unlike calendar/task/widgets tables those + tests DROP and recreate in an old shape), so it keeps the fresh- + install create_all() copy -- which already has this column -- when + those tests replay migrations 17+ from schema_version 16. Without + the guard, replaying this migration there re-adds a column that's + already there and SQLite raises "duplicate column name".""" + existing = {c["name"] for c in inspect(conn).get_columns("photo_widget_configs")} + if "locked" not in existing: + conn.execute(text("ALTER TABLE photo_widget_configs ADD COLUMN locked INTEGER NOT NULL DEFAULT 0")) + + MIGRATIONS = [ (1, _migration_1), (2, _migration_2), @@ -732,6 +752,7 @@ MIGRATIONS = [ (24, _migration_24), (25, _migration_25), (26, _migration_26), + (27, _migration_27), ] diff --git a/server/app/models.py b/server/app/models.py index 2db8ef7..439a4d6 100644 --- a/server/app/models.py +++ b/server/app/models.py @@ -483,6 +483,7 @@ class PhotoWidgetConfig(Base): queue_cursor: Mapped[int] = mapped_column(Integer, default=0) history: Mapped[list] = mapped_column(MutableList.as_mutable(JSON), default=list) excluded_asset_ids: Mapped[list] = mapped_column(MutableList.as_mutable(JSON), default=list) + locked: Mapped[bool] = mapped_column(Boolean, default=False) class CalendarWidgetConfig(Base): diff --git a/server/app/photo_queue.py b/server/app/photo_queue.py index 44c74ae..fd41cd2 100644 --- a/server/app/photo_queue.py +++ b/server/app/photo_queue.py @@ -211,11 +211,18 @@ def get_current(cfg: Frame, assets: list[dict], frame: Frame, in_quiet_hours: bo /api/queue), so without this an open browser tab polling overnight would silently advance the current photo on raw elapsed time alone, even though the device itself is correctly asleep through the - window (see main.py's _effective_refresh_interval_s).""" + window (see main.py's _effective_refresh_interval_s). + + cfg.locked suppresses the elapsed-time trigger the same way + in_quiet_hours does -- a locked widget still needs an initial pick + if it somehow has none (an unconfigured widget just locked, or a + changed album), but once it has a current photo the whole point of + locking is that it stops moving on its own until explicitly + unlocked.""" valid_ids = {a["id"] for a in assets} needs_pick = not cfg.current_asset_id or cfg.current_asset_id not in valid_ids time_elapsed = (time.time() - cfg.current_asset_set_at) >= frame.refresh_interval_s - stale = needs_pick or (time_elapsed and not in_quiet_hours) + stale = needs_pick or (time_elapsed and not in_quiet_hours and not cfg.locked) if not stale: return False advance_forced(cfg, assets, frame) diff --git a/server/app/routers/api_widgets.py b/server/app/routers/api_widgets.py index 2efde8a..e8c1648 100644 --- a/server/app/routers/api_widgets.py +++ b/server/app/routers/api_widgets.py @@ -86,10 +86,11 @@ CALENDAR_COLOR_INDEX_RANGE = range(2, 6) # Yellow/Red/Blue/Green -- see PALETTE MAX_TASKS_NAME_LEN = 40 # a sane on-panel-header length, see calendar_render._draw_tasks -def _widget_dict(w: Widget) -> dict: +def _widget_dict(w: Widget, locked: bool = False) -> dict: return {"id": w.id, "widget_type": w.widget_type, "x": w.x, "y": w.y, "w": w.w, "h": w.h, "sort_order": w.sort_order, "border_style": w.border_style, - "border_thickness": w.border_thickness, "border_color_index": w.border_color_index} + "border_thickness": w.border_thickness, "border_color_index": w.border_color_index, + "locked": locked} def require_widget_view( @@ -141,12 +142,21 @@ def api_widgets_list(request: Request, frame: Frame = Depends(require_frame_view widgets = db.scalars( select(Widget).where(Widget.frame_id == frame.id).order_by(Widget.sort_order) ).all() + # Layout canvas needs to know which photo widgets are locked (to draw + # the lock badge) -- a per-type config field, not on Widget itself, + # so it's a separate lookup rather than something _widget_dict can + # read straight off the row it's given. + photo_widget_ids = [w.id for w in widgets if w.widget_type == "photos"] + locked_by_widget_id = dict(db.execute( + select(PhotoWidgetConfig.widget_id, PhotoWidgetConfig.locked) + .where(PhotoWidgetConfig.widget_id.in_(photo_widget_ids)) + ).all()) if photo_widget_ids else {} return { "orientation": frame.orientation, "grid": {"cols": cols, "rows": rows}, "widget_types": list(WIDGET_TYPES.keys()), "min_footprint": grid.MIN_FOOTPRINT, - "widgets": [_widget_dict(w) for w in widgets], + "widgets": [_widget_dict(w, locked_by_widget_id.get(w.id, False)) for w in widgets], "control": { "controller": (frame.controlled_by.display_name or frame.controlled_by.username) if frame.controlled_by else None, "you": frame.controlled_by_user_id == user.id, @@ -486,6 +496,7 @@ def api_widget_queue( photo_queue.sync_queue_length(locked_pcfg, assets) current_asset_id = locked_pcfg.current_asset_id queue = list(locked_pcfg.queue) + locked = locked_pcfg.locked controller_id = locked_frame.controlled_by_user_id controller = ( (locked_frame.controlled_by.display_name or locked_frame.controlled_by.username) @@ -498,6 +509,7 @@ def api_widget_queue( return { "current": entry(current_asset_id) if current_asset_id else None, "upcoming": [entry(asset_id) for asset_id in queue], + "locked": locked, "control": {"controller": controller, "you": controller_id == user.id}, } @@ -567,6 +579,26 @@ def api_widget_queue_remove( return {"status": "removed"} +class QueueLockRequest(BaseModel): + locked: bool + + +@router.post("/api/frames/{frame_id}/widgets/{widget_id}/lock") +def api_widget_queue_lock( + body: QueueLockRequest, frame_widget: tuple[Frame, Widget] = Depends(require_widget_control), + db: Session = Depends(get_db), +): + """Freezes/unfreezes current_asset_id (models.PhotoWidgetConfig.locked) + -- while locked, neither the timer-elapsed auto-advance + (photo_queue.get_current) nor the advance/back button actions + (app/widgets/photos.py) change which photo is showing.""" + frame, widget = frame_widget + _require_widget_type(widget, "photos") + with widget_locked(db, frame.id, widget.id) as (_, _, cfg): + cfg.locked = body.locked + return {"status": "saved", "locked": body.locked} + + @router.get("/api/frames/{frame_id}/widgets/{widget_id}/thumbnail/{asset_id}") def api_widget_thumbnail( asset_id: str, frame_widget: tuple[Frame, Widget] = Depends(require_widget_view), diff --git a/server/app/static/frame_layout.js b/server/app/static/frame_layout.js index 6f47e7b..8ed4ad1 100644 --- a/server/app/static/frame_layout.js +++ b/server/app/static/frame_layout.js @@ -210,6 +210,14 @@ function renderCanvas() { label.textContent = WIDGET_LABELS[widget.widget_type] || widget.widget_type; box.appendChild(label); + if (widget.locked) { + const lockBadge = document.createElement('span'); + lockBadge.className = 'widget-box-lock-badge'; + lockBadge.textContent = '\u{1F512}'; // lock emoji -- open the gear icon to unlock + lockBadge.title = 'Locked -- won\'t change until unlocked in this widget\'s settings'; + box.appendChild(lockBadge); + } + const settingsBtn = document.createElement('button'); settingsBtn.type = 'button'; settingsBtn.className = 'widget-box-settings'; @@ -343,6 +351,7 @@ document.getElementById('widget-dialog').addEventListener('close', () => { openDialogWidgetType = null; window.FRAME_API = window.FRAME_BASE_API; document.getElementById('widget-dialog-body').innerHTML = ''; + loadWidgets(); // picks up anything the dialog changed that the canvas shows (e.g. the lock badge) }); loadWidgets(); diff --git a/server/app/static/theme.css b/server/app/static/theme.css index 5d0e187..e1dbff6 100644 --- a/server/app/static/theme.css +++ b/server/app/static/theme.css @@ -477,6 +477,20 @@ button.secondary:hover { background: var(--surface-alt); } .widget-box-remove { right: 4px; } .widget-box-settings { right: 28px; } .widget-box-remove:hover, .widget-box-settings:hover { background: var(--overlay-hover); } +.widget-box-lock-badge { + position: absolute; + bottom: 4px; + left: 4px; + width: 20px; + height: 20px; + border-radius: 50%; + background: var(--overlay); + color: #fff; + font-size: 11px; + line-height: 20px; + text-align: center; + pointer-events: none; /* passive indicator, not a control -- toggled from the widget's own dialog */ +} .widget-box-resize-handle { position: absolute; bottom: 0; diff --git a/server/app/static/widget_dialog_photos.js b/server/app/static/widget_dialog_photos.js index db81a0b..72af251 100644 --- a/server/app/static/widget_dialog_photos.js +++ b/server/app/static/widget_dialog_photos.js @@ -8,6 +8,33 @@ // FRAME_API + a global loadQueue()" contract queue.js has always had. let photosPollTimer = null; +let photoLocked = false; +let photoHasCurrent = false; + +function renderLockButton() { + const btn = document.getElementById('lock-photo-btn'); + if (!btn) return; + btn.textContent = photoLocked ? 'Unlock this photo' : 'Lock this photo'; + btn.classList.toggle('active', photoLocked); + btn.disabled = !photoHasCurrent && !photoLocked; // nothing displayed yet to lock +} + +async function toggleLock() { + const next = !photoLocked; + try { + const resp = await fetch(`${window.FRAME_API}/lock`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ locked: next }), + }); + if (!resp.ok) throw new Error(await apiError(resp)); + photoLocked = next; + renderLockButton(); + showStatus(true, photoLocked ? 'Locked -- this photo will stay put.' : 'Unlocked.'); + } catch (e) { + showStatus(false, e.message); + } +} async function loadQueue() { if (dragState) { @@ -21,9 +48,14 @@ async function loadQueue() { currentEl.innerHTML = '
Not available yet -- the owner needs to connect Immich (Settings) and pick an album.
'; renderUpcoming([]); + photoHasCurrent = false; + renderLockButton(); return; } const data = await resp.json(); + photoLocked = !!data.locked; + photoHasCurrent = !!data.current; + renderLockButton(); currentEl.innerHTML = ''; if (data.current) { const wrap = document.createElement('div'); @@ -109,6 +141,8 @@ function initPhotosDialog() { } }); + document.getElementById('lock-photo-btn').addEventListener('click', toggleLock); + loadQueue(); // Slow poll: picks up real changes (new photo displayed, queue edited // from elsewhere) without a manual refresh. Skipped mid-drag. diff --git a/server/app/templates/_widget_dialog_photos.html b/server/app/templates/_widget_dialog_photos.html index 8296682..5728eba 100644 --- a/server/app/templates/_widget_dialog_photos.html +++ b/server/app/templates/_widget_dialog_photos.html @@ -46,6 +46,10 @@Loading...
While locked, this photo stays + on screen -- the refresh timer and the next/back buttons won't + change it until you unlock it.