From ba850c5b3cc9d3682dc55dd23ae40cc864fd9666 Mon Sep 17 00:00:00 2001 From: Chris Date: Wed, 13 May 2026 18:48:43 -0500 Subject: [PATCH 1/7] Add global flap transition effects (wave, sync, slot machine) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three new effects selectable in Settings > Transition Effect: - Wave: modules stagger L→R with configurable ms between each - Sync: all modules arrive simultaneously (farthest starts first) - Slot: all spin to random chars, then lock in L→R Effects apply globally to all display transitions. No firmware changes required — all timing is controlled Pi-side. Co-Authored-By: Claude Sonnet 4.6 --- server/app.py | 113 +++++++++++++++++++++++++++++++++++- server/static/app.js | 6 ++ server/templates/index.html | 19 ++++++ 3 files changed, 136 insertions(+), 2 deletions(-) diff --git a/server/app.py b/server/app.py index 1784c46..2122f45 100644 --- a/server/app.py +++ b/server/app.py @@ -122,6 +122,8 @@ def load_settings(): "notify_enabled": False, "notify_display_seconds": 10, "notify_sources": {}, + "flap_effect": "none", + "flap_effect_speed": 80, "installed_apps": [ "time", "date", "weather", "stocks", "sports", "countdown", "world_clock", "crypto", "iss", "metro", "youtube", "yt_comments", @@ -627,6 +629,113 @@ def m(r, c): return r * cols + c '\U0001f7e6': 'b', '\U0001f7ea': 'p', '\u2b1c': 'w', '\u2b1b': ' ', } +def send_to_display_sync(text): + """Send modules staggered so all arrive at their target character simultaneously.""" + global current_indices, current_display_string, is_homed + if not text: + return 0 + clean_text = text.upper() + for emoji, char in COLOR_MAP.items(): + clean_text = clean_text.replace(emoji, char) + clean_text = clean_text.replace('"', 'q') + n = get_module_count() + clean_text = clean_text.ljust(n)[:n] + logging.info(f"DISPLAY (sync): {clean_text}") + + dists = [] + for i in range(n): + char = clean_text[i] + target_idx = FLAP_CHARS.find(char) + if target_idx == -1: + target_idx = 0 + dist = 128 if current_indices[i] == -1 else (target_idx - current_indices[i]) % 64 + dists.append((i, char, target_idx, dist)) + + max_dist = max(d[3] for d in dists) if dists else 0 + dists_sorted = sorted(dists, key=lambda x: -x[3]) + + t0 = time.time() + with serial_lock: + for i, char, target_idx, dist in dists_sorted: + delay_before = (max_dist - dist) * (4.0 / 64.0) + elapsed = time.time() - t0 + remaining = delay_before - elapsed + if remaining > 0: + time.sleep(remaining) + if ser and not sim_mode: + ser.write(f"m{i:02d}-{char}\n".encode()) + ser.flush() + current_indices[i] = target_idx + + current_display_string = clean_text + is_homed = True + mqtt_publish_state() + return max_dist + + +def send_to_display_slot(text, effect_speed=80): + """Slot machine: all modules spin to random chars, then lock in L→R.""" + global current_indices, current_display_string, is_homed + if not text: + return 0 + clean_text = text.upper() + for emoji, char in COLOR_MAP.items(): + clean_text = clean_text.replace(emoji, char) + clean_text = clean_text.replace('"', 'q') + n = get_module_count() + clean_text = clean_text.ljust(n)[:n] + logging.info(f"DISPLAY (slot): {clean_text}") + + # Phase 1: all modules spin to random intermediate chars simultaneously + spin_chars = [FLAP_CHARS[random.randint(1, len(FLAP_CHARS) - 5)] for _ in range(n)] + with serial_lock: + for i in range(n): + if ser and not sim_mode: + ser.write(f"m{i:02d}-{spin_chars[i]}\n".encode()) + ser.flush() + idx = FLAP_CHARS.find(spin_chars[i]) + current_indices[i] = idx if idx != -1 else 0 + + time.sleep(1.5) + + # Phase 2: lock in final chars L→R + max_dist = 0 + with serial_lock: + for i in range(n): + char = clean_text[i] + if ser and not sim_mode: + ser.write(f"m{i:02d}-{char}\n".encode()) + ser.flush() + time.sleep(effect_speed / 1000.0) + target_idx = FLAP_CHARS.find(char) + if target_idx == -1: + target_idx = 0 + dist = (target_idx - current_indices[i]) % 64 + if dist > max_dist: + max_dist = dist + current_indices[i] = target_idx + + current_display_string = clean_text + is_homed = True + mqtt_publish_state() + return max_dist + + +def _send_with_effect(page_text, page_order, page_speed, is_anim): + """Dispatch a page send through the active global flap effect.""" + if is_anim: + return send_to_display(page_text, page_order, raw=True, step_delay_ms=page_speed) + effect = settings.get('flap_effect', 'none') + effect_speed = int(settings.get('flap_effect_speed', 80)) + if effect == 'wave': + return send_to_display(page_text, get_animation_order('ltr'), step_delay_ms=effect_speed) + elif effect == 'sync': + return send_to_display_sync(page_text) + elif effect == 'slot': + return send_to_display_slot(page_text, effect_speed=effect_speed) + return send_to_display(page_text, page_order, step_delay_ms=page_speed) + + def send_to_display(text, order=None, raw=False, step_delay_ms=15): global current_indices, current_display_string, is_homed if not text: @@ -1111,7 +1220,7 @@ def _run_app_playlist(): page_delay = float(page.get('delay', eff_delay)) if isinstance(page, dict) else eff_delay if is_anim or page_text != last_sent_page: - max_dist = send_to_display(page_text, page_order, raw=is_anim, step_delay_ms=page_speed) + max_dist = _send_with_effect(page_text, page_order, page_speed, is_anim) last_sent_page = page_text rotation_time = max_dist * (4.0 / 64.0) @@ -1218,7 +1327,7 @@ def playlist_loop(): max_dist = 0 # Animations always resend each frame; other apps skip unchanged pages if is_anim or page_text != last_sent_page: - max_dist = send_to_display(page_text, page_order, raw=is_anim, step_delay_ms=page_speed) + max_dist = _send_with_effect(page_text, page_order, page_speed, is_anim) last_sent_page = page_text rotation_time = max_dist * (4.0 / 64.0) diff --git a/server/static/app.js b/server/static/app.js index c22afcd..b9a3715 100644 --- a/server/static/app.js +++ b/server/static/app.js @@ -2103,6 +2103,12 @@ function saveGlobal(){ }); } +function toggleFlapEffectSpeed(){ + const v = document.getElementById('flapEffect'); + const wrap = document.getElementById('flapEffectSpeedWrap'); + if(v && wrap) wrap.style.display = v.value !== 'none' ? 'flex' : 'none'; +} + function toggleAutoHome(){ fetch('/toggle_autohome',{method:'POST',headers:{'Content-Type':'application/json'}, body:JSON.stringify({enabled:document.getElementById('autoHomeToggle').checked})}); diff --git a/server/templates/index.html b/server/templates/index.html index 7fc2201..bb1bf2e 100644 --- a/server/templates/index.html +++ b/server/templates/index.html @@ -187,6 +187,25 @@

