diff --git a/frigate_camera_control_bridge/static/app.css b/frigate_camera_control_bridge/static/app.css index 3711be6..7c3d4d3 100644 --- a/frigate_camera_control_bridge/static/app.css +++ b/frigate_camera_control_bridge/static/app.css @@ -103,10 +103,9 @@ select { gap: 16px; } -.action-card { +.camera-card { display: flex; min-width: 0; - min-height: 388px; flex-direction: column; overflow: hidden; border: 1px solid var(--line); @@ -142,10 +141,16 @@ select { margin-top: 78px; } -.visual-badge { +.visual-stack { position: absolute; right: 12px; bottom: 12px; + display: flex; + gap: 8px; + align-items: center; +} + +.visual-badge { display: grid; width: 58px; height: 58px; @@ -161,14 +166,17 @@ select { height: 38px; } -.media-empty .visual-badge { +.media-empty .visual-stack { top: 50%; right: auto; bottom: auto; left: 50%; + transform: translate(-50%, -58%); +} + +.media-empty .visual-badge { width: 76px; height: 76px; - transform: translate(-50%, -58%); border-color: var(--line); background: rgba(255, 255, 255, 0.82); } @@ -197,7 +205,7 @@ select { display: flex; flex: 1; flex-direction: column; - gap: 14px; + gap: 15px; padding: 15px; } @@ -213,6 +221,54 @@ h2 { overflow-wrap: anywhere; } +.camera-count { + display: inline-flex; + color: var(--muted); + font-size: 0.84rem; +} + +.control-stack { + display: grid; + gap: 11px; +} + +.action-control { + display: grid; + gap: 10px; + border: 1px solid var(--line); + border-radius: 8px; + padding: 12px; + background: #fbfcfe; +} + +.control-head { + display: grid; + grid-template-columns: 42px minmax(0, 1fr); + gap: 10px; + align-items: center; +} + +.control-head .visual-badge { + width: 42px; + height: 42px; + border-color: var(--line); + background: #ffffff; + box-shadow: none; +} + +.control-head .visual-badge svg { + width: 28px; + height: 28px; +} + +h3 { + margin: 0; + font-size: 0.94rem; + line-height: 1.25; + font-weight: 700; + overflow-wrap: anywhere; +} + .action-id { display: block; max-width: 100%; @@ -244,15 +300,15 @@ h2 { } .chip-state-on { - border-color: #9dd8bd; - background: #edfdf5; - color: var(--green-dark); + border-color: #f2b2aa; + background: var(--red-soft); + color: var(--red); } .chip-state-off { - border-color: #f0b8b4; - background: var(--red-soft); - color: var(--red); + border-color: #9dd8bd; + background: #edfdf5; + color: var(--green-dark); } .chip-state-unknown { @@ -291,7 +347,6 @@ h2 { display: grid; grid-template-columns: repeat(auto-fit, minmax(112px, 1fr)); gap: 9px; - margin-top: auto; } .command-form { @@ -341,14 +396,64 @@ h2 { } .cmd-off { - border-color: #d7a5a1; + border-color: #9dd8bd; background: #ffffff; - color: var(--red); + color: var(--green-dark); } .cmd-off:hover { - border-color: var(--red); - background: var(--red-soft); + border-color: var(--green); + background: #edfdf5; +} + +.global-strip { + flex: 1; + display: flex; + justify-content: center; + min-width: min(100%, 360px); +} + +.global-action { + display: flex; + align-items: center; + justify-content: space-between; + gap: 14px; + width: min(100%, 520px); + border: 1px solid #c9d2df; + border-radius: 8px; + padding: 10px; + background: #ffffff; + box-shadow: 0 10px 26px rgba(17, 24, 39, 0.08); +} + +.global-main { + display: grid; + grid-template-columns: 44px minmax(0, 1fr); + gap: 10px; + align-items: center; + min-width: 0; +} + +.global-main .visual-badge { + width: 44px; + height: 44px; + border-color: var(--line); + background: #f8fafc; + box-shadow: none; +} + +.global-main .visual-badge svg { + width: 30px; + height: 30px; +} + +.global-action h2 { + margin-bottom: 4px; + font-size: 0.96rem; +} + +.global-controls { + min-width: 120px; } .empty-state { @@ -371,12 +476,21 @@ h2 { flex-direction: column; } + .global-strip, .toolbar, .language-form { width: 100%; + } + + .global-strip, + .toolbar { justify-content: space-between; } + .global-action { + width: 100%; + } + select { max-width: 60%; } diff --git a/frigate_camera_control_bridge/static/app.js b/frigate_camera_control_bridge/static/app.js index f7a6c37..2fd2061 100644 --- a/frigate_camera_control_bridge/static/app.js +++ b/frigate_camera_control_bridge/static/app.js @@ -14,11 +14,11 @@ function updateTimers() { } const remaining = target - now; if (remaining <= 0) { - item.textContent = item.dataset.expiredLabel || "Auto-off pending"; + item.textContent = item.dataset.expiredLabel || "Automatic turn-off pending"; item.classList.add("timer-expired"); return; } - item.textContent = `${item.dataset.prefix || "Auto-off"} ${formatRemaining(remaining)}`; + item.textContent = `${item.dataset.prefix || "Automatic turn-off in"} ${formatRemaining(remaining)}`; }); } diff --git a/frigate_camera_control_bridge/templates/index.html b/frigate_camera_control_bridge/templates/index.html index 869a73f..ec2df91 100644 --- a/frigate_camera_control_bridge/templates/index.html +++ b/frigate_camera_control_bridge/templates/index.html @@ -13,6 +13,7 @@

{title}

{action_count}{actions_label}
+{global_actions}
diff --git a/frigate_camera_control_bridge/ui.py b/frigate_camera_control_bridge/ui.py index 7b115a6..8bf0fd5 100644 --- a/frigate_camera_control_bridge/ui.py +++ b/frigate_camera_control_bridge/ui.py @@ -98,12 +98,51 @@ def visual_markup(action, lang): ) -def render_card(action, lang, token_query): - display_name = action.display_name(lang) - icon = visual_markup(action, lang) +def camera_group_key(action): + # Prefer the Frigate camera identifier because all actions discovered from + # the same camera share it. Actions without a snapshot remain grouped by id. + snapshot = action.cfg.get("snapshot") or {} + return ( + snapshot.get("frigate_url"), + snapshot.get("camera") or action.cfg.get("camera_name") or action.id, + ) + + +def camera_group_name(action, lang): + return ( + action.cfg.get("camera_name") + or (action.cfg.get("snapshot") or {}).get("camera") + or action.cfg.get("device", {}).get("name") + or action.display_name(lang) + ) + + +def action_label(action, lang): + name_key = action.cfg.get("name_key") + short_labels = { + "action.siren": "visual.siren", + "action.red_blue": "visual.red_blue", + "action.manual_white": "visual.white_light", + "action.all_off": "visual.all_off", + } + if name_key in short_labels: + return text_for(lang, short_labels[name_key]) + return action.display_name(lang) + + +def group_visual_markup(actions, lang): + return '
' + "".join( + visual_markup(action, lang) for action in actions[:3] + ) + "
" + + +def snapshot_markup_for_group(group, lang, token_query): + actions = group["actions"] + action = next((item for item in actions if item.cfg.get("snapshot")), actions[0]) + icons = group_visual_markup(actions, lang) snapshot_markup = ( '
' - f"{icon}" + f"{icons}" f'{html.escape(text_for(lang, "no_preview"))}' "
" ) @@ -115,31 +154,16 @@ def render_card(action, lang, token_query): snapshot_markup = ( '
' f'' - f"{icon}" + f"{icons}" "
" ) + return snapshot_markup - buttons = [] - for command in action.command_list(): - label = text_for(lang, f"button.{command}") - action_url = ( - f"/action/{urllib.parse.quote(action.id)}/{urllib.parse.quote(command)}" - f"?lang={urllib.parse.quote(lang)}{token_query}" - ) - ack_attrs = "" - if command == "on" and action.cfg.get("requires_ack"): - ack_attrs = ( - ' data-requires-ack="true"' - f' data-ack-message="{html.escape(text_for(lang, "ack.siren_on"))}"' - ) - buttons.append( - f'' - f'
" - ) + +def render_action_control(action, lang, token_query): + display_name = action_label(action, lang) state_value = (action.state or "unknown").lower() state_label = text_for(lang, f"state.{state_value}") @@ -165,12 +189,10 @@ def render_card(action, lang, token_query): ) return ( - '
' - f"{snapshot_markup}" - '
' - '
' - f"

{html.escape(display_name)}

" - f'{html.escape(action.id)}' + '
' + '
' + f"{visual_markup(action, lang)}" + f"

{html.escape(display_name)}

" "
" '
' f'{html.escape(platform_label)}' @@ -178,12 +200,65 @@ def render_card(action, lang, token_query): f"{html.escape(state_label)}" "
" f"{timer_markup}" - f'
{"".join(buttons)}
' + f'
{render_action_buttons(action, lang, token_query)}
' + f'{html.escape(action.id)}' + "
" + ) + + +def render_group_card(group, lang, token_query): + controls = "".join(render_action_control(action, lang, token_query) for action in group["actions"]) + return ( + '
' + f"{snapshot_markup_for_group(group, lang, token_query)}" + '
' + '
' + f"

{html.escape(group['name'])}

" + f'{html.escape(text_for(lang, "camera.controls_count", count=len(group["actions"])))}' "
" + f'
{controls}
' "
" ) +def render_global_action(action, lang, token_query): + return ( + '
' + '
' + f"{visual_markup(action, lang)}" + '
' + f"

{html.escape(action.display_name(lang))}

" + f'{html.escape(action.id)}' + "
" + "
" + f'
{render_action_buttons(action, lang, token_query)}
' + "
" + ) + + +def render_action_buttons(action, lang, token_query): + buttons = [] + for command in action.command_list(): + label = text_for(lang, f"button.{command}") + action_url = ( + f"/action/{urllib.parse.quote(action.id)}/{urllib.parse.quote(command)}" + f"?lang={urllib.parse.quote(lang)}{token_query}" + ) + ack_attrs = "" + if command == "on" and action.cfg.get("requires_ack"): + ack_attrs = ( + ' data-requires-ack="true"' + f' data-ack-message="{html.escape(text_for(lang, "ack.siren_on"))}"' + ) + buttons.append( + f'
' + f'
" + ) + return "".join(buttons) + + def render_language_options(lang): options = [] for code, label in available_languages().items(): @@ -192,12 +267,31 @@ def render_language_options(lang): return "".join(options) +def group_actions(actions, lang): + grouped = {} + order = [] + for action in actions: + if action.kind == "all_off": + continue + key = camera_group_key(action) + if key not in grouped: + grouped[key] = {"name": camera_group_name(action, lang), "actions": []} + order.append(key) + grouped[key]["actions"].append(action) + return [grouped[key] for key in order] + + def render_page(actions, lang, token_param=None): lang = normalize_language(lang) token_query = f"&token={urllib.parse.quote(token_param)}" if token_param else "" - cards = [render_card(action, lang, token_query) for action in actions] + global_actions = [action for action in actions if action.kind == "all_off"] + global_markup = "".join(render_global_action(action, lang, token_query) for action in global_actions) + if global_markup: + global_markup = f'
{global_markup}
' + groups = group_actions(actions, lang) + cards = [render_group_card(group, lang, token_query) for group in groups] cards_markup = "".join(cards) - if not cards_markup: + if not cards_markup and not global_markup: cards_markup = f'
{html.escape(text_for(lang, "no_actions"))}
' token_input = ( f'' @@ -208,10 +302,11 @@ def render_page(actions, lang, token_param=None): return template.format( lang=html.escape(lang), title=html.escape(text_for(lang, "title")), - action_count=len(cards), - actions_label=html.escape(text_for(lang, "actions")), + action_count=len(groups), + actions_label=html.escape(text_for(lang, "cameras")), language_label=html.escape(text_for(lang, "language")), language_options=render_language_options(lang), token_input=token_input, + global_actions=global_markup, cards=cards_markup, ) diff --git a/locale/en/LC_MESSAGES/frigate_camera_control_bridge.po b/locale/en/LC_MESSAGES/frigate_camera_control_bridge.po index a88353e..ff13931 100644 --- a/locale/en/LC_MESSAGES/frigate_camera_control_bridge.po +++ b/locale/en/LC_MESSAGES/frigate_camera_control_bridge.po @@ -26,6 +26,12 @@ msgstr "State" msgid "actions" msgstr "Actions" +msgid "cameras" +msgstr "Cameras" + +msgid "camera.controls_count" +msgstr "{count} controls" + msgid "snapshot" msgstr "Snapshot" @@ -66,13 +72,13 @@ msgid "visual.generic" msgstr "Camera control" msgid "timer.auto_off" -msgstr "Auto-off in" +msgstr "Automatic turn-off in" msgid "timer.expired" -msgstr "Auto-off pending" +msgstr "Automatic turn-off pending" msgid "timer.auto_off_configured" -msgstr "Auto-off after {seconds}s" +msgstr "Automatic turn-off after {seconds}s" msgid "ack.siren_on" msgstr "Confirm siren activation." diff --git a/locale/pl/LC_MESSAGES/frigate_camera_control_bridge.po b/locale/pl/LC_MESSAGES/frigate_camera_control_bridge.po index 9a2218b..f703175 100644 --- a/locale/pl/LC_MESSAGES/frigate_camera_control_bridge.po +++ b/locale/pl/LC_MESSAGES/frigate_camera_control_bridge.po @@ -26,6 +26,12 @@ msgstr "Stan" msgid "actions" msgstr "Akcje" +msgid "cameras" +msgstr "Kamery" + +msgid "camera.controls_count" +msgstr "Kontrolki: {count}" + msgid "snapshot" msgstr "Migawka" @@ -66,13 +72,13 @@ msgid "visual.generic" msgstr "Sterowanie kamera" msgid "timer.auto_off" -msgstr "Auto-off za" +msgstr "Automatyczne wylaczenie za" msgid "timer.expired" -msgstr "Auto-off w toku" +msgstr "Automatyczne wylaczenie w toku" msgid "timer.auto_off_configured" -msgstr "Auto-off po {seconds}s" +msgstr "Automatyczne wylaczanie po {seconds}s" msgid "ack.siren_on" msgstr "Potwierdz wlaczenie syreny." diff --git a/tests/test_ui_render.py b/tests/test_ui_render.py index 6c9e78a..527240e 100644 --- a/tests/test_ui_render.py +++ b/tests/test_ui_render.py @@ -4,21 +4,25 @@ from frigate_camera_control_bridge.ui import render_page class DummyAction: - def __init__(self): - self.id = "example_camera_siren" + def __init__(self, suffix="siren", name_key="action.siren", state="ON", kind="dahua_coaxial_io"): + self.id = f"example_camera_{suffix}" self.platform = "switch" - self.kind = "dahua_coaxial_io" - self.state = "ON" + self.kind = kind + self.state = state self.auto_off_seconds = 180 self.auto_off_at = 1234567890 self.cfg = { - "name_key": "action.siren", - "requires_ack": True, - "snapshot": {"camera": "example", "frigate_url": "http://frigate.example.test:5000"}, + "name_key": name_key, + "camera_name": "Example camera", + "requires_ack": name_key == "action.siren", + "snapshot": { + "camera": "example", + "frigate_url": "http://frigate.example.test:5000", + }, } def display_name(self, lang): - return "Example siren" + return f"Example {self.id}" def command_list(self): return ["on", "off"] @@ -30,7 +34,32 @@ class UiRenderTest(unittest.TestCase): self.assertIn('data-auto-off-at="1234567890"', html) self.assertIn('data-requires-ack="true"', html) - self.assertIn("Example siren", html) + self.assertIn("Example camera", html) + + def test_controls_from_same_camera_share_one_card(self): + actions = [ + DummyAction("siren", "action.siren"), + DummyAction("white", "action.manual_white", state="OFF"), + DummyAction("red_blue", "action.red_blue", state="OFF"), + ] + + html = render_page(actions, "en") + + self.assertEqual(html.count('class="camera-card"'), 1) + self.assertEqual(html.count('class="action-control"'), 3) + self.assertIn("3 controls", html) + self.assertIn("White light", html) + self.assertIn("Red-blue light", html) + self.assertIn("Siren", html) + + def test_all_off_renders_in_topbar_not_camera_grid(self): + action = DummyAction("all_off", "action.all_off", state=None, kind="all_off") + + html = render_page([action], "en") + + self.assertIn('class="global-strip"', html) + self.assertIn('class="global-action"', html) + self.assertNotIn('class="camera-card"', html) if __name__ == "__main__":