diff --git a/server/app.py b/server/app.py index 1784c46..74619ab 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": {}, + "transition_style": "ltr", + "transition_speed": 15, "installed_apps": [ "time", "date", "weather", "stocks", "sports", "countdown", "world_clock", "crypto", "iss", "metro", "youtube", "yt_comments", @@ -627,6 +629,127 @@ 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 + # 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 + 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 + # 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: + 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_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 + 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))) + last_transition_style = style + last_transition_speed = speed + if style == 'sync': + return send_to_display_sync(page_text) + 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): global current_indices, current_display_string, is_homed if not text: @@ -654,6 +777,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: @@ -673,9 +802,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 @@ -992,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 = [] @@ -1029,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: @@ -1106,12 +1235,18 @@ 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 + 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_to_display(page_text, page_order, raw=is_anim, step_delay_ms=page_speed) + 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) @@ -1144,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() @@ -1207,18 +1343,25 @@ 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 + 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: - max_dist = send_to_display(page_text, page_order, raw=is_anim, step_delay_ms=page_speed) + 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) @@ -1257,7 +1400,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=last_transition_style, + transition_speed=last_transition_speed) @app.route('/grid_config') def grid_config(): diff --git a/server/static/app.js b/server/static/app.js index c22afcd..c8b140a 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'){ @@ -149,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'); @@ -237,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) @@ -704,11 +811,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 +1959,14 @@ 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 +2207,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,6 +2222,16 @@ function saveGlobal(){ }); } +function updateTransitionSpeedDefault(style){ + const speedEl = document.getElementById('transitionSpeed'); + 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(){ fetch('/toggle_autohome',{method:'POST',headers:{'Content-Type':'application/json'}, body:JSON.stringify({enabled:document.getElementById('autoHomeToggle').checked})}); @@ -2570,10 +2699,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()); @@ -2830,6 +2963,13 @@ function renderAppPlaylistEntries(){ s + + `; diff --git a/server/templates/index.html b/server/templates/index.html index 7fc2201..833834e 100644 --- a/server/templates/index.html +++ b/server/templates/index.html @@ -16,7 +16,7 @@
Splitflap OS - v0.1.12 + v{{ version }}
@@ -47,6 +47,19 @@ +
+ + +
+
@@ -187,6 +200,21 @@

Settings

+
+

Transition Style

+

Default style and speed for all display transitions.

+
+
+ + +
+
+ + +
+
+