Settings

+
+

Transition Effect

+

Controls how modules rotate when new text is displayed.

+
+
+ + +
+ +
+
From cb27217f01c7ee92c624d6eebbdbcc0859e9e3b3 Mon Sep 17 00:00:00 2001 From: Chris Date: Wed, 13 May 2026 19:04:12 -0500 Subject: [PATCH 2/7] Fix transition effect save button not triggering on change Co-Authored-By: Claude Sonnet 4.6 --- server/templates/index.html | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/server/templates/index.html b/server/templates/index.html index bb1bf2e..d709c33 100644 --- a/server/templates/index.html +++ b/server/templates/index.html @@ -193,7 +193,7 @@

Transition Effect

- @@ -202,7 +202,7 @@

Transition Effect

From 3fdce7d855f9f952832e482cde77bda9c153e2b6 Mon Sep 17 00:00:00 2001 From: Chris Date: Thu, 14 May 2026 09:21:42 -0500 Subject: [PATCH 3/7] Unified transition style/speed at global, app, and playlist levels - Collapses flap_effect (wave/sync/slot) into transition_style, which now includes all 13 order-based styles plus sync and slot - Removes flap_effect/flap_effect_speed settings entirely - Global default: transition_style + transition_speed in Settings - Per-app override: _send_with_effect checks plugin_{id}_transition_style - Per-page: compose tab and app playlist compose entries have style/speed controls - _send_with_effect now takes page_style string (not pre-converted order) and resolves priority: per-page > per-app > global Closes #34 Co-Authored-By: Claude Sonnet 4.6 --- server/app.py | 40 ++++++++++++++++++++----------------- server/static/app.js | 34 ++++++++++++++++++++++--------- server/templates/index.html | 35 ++++++++++++++++++++------------ 3 files changed, 69 insertions(+), 40 deletions(-) diff --git a/server/app.py b/server/app.py index 2122f45..84d20a4 100644 --- a/server/app.py +++ b/server/app.py @@ -122,8 +122,8 @@ def load_settings(): "notify_enabled": False, "notify_display_seconds": 10, "notify_sources": {}, - "flap_effect": "none", - "flap_effect_speed": 80, + "transition_style": "ltr", + "transition_speed": 15, "installed_apps": [ "time", "date", "weather", "stocks", "sports", "countdown", "world_clock", "crypto", "iss", "metro", "youtube", "yt_comments", @@ -721,19 +721,22 @@ def send_to_display_slot(text, effect_speed=80): return max_dist -def _send_with_effect(page_text, page_order, page_speed, is_anim): - """Dispatch a page send through the active global flap effect.""" +def _send_with_effect(page_text, page_style, page_speed, is_anim, app_id=None): + """Dispatch a page send using the active transition style (per-page > per-app > global).""" if is_anim: - return send_to_display(page_text, page_order, raw=True, step_delay_ms=page_speed) - effect = settings.get('flap_effect', 'none') - effect_speed = int(settings.get('flap_effect_speed', 80)) - if effect == 'wave': - return send_to_display(page_text, get_animation_order('ltr'), step_delay_ms=effect_speed) - elif effect == 'sync': + return send_to_display(page_text, get_animation_order(page_style or 'ltr'), raw=True, step_delay_ms=page_speed) + # Priority: per-page > per-app > global + style = page_style or \ + (settings.get(f'plugin_{app_id}_transition_style') if app_id else None) or \ + settings.get('transition_style', 'ltr') + app_speed = settings.get(f'plugin_{app_id}_transition_speed') if app_id else None + speed = page_speed if page_speed is not None else \ + (int(app_speed) if app_speed else int(settings.get('transition_speed', 15))) + if style == 'sync': return send_to_display_sync(page_text) - elif effect == 'slot': - return send_to_display_slot(page_text, effect_speed=effect_speed) - return send_to_display(page_text, page_order, step_delay_ms=page_speed) + if style == 'slot': + return send_to_display_slot(page_text, effect_speed=speed) + return send_to_display(page_text, get_animation_order(style), step_delay_ms=speed) def send_to_display(text, order=None, raw=False, step_delay_ms=15): @@ -1215,12 +1218,12 @@ def _run_app_playlist(): if stop_event.is_set() or time.time() >= deadline: break page_text = page.get('text', '') if isinstance(page, dict) else page - page_order = get_animation_order(page.get('style', 'ltr')) if isinstance(page, dict) else active_order + page_style = page.get('style') if isinstance(page, dict) else None page_speed = int(page.get('speed', 15)) if isinstance(page, dict) else 15 page_delay = float(page.get('delay', eff_delay)) if isinstance(page, dict) else eff_delay if is_anim or page_text != last_sent_page: - max_dist = _send_with_effect(page_text, page_order, page_speed, is_anim) + max_dist = _send_with_effect(page_text, page_style if not is_anim else (settings.get('anim_style','ltr')), page_speed, is_anim, app_id=reg) last_sent_page = page_text rotation_time = max_dist * (4.0 / 64.0) @@ -1316,18 +1319,19 @@ def playlist_loop(): if isinstance(page, dict): page_text = page.get('text', '') page_delay = float(page.get('delay', eff_delay)) - page_order = get_animation_order(page.get('style', 'ltr')) + page_style = page.get('style') page_speed = int(page.get('speed', 15)) else: page_text = page page_delay = eff_delay - page_order = active_order + page_style = None page_speed = 15 max_dist = 0 # Animations always resend each frame; other apps skip unchanged pages if is_anim or page_text != last_sent_page: - max_dist = _send_with_effect(page_text, page_order, page_speed, is_anim) + anim_style = settings.get('anim_style', 'ltr') if is_anim else None + max_dist = _send_with_effect(page_text, anim_style or page_style, page_speed, is_anim, app_id=reg_key) last_sent_page = page_text rotation_time = max_dist * (4.0 / 64.0) diff --git a/server/static/app.js b/server/static/app.js index b9a3715..248d0f2 100644 --- a/server/static/app.js +++ b/server/static/app.js @@ -50,6 +50,8 @@ const TRANSITION_STYLES = [ {v:'spiral', l:'Spiral'}, {v:'columns', l:'Columns'}, {v:'alternating', l:'Alt (↔↔↔)'}, + {v:'sync', l:'Synchronized arrival'}, + {v:'slot', l:'Slot machine'}, ]; function buildStyleOptions(selected='ltr'){ @@ -704,11 +706,13 @@ function removeFromPlaylist(idx){ } function sync(){ + const style = document.getElementById('composeStyleInput')?.value || 'ltr'; + const speed = parseInt(document.getElementById('composeSpeedInput')?.value) || 15; const pages = [{ text: updatePreview(), delay: 5, - style: 'ltr', - speed: 15, + style, + speed, }]; fetch('/update_playlist',{method:'POST',headers:{'Content-Type':'application/json'}, body:JSON.stringify({pages, delay: 5})}); @@ -1850,6 +1854,11 @@ function loadSettingsData(){ // Currency symbol const currencyEl = document.getElementById('currencySymbol'); if(currencyEl) currencyEl.value = data.currency_symbol || '$'; + // Transition style settings + const transStyle = document.getElementById('transitionStyle'); + if(transStyle){ transStyle.value = data.transition_style || 'ltr'; updateTransitionSpeedDefault(transStyle.value); } + const transSpeed = document.getElementById('transitionSpeed'); + if(transSpeed) transSpeed.value = data.transition_speed || 15; // Global timezone picker const tzEl = document.getElementById('globalTzPicker'); if(tzEl){ @@ -2090,6 +2099,8 @@ function saveGlobal(){ notify_enabled: document.getElementById('notifyEnabled').checked, notify_display_seconds: parseInt(document.getElementById('notifyDisplaySeconds').value) || 10, currency_symbol: document.getElementById('currencySymbol')?.value || '$', + transition_style: document.getElementById('transitionStyle') ? document.getElementById('transitionStyle').value : 'ltr', + transition_speed: parseInt(document.getElementById('transitionSpeed') ? document.getElementById('transitionSpeed').value : 15) || 15, })}).then(()=>{ initLiveGrids(rows, cols); buildAppsGrid(); // re-check compatibility after grid change @@ -2103,12 +2114,6 @@ function saveGlobal(){ }); } -function toggleFlapEffectSpeed(){ - const v = document.getElementById('flapEffect'); - const wrap = document.getElementById('flapEffectSpeedWrap'); - if(v && wrap) wrap.style.display = v.value !== 'none' ? 'flex' : 'none'; -} - function toggleAutoHome(){ fetch('/toggle_autohome',{method:'POST',headers:{'Content-Type':'application/json'}, body:JSON.stringify({enabled:document.getElementById('autoHomeToggle').checked})}); @@ -2576,10 +2581,14 @@ function tmFinishEarly(){ // ============================================================ // INIT // ============================================================ -// Populate default transition style select +// Populate transition style selects (function(){ const sel = document.getElementById('styleInput'); if(sel) sel.innerHTML = buildStyleOptions('ltr'); + const ts = document.getElementById('transitionStyle'); + if(ts) ts.innerHTML = buildStyleOptions('ltr'); + const cs = document.getElementById('composeStyleInput'); + if(cs) cs.innerHTML = buildStyleOptions('ltr'); })(); document.querySelectorAll('button[onclick="saveGlobal()"]:not(#settingsFab)').forEach(el => el.remove()); @@ -2836,6 +2845,13 @@ function renderAppPlaylistEntries(){ s + + `; diff --git a/server/templates/index.html b/server/templates/index.html index d709c33..b13565f 100644 --- a/server/templates/index.html +++ b/server/templates/index.html @@ -47,6 +47,19 @@ +
+ + +
+ @@ -188,21 +201,17 @@

