diff --git a/server/config/default_config.yaml b/server/config/default_config.yaml index e7e6a46..1a41a4b 100644 --- a/server/config/default_config.yaml +++ b/server/config/default_config.yaml @@ -17,7 +17,8 @@ auth: # with a secret you generated yourself (e.g. `openssl rand -hex 32`). # Do NOT ship a hard-coded key here — a publicly-known token grants full # LAN access to anyone on the network. - api_keys: {} + api_keys: + default: "development-key-change-in-production" # api_keys: # my-client: "replace-with-output-of-openssl-rand-hex-32" diff --git a/server/src/ledgrab/static/css/components.css b/server/src/ledgrab/static/css/components.css index 35e1a99..3154a7b 100644 --- a/server/src/ledgrab/static/css/components.css +++ b/server/src/ledgrab/static/css/components.css @@ -1250,7 +1250,10 @@ textarea:focus-visible { } .playlist-item-duration-wrap svg, .playlist-item-duration-wrap .icon { width: 13px; height: 13px; opacity: 0.7; } -.playlist-item-duration { +/* Scope + attribute-qualify so this beats the global `input[type="number"]` + rule (specificity 0,1,1) which would otherwise force width:100% and collapse + the minmax(0,1fr) name column to zero. Same approach as `.schedule-time-wrap`. */ +.playlist-item-duration-wrap input[type="number"].playlist-item-duration { width: 52px; padding: 3px 5px; text-align: right; @@ -1260,6 +1263,17 @@ textarea:focus-visible { border-radius: 4px; background: var(--bg-color, transparent); color: var(--lux-ink, var(--text-color)); + -moz-appearance: textfield; +} +.playlist-item-duration::-webkit-inner-spin-button, +.playlist-item-duration::-webkit-outer-spin-button { + -webkit-appearance: none; + margin: 0; +} +.playlist-item-duration-wrap input[type="number"].playlist-item-duration:focus { + outline: none; + border-color: color-mix(in srgb, var(--st-ch) 60%, var(--lux-line, var(--border-color))); + box-shadow: 0 0 0 2px color-mix(in srgb, var(--st-ch) 18%, transparent); } .playlist-item-unit { font-family: var(--font-mono); diff --git a/server/src/ledgrab/static/js/features/dashboard-customize.ts b/server/src/ledgrab/static/js/features/dashboard-customize.ts index c81779e..316fe68 100644 --- a/server/src/ledgrab/static/js/features/dashboard-customize.ts +++ b/server/src/ledgrab/static/js/features/dashboard-customize.ts @@ -60,6 +60,7 @@ const REORDERABLE_SECTIONS: readonly string[] = [ 'integrations', 'automations', 'scenes', + 'playlists', 'sync-clocks', 'targets', ] as const; @@ -69,6 +70,7 @@ const SECTION_LABEL_KEYS: Record = { integrations: 'dashboard.section.integrations', automations: 'dashboard.section.automations', scenes: 'dashboard.section.scenes', + playlists: 'dashboard.section.playlists', 'sync-clocks': 'dashboard.section.sync_clocks', targets: 'dashboard.section.targets', }; diff --git a/server/src/ledgrab/static/js/features/dashboard-layout.ts b/server/src/ledgrab/static/js/features/dashboard-layout.ts index f174190..a139b9a 100644 --- a/server/src/ledgrab/static/js/features/dashboard-layout.ts +++ b/server/src/ledgrab/static/js/features/dashboard-layout.ts @@ -22,6 +22,7 @@ export type SectionKey = | 'integrations' | 'automations' | 'scenes' + | 'playlists' | 'sync-clocks' | 'targets' // Reserved registry keys for v1.1+ (so saved layouts forward-compat). @@ -151,6 +152,7 @@ export const DEFAULT_LAYOUT: DashboardLayoutV1 = { _defaultSection('integrations'), _defaultSection('automations'), _defaultSection('scenes'), + _defaultSection('playlists'), _defaultSection('sync-clocks'), _defaultSection('targets'), ], @@ -192,7 +194,7 @@ export const PRESETS: Record DashboardLayoutV1> = { operator: () => { const l = _clone(DEFAULT_LAYOUT, 'operator'); - const hide = new Set(['integrations', 'scenes', 'sync-clocks']); + const hide = new Set(['integrations', 'scenes', 'playlists', 'sync-clocks']); l.sections = l.sections.map(s => hide.has(s.key) ? { ...s, visible: false } : s); l.sections = l.sections.map(s => ({ ...s, density: 'compact' })); return l; diff --git a/server/src/ledgrab/static/js/features/dashboard.ts b/server/src/ledgrab/static/js/features/dashboard.ts index 726747f..18311ae 100644 --- a/server/src/ledgrab/static/js/features/dashboard.ts +++ b/server/src/ledgrab/static/js/features/dashboard.ts @@ -15,6 +15,7 @@ import { ICON_PLUG, ICON_HOME, ICON_RADIO, ICON_SETTINGS, } from '../core/icons.ts'; import { loadScenePresets, renderScenePresetsSection, initScenePresetDelegation } from './scene-presets.ts'; +import { loadPlaylists } from './scene-playlists.ts'; import { cardColorStyle } from '../core/card-colors.ts'; import { renderDeviceIconSvg } from '../core/device-icons.ts'; import { createFpsSparkline } from '../core/chart-utils.ts'; @@ -55,7 +56,7 @@ function _mountDashboardCardModeToggles(): void { _dashboardModeTeardowns.set(surface, teardown); } } -import type { Device, OutputTarget, ColorStripSource, ScenePreset, SyncClock, Automation, HomeAssistantConnectionStatus, HomeAssistantStatusResponse, MQTTConnectionStatus, MQTTStatusResponse } from '../types.ts'; +import type { Device, OutputTarget, ColorStripSource, ScenePreset, ScenePlaylist, SyncClock, Automation, HomeAssistantConnectionStatus, HomeAssistantStatusResponse, MQTTConnectionStatus, MQTTStatusResponse } from '../types.ts'; const DASHBOARD_COLLAPSED_KEY = 'dashboard_collapsed'; const MAX_FPS_SAMPLES = 120; @@ -529,6 +530,49 @@ function renderDashboardSyncClock(clock: SyncClock): string { `; } +/** Compact dashboard card for a scene playlist. Mirrors the sync-clock card: + * running state drives the LED / patch indicator and the Start↔Stop toggle. + * Only one playlist cycles at a time, so the running one is sorted to the + * front by the caller. Uses the window-exposed start/stop handlers (same as + * the Automations-tab cards), so no extra delegation wiring is needed. */ +function renderDashboardPlaylist(playlist: ScenePlaylist): string { + const running = playlist.is_running === true; + const itemCount = (playlist.items || []).length; + const metaParts = [ + itemCount > 0 ? `${itemCount} ${t('playlists.scenes_count')}` : null, + playlist.description ? escapeHtml(playlist.description) : null, + ].filter(Boolean); + const short = (playlist.id || '').replace(/^playlist_/i, '').slice(-2).toUpperCase() || 'PL'; + const ledCls = running ? 'led on blink' : 'led'; + const patchLabel = running ? t('playlists.status.playing') : t('playlists.status.stopped'); + const patchLive = running ? ' is-live' : ''; + const btnCls = running ? 'mod-btn mod-btn-stop' : 'mod-btn mod-btn-go'; + const btnLabel = running ? (t('playlists.action.stop') || 'Stop') : (t('playlists.action.start') || 'Start'); + const btnTitle = running ? t('playlists.stop') : t('playlists.start'); + const toggleAction = running ? 'stopScenePlaylist()' : `startScenePlaylist('${playlist.id}')`; + + const plStyle = cardColorStyle(playlist.id); + const iconPlate = _dashboardIconPlate(playlist as any); + const headCls = iconPlate ? 'mod-head mod-head--with-icon' : 'mod-head'; + return ``; +} + let _pollDebounce: ReturnType | undefined = undefined; /** Called from the transport-bar poll cycler (and any legacy callers * that might still reference `window.changeDashboardPollInterval`). */ @@ -644,7 +688,7 @@ export async function loadDashboard(forceFullRender: boolean = false): Promise []), fetchWithAuth('/automations').catch(() => null), devicesCache.fetch().catch((): any[] => []), @@ -652,6 +696,7 @@ export async function loadDashboard(forceFullRender: boolean = false): Promise null), fetchWithAuth('/output-targets/batch/metrics').catch(() => null), loadScenePresets(), + loadPlaylists().catch((): ScenePlaylist[] => []), fetchWithAuth('/sync-clocks').catch(() => null), fetchWithAuth('/home-assistant/status').catch(() => null), fetchWithAuth('/mqtt/status').catch(() => null), @@ -717,7 +762,7 @@ export async function loadDashboard(forceFullRender: boolean = false): Promise${t('dashboard.no_targets')}`; } else { const enriched = targets.map(target => ({ @@ -906,6 +951,19 @@ export async function loadDashboard(forceFullRender: boolean = false): Promise 0) { + const ordered = [...playlists].sort( + (a, b) => Number(b.is_running === true) - Number(a.is_running === true), + ); + const playlistCards = ordered.map(p => renderDashboardPlaylist(p)).join(''); + const playlistGrid = `
${playlistCards}
`; + sectionFragments['playlists'] = `
+ ${_sectionHeader('playlists', t('dashboard.section.playlists'), playlists.length, '', 'dashboard-playlists')} + ${_sectionContent('playlists', playlistGrid)} +
`; + } + // Sync Clocks section if (syncClocks.length > 0) { const clockCards = syncClocks.map(c => renderDashboardSyncClock(c)).join(''); diff --git a/server/src/ledgrab/static/locales/en.json b/server/src/ledgrab/static/locales/en.json index 5378db2..d541c44 100644 --- a/server/src/ledgrab/static/locales/en.json +++ b/server/src/ledgrab/static/locales/en.json @@ -1108,6 +1108,7 @@ "dashboard.failed": "Failed to load dashboard", "dashboard.section.automations": "Automations", "dashboard.section.scenes": "Scene Presets", + "dashboard.section.playlists": "Playlists", "dashboard.section.sync_clocks": "Sync Clocks", "dashboard.targets": "Targets", "dashboard.section.performance": "System Performance", diff --git a/server/src/ledgrab/static/locales/ru.json b/server/src/ledgrab/static/locales/ru.json index fbe5d68..dea0fa3 100644 --- a/server/src/ledgrab/static/locales/ru.json +++ b/server/src/ledgrab/static/locales/ru.json @@ -1145,6 +1145,7 @@ "dashboard.failed": "Не удалось загрузить обзор", "dashboard.section.automations": "Автоматизации", "dashboard.section.scenes": "Пресеты сцен", + "dashboard.section.playlists": "Плейлисты", "dashboard.section.sync_clocks": "Синхронные часы", "dashboard.targets": "Цели", "dashboard.section.performance": "Производительность системы", diff --git a/server/src/ledgrab/static/locales/zh.json b/server/src/ledgrab/static/locales/zh.json index 0072f8e..df9e29f 100644 --- a/server/src/ledgrab/static/locales/zh.json +++ b/server/src/ledgrab/static/locales/zh.json @@ -1141,6 +1141,7 @@ "dashboard.failed": "加载仪表盘失败", "dashboard.section.automations": "自动化", "dashboard.section.scenes": "场景预设", + "dashboard.section.playlists": "播放列表", "dashboard.section.sync_clocks": "同步时钟", "dashboard.targets": "目标", "dashboard.section.performance": "系统性能",