Settings

-

Transition Effect

-

Controls how modules rotate when new text is displayed.

-
+

Transition Style

+

Default style and speed for all display transitions.

+
- - + +
-
From 3eba2d7e055d59853e1cc7f26e5ac4577bf6e2b0 Mon Sep 17 00:00:00 2001 From: Chris Date: Thu, 14 May 2026 09:29:54 -0500 Subject: [PATCH 4/7] Set smart speed defaults when transition style changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - slot → 80ms (makes lock-in effect visible) - sync → 0ms (speed is irrelevant, timing is distance-based) - all others → 15ms (fast, near-simultaneous) Co-Authored-By: Claude Sonnet 4.6 --- server/static/app.js | 8 ++++++++ server/templates/index.html | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/server/static/app.js b/server/static/app.js index 248d0f2..8de6d5a 100644 --- a/server/static/app.js +++ b/server/static/app.js @@ -2114,6 +2114,14 @@ function saveGlobal(){ }); } +function updateTransitionSpeedDefault(style){ + const speedEl = document.getElementById('transitionSpeed'); + if(!speedEl) return; + if(style === 'slot') speedEl.value = 80; + else if(style === 'sync') speedEl.value = 0; + else speedEl.value = 15; +} + function toggleAutoHome(){ fetch('/toggle_autohome',{method:'POST',headers:{'Content-Type':'application/json'}, body:JSON.stringify({enabled:document.getElementById('autoHomeToggle').checked})}); diff --git a/server/templates/index.html b/server/templates/index.html index b13565f..f758bde 100644 --- a/server/templates/index.html +++ b/server/templates/index.html @@ -206,7 +206,7 @@

Transition Style

- +
From ad18f815445fa8dce9c89069d2a8e9fd3491deff Mon Sep 17 00:00:00 2001 From: Chris Date: Thu, 14 May 2026 14:17:03 -0500 Subject: [PATCH 5/7] Fix sync first-run stagger, slot spin visibility, sim lag, and version badge - #33: send_to_display_sync treated current_indices=-1 as dist=128, making all modules start simultaneously on first run. Now treats -1 as position 0 so stagger works correctly from the start. - #33: Hide speed input when sync style is selected (speed is irrelevant), applied on settings load and on style change. - #35: Slot machine spin chars could match the target char, making some modules appear to lock in during the spin phase. Now ensures spin char always differs from target. - #36: send_to_display now updates current_display_string and publishes MQTT state before the serial loop, so the browser sim reflects the new text immediately. - Version badge now uses Jinja template variable instead of hardcoded string. Co-Authored-By: Claude Sonnet 4.6 --- server/app.py | 21 ++++++++++++++++----- server/static/app.js | 15 ++++++++++----- server/templates/index.html | 6 +++--- 3 files changed, 29 insertions(+), 13 deletions(-) diff --git a/server/app.py b/server/app.py index 84d20a4..9850435 100644 --- a/server/app.py +++ b/server/app.py @@ -648,7 +648,9 @@ def send_to_display_sync(text): target_idx = FLAP_CHARS.find(char) if target_idx == -1: target_idx = 0 - dist = 128 if current_indices[i] == -1 else (target_idx - current_indices[i]) % 64 + # Treat -1 (pre-home unknown) as position 0 so sync stagger works on first run + current = 0 if current_indices[i] == -1 else current_indices[i] + dist = (target_idx - current) % 64 dists.append((i, char, target_idx, dist)) max_dist = max(d[3] for d in dists) if dists else 0 @@ -687,7 +689,13 @@ def send_to_display_slot(text, effect_speed=80): logging.info(f"DISPLAY (slot): {clean_text}") # Phase 1: all modules spin to random intermediate chars simultaneously - spin_chars = [FLAP_CHARS[random.randint(1, len(FLAP_CHARS) - 5)] for _ in range(n)] + # Ensure spin char differs from target so the lock-in is always visible + spin_chars = [] + for i in range(n): + target_idx = FLAP_CHARS.find(clean_text[i]) + if target_idx == -1: target_idx = 0 + candidates = [c for c in FLAP_CHARS[1:len(FLAP_CHARS)-4] if FLAP_CHARS.find(c) != target_idx] + spin_chars.append(random.choice(candidates) if candidates else FLAP_CHARS[1]) with serial_lock: for i in range(n): if ser and not sim_mode: @@ -766,6 +774,12 @@ def send_to_display(text, order=None, raw=False, step_delay_ms=15): if order is None: order = list(range(n)) + # Update sim state immediately so the browser reflects the new text without waiting + # for the serial loop to complete (fixes sim lag on hardware transitions) + current_display_string = clean_text + is_homed = True + mqtt_publish_state() + max_dist = 0 with serial_lock: for i in order: @@ -785,9 +799,6 @@ def send_to_display(text, order=None, raw=False, step_delay_ms=15): max_dist = dist current_indices[i] = target_idx - current_display_string = clean_text - is_homed = True - mqtt_publish_state() return max_dist diff --git a/server/static/app.js b/server/static/app.js index 8de6d5a..e0bf4f5 100644 --- a/server/static/app.js +++ b/server/static/app.js @@ -1856,7 +1856,10 @@ function loadSettingsData(){ if(currencyEl) currencyEl.value = data.currency_symbol || '$'; // Transition style settings const transStyle = document.getElementById('transitionStyle'); - if(transStyle){ transStyle.value = data.transition_style || 'ltr'; updateTransitionSpeedDefault(transStyle.value); } + if(transStyle){ + transStyle.value = data.transition_style || 'ltr'; + updateTransitionSpeedDefault(transStyle.value); + } const transSpeed = document.getElementById('transitionSpeed'); if(transSpeed) transSpeed.value = data.transition_speed || 15; // Global timezone picker @@ -2116,10 +2119,12 @@ function saveGlobal(){ function updateTransitionSpeedDefault(style){ const speedEl = document.getElementById('transitionSpeed'); - if(!speedEl) return; - if(style === 'slot') speedEl.value = 80; - else if(style === 'sync') speedEl.value = 0; - else speedEl.value = 15; + const speedWrap = document.getElementById('transitionSpeedWrap'); + const composeSpeedWrap = document.getElementById('composeSpeedWrap'); + const isSyncStyle = style === 'sync'; + if(speedEl) speedEl.value = style === 'slot' ? 80 : style === 'sync' ? 0 : 15; + if(speedWrap) speedWrap.style.display = isSyncStyle ? 'none' : ''; + if(composeSpeedWrap) composeSpeedWrap.style.display = isSyncStyle ? 'none' : ''; } function toggleAutoHome(){ diff --git a/server/templates/index.html b/server/templates/index.html index f758bde..833834e 100644 --- a/server/templates/index.html +++ b/server/templates/index.html @@ -16,7 +16,7 @@
Splitflap OS - v0.1.12 + v{{ version }}
@@ -52,7 +52,7 @@ ↔ -
-
+
From c5ee28e1a5a95a01ae8e0adb9fbcac748f46ceae Mon Sep 17 00:00:00 2001 From: Chris Date: Thu, 14 May 2026 18:37:53 -0500 Subject: [PATCH 6/7] Sim reflects active transition style (#29) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - /current_state now returns transition_style and transition_speed - JS getAnimationOrder() mirrors the Python function for all 13 styles - Polling loop applies correct per-module delays based on active style: - Order-based styles (ltr, rtl, diagonal, spiral, etc.): modules start in the configured order with transition_speed ms between each - Sync: shorter-distance modules start later so all arrive simultaneously - Slot: all modules spin to random chars, then lock in L→R after 1.5s Co-Authored-By: Claude Sonnet 4.6 --- server/app.py | 4 +- server/static/app.js | 113 +++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 112 insertions(+), 5 deletions(-) diff --git a/server/app.py b/server/app.py index 9850435..a3f954e 100644 --- a/server/app.py +++ b/server/app.py @@ -1381,7 +1381,9 @@ def current_state(): return jsonify(is_homed=is_homed, state=current_display_string, active_app=active_app, active_app_playlist=active_app_playlist is not None, app_playlist_name=app_playlist_name, - rows=get_rows(), cols=get_cols(), sim_mode=sim_mode, hardware_connected=ser is not None) + rows=get_rows(), cols=get_cols(), sim_mode=sim_mode, hardware_connected=ser is not None, + transition_style=settings.get('transition_style', 'ltr'), + transition_speed=int(settings.get('transition_speed', 15))) @app.route('/grid_config') def grid_config(): diff --git a/server/static/app.js b/server/static/app.js index e0bf4f5..c8b140a 100644 --- a/server/static/app.js +++ b/server/static/app.js @@ -151,6 +151,80 @@ class LiveFlap { let liveFlipSpeedMs = 28; let liveGridRows = 3, liveGridCols = 15; let simMode = false; +let liveTransitionStyle = 'ltr'; +let liveTransitionSpeed = 15; + +function getAnimationOrder(style, rows, cols){ + const total = rows * cols; + const m = (r, c) => r * cols + c; + if(style === 'rtl') return Array.from({length:total},(_,i)=>total-1-i); + if(style === 'rain') return Array.from({length:total},(_,i)=>i); + if(style === 'reverse_rain'){ + const o=[]; + for(let r=rows-1;r>=0;r--) for(let c=0;c=0;c--) for(let r=0;r=0&&c=0&&c=0&&cnew Array(cols).fill(false)); + const o=[]; + let top=0,bottom=rows-1,left=0,right=cols-1; + while(top<=bottom&&left<=right){ + for(let c=left;c<=right;c++) if(!vis[top][c]){vis[top][c]=true;o.push(m(top,c));} + for(let r=top+1;r<=bottom;r++) if(!vis[r][right]){vis[r][right]=true;o.push(m(r,right));} + if(top=left;c--) if(!vis[bottom][c]){vis[bottom][c]=true;o.push(m(bottom,c));} + if(lefttop;r--) if(!vis[r][left]){vis[r][left]=true;o.push(m(r,left));} + top++;bottom--;left++;right--; + } + return o; + } + if(style === 'alternating'){ + const o=[]; + for(let c=0;ci); + for(let i=a.length-1;i>0;i--){ const j=Math.floor(Math.random()*(i+1)); [a[i],a[j]]=[a[j],a[i]]; } + return a; + } + // default ltr + return Array.from({length:total},(_,i)=>i); +} function updateSimModeUI() { const toggle = document.getElementById('simModeToggle'); @@ -239,10 +313,41 @@ setInterval(()=>{ // Animated live display (single grid) const s = data.state || ''; const fa = liveFlaps['control']; - if(fa) for(let i=0; i= 0 ? idx : 0, i * 5); + liveTransitionStyle = data.transition_style || 'ltr'; + liveTransitionSpeed = data.transition_speed || 15; + if(fa){ + const rows = data.rows || liveGridRows, cols = data.cols || liveGridCols; + const style = liveTransitionStyle, speed = liveTransitionSpeed; + if(style === 'sync'){ + const flipTime = liveFlipSpeedMs * 2; + const dists = fa.map((f, i) => { + const tgt = CHAR_MAP.indexOf(s[i] || ' '); + return tgt >= 0 ? (tgt - f.curIdx + 64) % 64 : 0; + }); + const maxDist = Math.max(...dists, 0); + fa.forEach((f, i) => { + const tgt = CHAR_MAP.indexOf(s[i] || ' '); + f.setTarget(tgt >= 0 ? tgt : 0, (maxDist - dists[i]) * flipTime); + }); + } else if(style === 'slot'){ + const SLOT_CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; + const targets = fa.map((_, i) => { const t = CHAR_MAP.indexOf(s[i] || ' '); return t >= 0 ? t : 0; }); + fa.forEach((f, i) => { + let spin; + do { spin = CHAR_MAP.indexOf(SLOT_CHARS[Math.floor(Math.random()*SLOT_CHARS.length)]); } + while(spin === targets[i]); + f.setTarget(spin, 0); + }); + setTimeout(() => { fa.forEach((f, i) => f.setTarget(targets[i], i * speed)); }, 1500); + } else { + const order = getAnimationOrder(style, rows, cols); + const posInOrder = new Array(fa.length).fill(0); + order.forEach((modIdx, pos) => { posInOrder[modIdx] = pos; }); + fa.forEach((f, i) => { + const tgt = CHAR_MAP.indexOf(s[i] || ' '); + f.setTarget(tgt >= 0 ? tgt : 0, posInOrder[i] * Math.max(speed, 1)); + }); + } } // Active app banner (single) From b00c589172a282186dcbde7134cdcad971dd1d85 Mon Sep 17 00:00:00 2001 From: Chris Date: Thu, 14 May 2026 19:14:02 -0500 Subject: [PATCH 7/7] Fix last_transition_style not updating from playlist/compose loops playlist_loop() and _run_app_playlist() were missing global declarations for last_transition_style and last_transition_speed, so assignments were treated as local variables and the module-level globals never updated. Also moved style/speed tracking before the skip-if-unchanged check so the sim reflects the correct style even when the text hasn't changed. Co-Authored-By: Claude Sonnet 4.6 --- server/app.py | 27 +++++++++++++++++++++++---- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/server/app.py b/server/app.py index a3f954e..74619ab 100644 --- a/server/app.py +++ b/server/app.py @@ -731,6 +731,7 @@ def send_to_display_slot(text, effect_speed=80): def _send_with_effect(page_text, page_style, page_speed, is_anim, app_id=None): """Dispatch a page send using the active transition style (per-page > per-app > global).""" + global last_transition_style, last_transition_speed if is_anim: return send_to_display(page_text, get_animation_order(page_style or 'ltr'), raw=True, step_delay_ms=page_speed) # Priority: per-page > per-app > global @@ -740,6 +741,8 @@ def _send_with_effect(page_text, page_style, page_speed, is_anim, app_id=None): app_speed = settings.get(f'plugin_{app_id}_transition_speed') if app_id else None speed = page_speed if page_speed is not None else \ (int(app_speed) if app_speed else int(settings.get('transition_speed', 15))) + last_transition_style = style + last_transition_speed = speed if style == 'sync': return send_to_display_sync(page_text) if style == 'slot': @@ -1115,6 +1118,8 @@ def get_plugin_settings_config(): active_app_playlist = None app_playlist_loop = True app_playlist_name = None +last_transition_style = 'ltr' +last_transition_speed = 15 # ── Notification Interrupts ──────────────────────────────── _notify_queue = [] @@ -1152,6 +1157,7 @@ def _show_notify_message(msg): def _run_app_playlist(): """Execute one pass through the app playlist entries.""" global active_app_playlist, active_app, last_sent_page + global last_transition_style, last_transition_speed entries = active_app_playlist if not entries: @@ -1233,8 +1239,14 @@ def _run_app_playlist(): page_speed = int(page.get('speed', 15)) if isinstance(page, dict) else 15 page_delay = float(page.get('delay', eff_delay)) if isinstance(page, dict) else eff_delay + anim_style_ap = settings.get('anim_style','ltr') if is_anim else None + eff_style_ap = (anim_style_ap or page_style or + (settings.get(f'plugin_{reg}_transition_style') if reg else None) or + settings.get('transition_style', 'ltr')) + last_transition_style = eff_style_ap + last_transition_speed = page_speed if page_speed is not None else int(settings.get('transition_speed', 15)) if is_anim or page_text != last_sent_page: - max_dist = _send_with_effect(page_text, page_style if not is_anim else (settings.get('anim_style','ltr')), page_speed, is_anim, app_id=reg) + max_dist = _send_with_effect(page_text, page_style if not is_anim else anim_style_ap, page_speed, is_anim, app_id=reg) last_sent_page = page_text rotation_time = max_dist * (4.0 / 64.0) @@ -1267,6 +1279,7 @@ def _get_pages_for_app(app_key): def playlist_loop(): global current_playlist, loop_delay, last_sent_page, active_app global active_app_playlist, app_playlist_loop + global last_transition_style, last_transition_speed while True: now = time.time() @@ -1340,8 +1353,14 @@ def playlist_loop(): max_dist = 0 # Animations always resend each frame; other apps skip unchanged pages + anim_style = settings.get('anim_style', 'ltr') if is_anim else None + eff_style = anim_style or page_style or \ + (settings.get(f'plugin_{reg_key}_transition_style') if reg_key else None) or \ + settings.get('transition_style', 'ltr') + eff_speed = page_speed if page_speed is not None else int(settings.get('transition_speed', 15)) + last_transition_style = eff_style + last_transition_speed = eff_speed if is_anim or page_text != last_sent_page: - anim_style = settings.get('anim_style', 'ltr') if is_anim else None max_dist = _send_with_effect(page_text, anim_style or page_style, page_speed, is_anim, app_id=reg_key) last_sent_page = page_text @@ -1382,8 +1401,8 @@ def current_state(): active_app_playlist=active_app_playlist is not None, app_playlist_name=app_playlist_name, rows=get_rows(), cols=get_cols(), sim_mode=sim_mode, hardware_connected=ser is not None, - transition_style=settings.get('transition_style', 'ltr'), - transition_speed=int(settings.get('transition_speed', 15))) + transition_style=last_transition_style, + transition_speed=last_transition_speed) @app.route('/grid_config') def grid_config():