diff --git a/config/dashboards/README.md b/config/dashboards/README.md index b4ae8d58..0d02f5bf 100644 --- a/config/dashboards/README.md +++ b/config/dashboards/README.md @@ -22,6 +22,8 @@ This folder holds YAML-managed Home Assistant Lovelace dashboards and UI resources. +Home, Mobile, and Systems show optional, unexpired appliance notices in a blue banner above their controls. Informational notices stay outside actionable issue counts and health warnings. The banner reuses existing live telemetry without additional sensors or history. + ### Walkthrough - Mobile quick-access video: [![Watch on YouTube](https://img.shields.io/badge/Watch-YouTube-FF0000?logo=youtube&logoColor=white)](https://youtu.be/ujP-zYLEso8) - Mobile quick-access companion post: [![vCloudInfo Blog Post](https://img.shields.io/static/v1?label=vCloudInfo&message=Blog%20Post&color=21759B&logo=wordpress&logoColor=white)](https://www.vcloudinfo.com/2026/07/home-assistant-mobile-dashboard-big-buttons.html) @@ -52,14 +54,27 @@ This folder holds YAML-managed Home Assistant Lovelace dashboards and UI resourc ### Camera Timeline Playback -The Frigate Events Card keeps its event gallery and continuous-recording timeline. Its resource loads through `www/frigate-events-auth.js`, which imports the HACS card and signs the timeline manifest and MP4 fallback through Home Assistant. Frigate passes the manifest signature to the recording segments; native video playback also needs a signed URL. The adapter is scoped to this card and preserves the installed HACS bundle and direct Frigate URL behavior. +The Frigate Events Card keeps its event gallery and continuous-recording timeline. Its resource loads through `www/frigate-events-navigation.js`, which reuses `www/frigate-events-auth.js` to import the HACS card and sign the timeline manifest and MP4 fallback through Home Assistant. Frigate passes the manifest signature to the recording segments; native video playback also needs a signed URL. The adapters are scoped to this card and preserve the installed HACS bundle and direct Frigate URL behavior. This bridges the unsigned timeline URLs in Frigate Events Card v2.5.23. Recheck playback after card upgrades and remove the adapter when upstream signs both timeline paths. The adapter discards pending authorization when the camera, time window, or modal changes. +The HACS bundle import carries its installed version, and adapter imports are versioned together. Bump the bundle URL and dependent adapter URLs when updating this chain; otherwise a browser can combine the new review controls with a cached older card that has no timeline API. + +The Live Cameras view at `live-cameras` keeps its existing camera layout. Tap a camera tile to open its live camera dialog; its compact recorded thumbnails play individual clips. Home's Birdseye tile opens Live Cameras. The full timeline is enabled only on the dedicated Clips view at `clips`. There, clicking a clip opens the timeline at its event time. Now uses a shared clock about 30 seconds behind current time so completed recording segments can be played. Camera switches preserve Now or the current historical playhead, including a clip's event time. Cameras without footage at the selected time show a gap message and keep that time selected. + +Camera changes freeze the selected recording time and hide outgoing and initial frames until the incoming stream has sought to that time. Recording timestamps provide the common reference; camera-generated clock overlays can differ. + +When saved footage ends, playback holds its last frame unless later recordings are available. It does not open an empty stream just because the current time advanced while the video was playing. + +The Clips view shows 60 recorded clips per page with readable camera, object and date/time labels, a camera filter, and Older/Newer navigation. Live Cameras shows only eight thumbnails in two desktop rows, without a heading, filters, paging controls or visible captions. Accessible thumbnail labels still identify each event. Both browsers request saved video clips, including clips without snapshots, rather than snapshot-only detections. Integer API pagination overlaps boundary seconds and excludes seen IDs to avoid dropping fractional-second events. The Clips archive's Recent footage button opens the selected camera near Now; if that time has no recording, it opens the latest saved segment from the past 24 hours and reports its time. With All cameras selected, it uses the newest displayed clip's camera. Camera switches then preserve that selected recording time. Old `camera` and `cameras` bookmarks redirect to `live-cameras` and `clips`, respectively. + +All three event cards share the ordered recording-camera list in `overview/partials/frigate_camera_names.yaml`. Add or remove a Frigate camera there to change the available timeline tabs, even when a camera has no recent detections. All nine recording cameras are included; Birdseye is a composite feed and Blink has no Frigate recording history. Camera tabs and transport controls wrap on narrow screens. Availability and recording gaps remain properties of each camera's saved footage. + ### Mobile Quick Access -- Mobile is the second visible Overview view, directly after Home and before Cameras. +- Mobile is the second visible Overview view, directly after Home and before Live Cameras. - Two-column controls use large labels, states, icons, and touch targets for quick use without glasses. - Primary actions cover garage doors, the front-door lock, thermostats, household alerts, and entry-point status. +- Living Room TV lives on Systems > Kiosks, with a branded Raven launch/status card and separate large-button Roku Remote and TV Remote subviews. The main Overview tabs and Mobile page keep TV controls out of the way. - Packages opens the doorbell camera; Systems opens the desktop control center; destination views place a large Back to Home control at the top. ### Lighting Controls @@ -101,6 +116,7 @@ This folder is referenced from `config/configuration.yaml` via: - `dashboard-kiosk-satellite-staging` is the hidden validation surface for the shared Kiosk Satellite camera view. - Systems' visible Kiosks tab sits immediately before Joanna and combines attention telemetry with the latest Bedroom Tablet, Kitchen Show, Upstairs Show, and Office Show screenshots; tapping a snapshot opens that display's live dashboard. Upstairs and Office use the compact Echo Show 5 view and native ESPHome telemetry; offline, page/app drift, power, and stale-screenshot faults join the shared Systems rollup. App updates remain advisory. Native voice status, assistant pipeline, and wake word are visible for all enrolled Shows. - Mini App local/public health leads Joanna's top status row; total 24-hour dispatches sit in Latest Activity, with AI Diagnostics at the bottom. Runtime & Memory retains failure details. Sustained failures appear in Systems Active Issues and the shared issue count, with persistent Repairs and independent mobile alerts. + - Scheduled-job warnings and errors have separate entries in both Systems Active Issues and Joanna Runtime & Memory, including each job's detail and next check. The Systems badge counts those jobs individually; other health categories still count once. Warning rows remain amber even when a different job is in error. A bounded-summary remainder or unavailable telemetry stays visible. - The Systems Joanna page links to BearClaw Admin from Dispatch Trend and to a hidden AI Diagnostics subview that shows the latest OpenAI instructions and response together; package-classification diagnostics and retired QMD health telemetry are not shown. - Overview's second visible view is **Mobile**, a phone-first quick-control surface for garage doors, the front-door lock, thermostats, and compact household alerts. - Overview Home uses ordered production sections under `overview/sections/`; approved pilot edits now apply directly there. @@ -124,7 +140,7 @@ Lovelace resources are loaded from: - Include paths in Lovelace YAML should use absolute container paths starting with `/config/`. - Example: `!include /config/dashboards/overview/partials/some_cards.yaml` - Views are loaded using `!include_dir_list` and ordered by filename (prefix with `01_`, `02_`, etc.). -- Systems views are desktop-first and dense by default: use `type: sections`, `max_columns: 4`, and `dense_section_placement: true` on every standard view. +- Systems views are desktop-first and dense by default: use `type: sections`, `max_columns: 4`, and `dense_section_placement: true` on every standard view. The landing strip starts with Back to Overview and twelve category tiles; it omits the redundant Systems aggregate tile. - Use responsive `layout-card` grids with `min-content` rows inside full-width section wrappers so panels pack tightly without fixed-height gaps. - Keep related panels similar in height, avoid spacer cards, and verify both desktop and single-column mobile layouts. - Keep WAN telemetry, compact dual Pi-hole controls, and current website health together on the Network view; reserve hidden subviews for detailed Pi-hole analytics and domain/monitor telemetry. @@ -173,4 +189,18 @@ shuffled rounds. An eleventh Keep is rejected visibly without releasing another. ### Joanna Security & Updates -The Joanna view includes a private security summary from the appliance's native read-only scheduler. The `sensor.joanna_security_updates` entity exposes aggregate counts and scan freshness. Review tiles open the internal Admin report directly (no token required); the vendor tile is informational. Vendor-only waits leave the main dashboards clear and remain amber inside the report. Actionable work, failed maintenance and missing coverage still warn. Configure the private destination in the local `bearclaw_security_admin_url` secret. Security summaries and raw poll snapshots are excluded from recorder; existing dispatch trends remain recorded. Detailed inventory, CVEs, and findings remain in appliance private state and are never committed to this repository. The dashboard does not trigger updates or public issues. +The Joanna view includes a private security summary from the appliance's native read-only scheduler. The `sensor.joanna_security_updates` entity exposes aggregate counts and scan freshness. Review tiles open the internal Admin report directly (no token required); the vendor tile is informational. Vendor-only waits leave the main dashboards clear and remain amber inside the report. Actionable work, failed maintenance and missing coverage still warn. Configure the private destination in the local `bearclaw_security_admin_url` secret. Security summaries and raw poll snapshots are excluded from recorder; existing dispatch trends remain recorded. Detailed inventory, CVEs, and findings remain in appliance private state and are never committed to this repository. The Joanna security tile can copy a Codex review prompt directly, with a selection fallback when clipboard access is blocked. The prompt contains aggregate context and instructions to retrieve fresh private findings; it copies text only. Successful automatic maintenance, limited source-rebuild coverage and ordinary package retries remain quiet. The dashboard does not trigger updates or public issues. + +### Shared household kiosk content + +Kitchen, Upstairs, Office, Bedroom and Raven use the notification model in `config/www/kiosk/ambient.js`. Add notification types and their entity dependencies there so all enrolled displays receive the same household information. Shared phone-notification entries retain the source feed's expiry and replacement rules. Kiosk renderers keep their own dimensions and interaction policies. + +Seasonal presentation ([#2029](https://github.com/CCOSTAN/Home-AssistantConfig/issues/2029)) follows `sensor.holiday_lighting_mode`, the same automatic selection used by exterior lights and Joanna's avatar. `config/www/kiosk/ambient.js` owns the shared labels, lighting-derived palettes and local artwork paths. Home, Mobile and Systems reuse their existing section headers: a light seasonal background wash and a holiday image in the existing header-icon slot. No banner, row, spacing, card dimension or view layout is added or changed. Shows display seasonal artwork in the ambient panel, Bedroom places it beside the alarm text, and Raven reserves space beside the notice; the Nest already follows the same sensor through Joanna's portrait. Text and semantic status colors retain their meaning, while cameras, controls, and event/detail takeovers retain priority. Ordinary, unsupported and unavailable modes return to the normal presentation. No renderer contains holiday date rules. The decoration is static, with no extra polling, fonts or external asset requests. Locally served Twemoji artwork and attribution are in `config/www/kiosk/seasonal/`. Aurora Glass remains inspiration only; no Aurora code or dependency is installed. Full-screen clip review and map views keep their dedicated layouts. + +Systems > Kiosks groups five device captures and a branded Raven app card near the top, with six device-detail panels below. The Raven card shows TV-off or Roku app state, the current Roku app, Open Raven, and a Roku Remote link. It replaces the retired Samsung Haven browser iframe; it is a launch/status card rather than a screenshot. The Raven device panel has TV power, the manual-exit pause, automatic-launch state, last automatic launch, and links to both remotes. Its Automatic Raven launch row toggles `input_boolean.haven`, retaining the existing entity ID for compatibility. Turning it off pauses automatic Roku Home idle launches, for example during gaming; it does not close an app already on screen. The helper preserves its state across restarts and turns back on at 03:00 Home Assistant local time without waking the TV or launching Raven. Manual Open Raven launches the native Roku app through the existing action name. TV-off is normal and remains outside fleet health alerts. Raven portraits are dimmed and grayscale while away, with each person's configured home locations retaining full color. + +Raven is the active TV dashboard; Samsung Haven is retired from the Home Assistant dashboard interface. The native Roku Raven dashboard ([#2030](https://github.com/CCOSTAN/Home-AssistantConfig/issues/2030)) shows Front Door, Front Lawn, Driveway and Garage in one live four-camera mosaic, with thin dividers and no camera labels. The low-resolution stream runs at ten frames per second; five-second snapshots load offscreen before replacing the previous image when live video is unavailable. A house-status ribbon and family presence remain visible while current/three-day weather with away locations rotates with daily energy/water totals and the approved family agenda. Water keeps its gallon unit; energy uses kWh. Garage-open, windows-open and unlocked-door tiles turn red; cooling stays blue. The Front Door tile follows the lock independently of its door contact. Existing Garage, Front Door and Driveway priority decisions from `sensor.kiosk_active_camera_view` replace the wall with one muted live camera, with snapshot fallback. Birdseye is not selected automatically. Household notices and year-round seasonal artwork reuse the canonical kiosk model and shared holiday mode. Guest/vacation mode and stale or disconnected data hide private agenda/location details. The app tile and splash use vCloud Info branding. Open Raven remains available in the interface; there is no separate Alexa demo helper. + +Automatic launch requires two minutes on Roku Home, the TV on for two minutes, Raven enabled, and no manual-exit pause. Back/Home exit natively and start a restoring 15-minute pause. The display bridge checks the current Roku app again before launching, so paused streaming apps are not treated as idle Home. Samsung input-switch and Exit handoffs are retired. The Roku Remote subview sends native Roku Home, Back, directional, Select, Options and Play commands; the separate TV Remote keeps Samsung menu navigation available. Stable internal IDs and the shared display-client code are retained for Raven compatibility. The bridge renews its read-only HA session and camera authentication; the Roku stores only its scoped display connection. After a detected Roku reboot/network recovery, HA reloads only the living-room Roku integration. App/bridge source belongs to its own service, outside this HA repository. Private endpoints, keys and entity mappings stay outside Git. + +OneNote account indexes show blue Indexing or Recovering while automatic work is pending. These states do not increase the Systems issue count. Amber warnings indicate operator attention after the appliance recovery workflow; the row includes the reason. diff --git a/config/dashboards/overview/partials/camera_sections.yaml b/config/dashboards/overview/partials/camera_sections.yaml index 98318e58..2fc1094c 100644 --- a/config/dashboards/overview/partials/camera_sections.yaml +++ b/config/dashboards/overview/partials/camera_sections.yaml @@ -9,6 +9,8 @@ # Notes: Entry controls reuse the same shared template as the Home ribbon. # Notes: Desktop keeps three upper cameras and four second-row cameras beside the doorbell. # Notes: A full-width responsive grid prevents section wrapping; narrow screens use two columns. +# Notes: Camera tiles open live camera details; thumbnails play individual clips. +# Notes: The full recording timeline is available on the dedicated Clips view. ###################################################################### - type: grid @@ -64,7 +66,7 @@ ok_states: - locked -# Picture-glance supplies camera previews and tap-to-open video; button-card cannot. +# Picture-glance supplies previews and live camera dialogs; button-card cannot. - type: grid column_span: 4 cards: @@ -95,6 +97,10 @@ cards: - type: picture-glance camera_image: camera.frontdoorbell + tap_action: + action: more-info + hold_action: + action: more-info camera_view: auto aspect_ratio: '4:5' entities: [] @@ -102,6 +108,10 @@ grid-area: door - type: picture-glance camera_image: camera.poolcam + tap_action: + action: more-info + hold_action: + action: more-info camera_view: auto aspect_ratio: '16:9' entities: [] @@ -110,50 +120,86 @@ place-self: start stretch - type: picture-glance camera_image: camera.garagecam + tap_action: + action: more-info + hold_action: + action: more-info camera_view: auto entities: [] view_layout: grid-area: garage - type: picture-glance camera_image: camera.frontlawn + tap_action: + action: more-info + hold_action: + action: more-info camera_view: auto entities: [] view_layout: grid-area: lawn - type: picture-glance camera_image: camera.driveway + tap_action: + action: more-info + hold_action: + action: more-info camera_view: auto entities: [] view_layout: grid-area: drive - type: picture-glance camera_image: camera.kidsgate + tap_action: + action: more-info + hold_action: + action: more-info camera_view: auto entities: [] view_layout: grid-area: kids - type: picture-glance camera_image: camera.bedroomgate + tap_action: + action: more-info + hold_action: + action: more-info camera_view: auto entities: [] view_layout: grid-area: bedroom - type: picture-glance camera_image: camera.filtergate + tap_action: + action: more-info + hold_action: + action: more-info camera_view: auto entities: [] view_layout: grid-area: filter - type: picture-glance camera_image: camera.conico_wireless + tap_action: + action: more-info + hold_action: + action: more-info camera_view: auto entities: [] view_layout: grid-area: conico - # Preserve the existing Frigate event previews below the camera rows. + # Eight thumbnail-only previews keep the live grid compact; use Clips for review. - type: custom:frigate-events-card frigate_client_id: frigate - event_count: 3 + cameras: !include /config/dashboards/overview/partials/frigate_camera_names.yaml + review_clips: true + show_timeline: false + timeline_on_click: false + layout: grid + scroll: false + event_count: 8 + review_compact: true + grid_max_height: 215px video: true video_on_hover: true view_layout: diff --git a/config/dashboards/overview/partials/cameras_cards.yaml b/config/dashboards/overview/partials/cameras_cards.yaml index 64a8f174..6147ec86 100644 --- a/config/dashboards/overview/partials/cameras_cards.yaml +++ b/config/dashboards/overview/partials/cameras_cards.yaml @@ -4,47 +4,22 @@ # Original Repo : https://github.com/CCOSTAN/Home-AssistantConfig # ------------------------------------------------------------------- # Overview Partial - cameras cards -# Reusable list extracted from a view for smaller diffs and safer edits. +# Recording gallery with direct access to the shared timeline player. # ------------------------------------------------------------------- -# Notes: Extracted from config/dashboards/overview/views/04_cameras.yaml key `cards`. +# Notes: Pages through recorded clips; each clip opens its exact event time. ###################################################################### -- type: custom:frigate-card - cameras: - - camera_entity: camera.frontdoorbell - dependencies: - all_cameras: true - cameras: - - camera.bedroomgate - - camera.filtergate - - camera.driveway - - camera_entity: camera.bedroomgate - - camera_entity: camera.filtergate - - camera_entity: camera.driveway - - camera_entity: camera.poolcam - live_provider: auto - - camera_entity: camera.blink1 - live_provider: auto - - camera_entity: camera.frontlawn - live_provider: auto - - camera_entity: camera.kidsgate - - camera_entity: camera.garagecam - view: - default: clips - camera_select: clips - default_cycle_camera: false - menu: - style: hidden - media_gallery: - controls: - thumbnails: - size: 175 - show_favorite_control: false - show_details: false - dimensions: - min_height: '500' - aspect_ratio: '16:9' - aspect_ratio_mode: unconstrained - media_viewer: - layout: - fit: cover +# The event card provides recording playback and scrubbing beyond button-card. +- type: custom:frigate-events-card + title: Camera Clips + frigate_client_id: frigate + cameras: !include /config/dashboards/overview/partials/frigate_camera_names.yaml + review_clips: true + show_timeline: true + timeline_on_click: true + video: true + video_on_hover: true + layout: grid + scroll: false + event_count: 60 + grid_max_height: 75vh diff --git a/config/dashboards/overview/partials/frigate_camera_names.yaml b/config/dashboards/overview/partials/frigate_camera_names.yaml new file mode 100644 index 00000000..c39482e4 --- /dev/null +++ b/config/dashboards/overview/partials/frigate_camera_names.yaml @@ -0,0 +1,21 @@ +###################################################################### +# @CCOSTAN - Follow Me on X +# For more info visit https://www.vcloudinfo.com/click-here +# Original Repo : https://github.com/CCOSTAN/Home-AssistantConfig +# ------------------------------------------------------------------- +# Frigate Timeline Cameras - explicit recording camera order +# Shared by Home, the camera grid, and the recording viewer. +# ------------------------------------------------------------------- +# Notes: Keep cameras visible even when they have no recent detections. +# Notes: Birdseye is a composite feed; Blink is not a Frigate recorder. +###################################################################### + +- frontdoorbell +- garagecam +- frontlawn +- driveway +- kidsgate +- bedroomgate +- filtergate +- conico_wireless +- poolcam diff --git a/config/dashboards/overview/sections/30_cameras.yaml b/config/dashboards/overview/sections/30_cameras.yaml index 81ec8735..f591b24d 100644 --- a/config/dashboards/overview/sections/30_cameras.yaml +++ b/config/dashboards/overview/sections/30_cameras.yaml @@ -6,13 +6,16 @@ # Overview Section - camera and weather awareness # Keeps security views and outdoor context together in one column. # ------------------------------------------------------------------- -# Notes: Camera cards retain existing navigation and live behavior. -# Notes: Hourly and daily forecasts remain complementary at a glance. +# Notes: Birdseye opens Live Cameras; the recording timeline lives on Clips. +# Notes: Pirate Weather shows current/hourly weather; the lower card is daily-only. ###################################################################### cards: - type: custom:frigate-events-card frigate_client_id: frigate + cameras: !include /config/dashboards/overview/partials/frigate_camera_names.yaml + show_timeline: false + timeline_on_click: false event_count: 5 video: true video_on_hover: true @@ -24,7 +27,9 @@ cards: camera_view: live tap_action: action: navigate - navigation_path: /lovelace/cameras + navigation_path: /lovelace/live-cameras + hold_action: + action: more-info - type: weather-forecast show_current: true @@ -33,28 +38,19 @@ cards: forecast_type: hourly secondary_info_attribute: humidity +# Retain the weather card's daily temperature bars using its v3 section options. - type: custom:clock-weather-card entity: weather.pirateweather - sun_entity: sun.sun - temperature_sensor: sensor.pirateweather_temperature - humidity_sensor: sensor.pirateweather_humidity weather_icon_type: fill - animated_icon: true - forecast_rows: 5 locale: en-GB - time_pattern: HH:mm - time_format: 12 - date_pattern: DDDD - hide_today_section: true - hide_forecast_section: false - show_humidity: true - hide_clock: false - hide_date: true - hourly_forecast: false - use_browser_time: false - time_zone: null - show_decimal: false - aqi_sensor: sensor.bear_stone_common_air_quality_index - apparent_sensor: null + sections: + header: + hide: true + forecast_strip: + hide: true + forecast_list: + hide: false + forecast_type: daily + count: 5 column_span: 1 diff --git a/config/dashboards/overview/views/03_camera.yaml b/config/dashboards/overview/views/03_camera.yaml index 581e2751..06c794a5 100644 --- a/config/dashboards/overview/views/03_camera.yaml +++ b/config/dashboards/overview/views/03_camera.yaml @@ -3,14 +3,14 @@ # For more info visit https://www.vcloudinfo.com/click-here # Original Repo : https://github.com/CCOSTAN/Home-AssistantConfig # ------------------------------------------------------------------- -# Overview View - Camera +# Overview View - Live Cameras # Standardized access controls above the live camera grid. # ------------------------------------------------------------------- # Notes: Uses the shared Overview ribbon template for entry controls. ###################################################################### -title: Camera -path: camera +title: Live Cameras +path: live-cameras icon: mdi:cctv type: sections max_columns: 4 diff --git a/config/dashboards/overview/views/04_cameras.yaml b/config/dashboards/overview/views/04_cameras.yaml index b404eaa2..41a09107 100644 --- a/config/dashboards/overview/views/04_cameras.yaml +++ b/config/dashboards/overview/views/04_cameras.yaml @@ -3,15 +3,15 @@ # For more info visit https://www.vcloudinfo.com/click-here # Original Repo : https://github.com/CCOSTAN/Home-AssistantConfig # ------------------------------------------------------------------- -# Overview View - Camera View -# YAML-exported Lovelace dashboard (split into view files). +# Overview View - Camera Clips +# Paged recording review with direct access to the shared timeline. # ------------------------------------------------------------------- -# Notes: Exported from config/.storage/lovelace.lovelace view index 4. +# Notes: The resource loader redirects bookmarks from the former cameras route. # Notes: Inherits the selected frontend theme. ###################################################################### -title: Camera View -path: cameras -icon: mdi:camera +title: Clips +path: clips +icon: mdi:filmstrip type: panel cards: !include /config/dashboards/overview/partials/cameras_cards.yaml diff --git a/config/dashboards/resources.yaml b/config/dashboards/resources.yaml index f5a5ec6b..8b78f4ea 100644 --- a/config/dashboards/resources.yaml +++ b/config/dashboards/resources.yaml @@ -4,6 +4,7 @@ # Original Repo : https://github.com/CCOSTAN/Home-AssistantConfig # ------------------------------------------------------------------- # Lovelace Resources - Custom card JS resources +# Related Issue: 2029 # YAML-managed Lovelace resources (resource_mode: yaml). # ------------------------------------------------------------------- # Notes: Generated from config/.storage/lovelace_resources. Edit here after migration; do not edit .storage. @@ -50,8 +51,11 @@ - type: module url: /hacsfiles/lovelace-layout-card/layout-card.js?hacstag=156434866247 - type: module - # Loads the HACS card with signed timeline HLS and MP4 media URLs. - url: /local/frigate-events-auth.js?v=1 + # One notification model for the Show, Bedroom, and Raven kiosk renderers. + url: /local/kiosk/ambient.js?v=1.2.0 +- type: module + # Loads the versioned HACS card with signed timeline media for Clips. + url: /local/frigate-events-navigation.js?v=8 - type: module url: /local/ookla_speedtest/ookla-speedtest-card.js?v=3.0.5 - type: module diff --git a/config/www/frigate-clip-review.js b/config/www/frigate-clip-review.js new file mode 100644 index 00000000..1c57fc5a --- /dev/null +++ b/config/www/frigate-clip-review.js @@ -0,0 +1,208 @@ +// A paged, labeled review surface around the installed Frigate Events Card. +import "/local/frigate-events-auth.js?v=2"; + +const Card = customElements.get("frigate-events-card"); +const installed = Symbol.for("frigate-events-card.clip-review"); + +if (Card && !Card.prototype[installed]) { + Card.prototype[installed] = true; + const loadEvents = Card.prototype._loadEvents; + const updated = Card.prototype.updated; + const states = new WeakMap(); + + function stateFor(card) { + if (!states.has(card)) states.set(card, { page: 0, cursors: [null], camera: "", request: 0 }); + return states.get(card); + } + + Card.prototype._loadEvents = async function () { + if (!this._config?.review_clips) return loadEvents.call(this); + if (!this.hass) return; + const state = stateFor(this); + const cursor = state.cursors[state.page]; + const key = JSON.stringify([state.page, state.camera, cursor]); + if (state.loading && state.key === key) return; + const request = ++state.request; + state.busy = state.key !== key || !this._events?.length; + state.key = key; + state.loading = true; + state.error = false; + this._loading = true; + this.requestUpdate(); + try { + const size = this._config.event_count || 60; + const query = { type: "frigate/events/get", instance_id: this._config.frigate_client_id || "frigate", + cameras: state.camera ? [state.camera] : this._config.cameras, + has_clip: true, limit: size + 1 + (cursor?.exclude.length || 0) }; + if (cursor) query.before = cursor.before; + const response = await this.hass.callWS(query); + if (request !== state.request || !this.isConnected) return; + const excluded = new Set(cursor?.exclude || []); + const rows = (typeof response === "string" ? JSON.parse(response) : response) + .filter(event => !excluded.has(event.id)).sort((a, b) => b.start_time - a.start_time); + this._events = rows.slice(0, size); + state.more = rows.length > size; + if (state.more) { + const boundary = Math.floor(this._events.at(-1).start_time); + const exclude = new Set(cursor?.before === boundary + 1 ? cursor.exclude : []); + this._events.filter(event => event.start_time >= boundary).forEach(event => exclude.add(event.id)); + // The HA WebSocket API accepts integer boundaries. Overlap the last + // second and exclude seen IDs so fractional-second clips are retained. + state.cursors[state.page + 1] = { before: boundary + 1, exclude: [...exclude] }; + } + this._error = undefined; + } catch (error) { + if (request !== state.request) return; + state.error = true; + console.warn("Recorded clip review failed", error?.message); + } finally { + if (request === state.request) { + state.loading = false; + state.busy = false; + this._loading = false; + this.requestUpdate(); + } + } + }; + + function decorate(card) { + const root = card.shadowRoot; + const surface = root?.querySelector("ha-card"); + if (!surface) return; + const state = stateFor(card); + let toolbar = root.querySelector(".clip-review-toolbar"); + if (!toolbar) { + toolbar = document.createElement("div"); + toolbar.className = "clip-review-toolbar"; + toolbar.innerHTML = '
Recorded clips
'; + const select = toolbar.querySelector("select"); + for (const camera of card._config.cameras || []) { + const option = document.createElement("option"); + option.value = camera; + option.textContent = card._formatCameraName(camera); + select.appendChild(option); + } + select.addEventListener("change", () => { + state.camera = select.value; + state.page = 0; + state.cursors = [null]; + card._loadEvents(); + }); + toolbar.addEventListener("click", async event => { + const action = event.target.closest("[data-review]")?.dataset.review; + if (!action) return; + if (action === "now") { + const button = toolbar.querySelector('[data-review="now"]'); + const camera = state.camera || card._events?.[0]?.camera || card._config.cameras[0]; + button.disabled = true; + button.textContent = "Opening..."; + try { + const now = Date.now() / 1000; + const response = await card.hass.callWS({ type: "frigate/recordings/get", + instance_id: card._config.frigate_client_id || "frigate", camera, + after: Math.floor(now - 86400), before: Math.floor(now) }); + if (!card.isConnected) return; + const recordings = (typeof response === "string" ? JSON.parse(response) : response) + .filter(recording => recording.end_time > recording.start_time) + .sort((a, b) => b.end_time - a.end_time); + const nearNow = recordings.some(recording => now - 30 >= recording.start_time && now - 30 <= recording.end_time); + const latest = recordings[0]; + // Motion-based retention can leave Now empty. Choose saved footage + // explicitly here; historical camera switches still keep its time. + const target = !nearNow && latest ? Math.max(latest.start_time, latest.end_time - 10) : undefined; + await card._showTimelineModal(camera, target); + if (Number.isFinite(target)) card.dispatchEvent(new CustomEvent("hass-notification", { + detail: { message: `No recording near now. Opened latest saved footage from ${new Date(target * 1000).toLocaleString()}.` }, + bubbles: true, composed: true, + })); + } catch (error) { + console.warn("Recent recording playback failed", error?.message); + card.dispatchEvent(new CustomEvent("hass-notification", { + detail: { message: "Unable to open recent footage. Please try again." }, bubbles: true, composed: true, + })); + } finally { + button.disabled = false; + button.textContent = "Recent footage"; + } + return; + } + if (action === "older" && state.more) state.page++; + else if (action === "newer") state.page = Math.max(0, state.page - 1); + else if (action === "latest") { state.page = 0; state.cursors = [null]; } + card._loadEvents(); + root.querySelector(".events")?.scrollTo({ top: 0 }); + }); + surface.prepend(toolbar); + const style = document.createElement("style"); + style.textContent = ` + .clip-review-toolbar { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 12px; padding: 12px 0; } + .clip-review-toolbar[hidden] { display: none; } + .review-heading { display: flex; flex-direction: column; gap: 4px; } + .review-status { font-size: 12px; color: var(--secondary-text-color); } + .review-actions { display: flex; gap: 6px; flex-wrap: wrap; } + .review-actions button, .review-actions select { min-height: 36px; padding: 6px 10px; border: 1px solid var(--divider-color, #aaa); border-radius: 8px; background: var(--card-background-color); color: var(--primary-text-color); font: inherit; font-size: 13px; cursor: pointer; } + .review-actions button:disabled { opacity: .4; cursor: default; } + .events.grid { grid-template-columns: repeat(auto-fill, minmax(min(100%, 220px), 1fr)) !important; max-height: var(--review-height, 75vh); overflow-y: auto; padding: 3px; } + .events .event { aspect-ratio: auto; } + .events .event img { height: 150px; } + .events .event video { height: 150px !important; } + .review-caption { display: flex; flex-direction: column; gap: 4px; padding: 9px; color: var(--primary-text-color); font-size: 13px; line-height: 1.35; } + .review-caption span { color: var(--secondary-text-color); font-size: 12px; } + .placeholder { display: none; } + [data-review-compact] .events.grid { grid-template-columns: repeat(4, minmax(0, 1fr)) !important; } + [data-review-compact] .events .event img, [data-review-compact] .events .event video { height: 100px !important; } + @media (max-width: 600px) { + [data-review-compact] .events.grid { grid-template-columns: repeat(2, minmax(0, 1fr)) !important; } + } + `; + root.appendChild(style); + } + surface.style.setProperty("--review-height", card._config.grid_max_height || "75vh"); + surface.toggleAttribute("data-review-compact", !!card._config.review_compact); + toolbar.hidden = !!card._config.review_compact; + const size = card._config.event_count || 60; + const count = card._events?.length || 0; + toolbar.querySelector(".review-status").textContent = state.busy ? "Loading recorded clips..." + : state.error ? "Could not load clips. Use Latest to retry." + : count ? `Page ${state.page + 1} · ${count} recorded clips · newest first` : "No recorded clips for this camera."; + toolbar.querySelector('[data-review="newer"]').disabled = state.busy || state.page === 0; + toolbar.querySelector('[data-review="older"]').disabled = state.busy || !state.more; + toolbar.querySelector('[data-review="latest"]').disabled = state.busy; + root.querySelectorAll(".events .event").forEach((tile, index) => { + const event = card._events[index]; + if (!event || index >= size) return; + for (const node of [...tile.childNodes]) { + if (node.nodeType === Node.TEXT_NODE && node.textContent.includes("}}")) node.remove(); + } + let caption = tile.querySelector(".review-caption"); + if (card._config.review_compact) { + caption?.remove(); + caption = null; + } else if (!caption) { + caption = document.createElement("div"); + caption.className = "review-caption"; + caption.append(document.createElement("strong"), document.createElement("span")); + tile.appendChild(caption); + } + if (!tile.hasAttribute("role")) { + tile.tabIndex = 0; + tile.setAttribute("role", "button"); + tile.addEventListener("keydown", keyboard => { + if (keyboard.key === "Enter" || keyboard.key === " ") { keyboard.preventDefault(); tile.click(); } + }); + } + const label = `${card._formatCameraName(event.camera)} · ${event.label}`; + const time = new Date(event.start_time * 1000).toLocaleString(); + if (caption) { + caption.querySelector("strong").textContent = label; + caption.querySelector("span").textContent = time; + } + tile.setAttribute("aria-label", `${label}, ${time}`); + }); + } + + Card.prototype.updated = function (...args) { + updated.apply(this, args); + if (this._config?.review_clips) decorate(this); + }; +} diff --git a/config/www/frigate-events-auth.js b/config/www/frigate-events-auth.js index e83c0e92..0c348f80 100644 --- a/config/www/frigate-events-auth.js +++ b/config/www/frigate-events-auth.js @@ -1,6 +1,8 @@ // Frigate Events Card v2.5.23 signs thumbnails but leaves timeline media unsigned. // Keep the HACS bundle intact and adapt only this card's timeline media loaders. -import "/hacsfiles/frigate-events-card/frigate-events-card.js?hacstag=1111882479100"; +// Version the underlying bundle as well as the adapters: an old HACS cache key +// can otherwise load a card release that does not have the timeline API. +import "/hacsfiles/frigate-events-card/frigate-events-card.js?v=2.5.23"; const Card = customElements.get("frigate-events-card"); const originalLoad = Card?.prototype._loadTimelineVideo; diff --git a/config/www/frigate-events-navigation.js b/config/www/frigate-events-navigation.js new file mode 100644 index 00000000..3f903954 --- /dev/null +++ b/config/www/frigate-events-navigation.js @@ -0,0 +1,95 @@ +// Load the shared timeline helpers and keep one Clips player open at a time. +import "/local/frigate-events-auth.js?v=2"; +import "/local/frigate-timeline-time.js?v=5"; +import "/local/frigate-clip-review.js?v=5"; + +// Keep existing bookmarks useful while the visible routes describe each page. +const renamedRoute = { + "/lovelace/camera": "/lovelace/live-cameras", + "/lovelace/cameras": "/lovelace/clips", +}[location.pathname.replace(/\/$/, "")]; +if (renamedRoute) location.replace(renamedRoute + location.search + location.hash); + +const Card = customElements.get("frigate-events-card"); +const installed = Symbol.for("frigate-events-card.dashboard-navigation"); + +if (Card?.prototype._showTimelineModal && !Card.prototype[installed]) { + Card.prototype[installed] = true; + const showTimeline = Card.prototype._showTimelineModal; + const removeTimeline = Card.prototype._removeTimelineModal; + const updated = Card.prototype.updated; + const cleanup = Card.prototype._cleanup; + const autoOpened = new WeakSet(); + let activeCard; + + function reportError(error) { + console.warn("Unable to open camera timeline", error?.message); + document.querySelector("home-assistant")?.dispatchEvent(new CustomEvent("hass-notification", { + detail: { message: "Unable to open camera history. Please try again." }, + bubbles: true, composed: true, + })); + } + + async function openCamera(card, requestedCamera) { + const cameras = card._getAvailableCameras(); + const camera = typeof requestedCamera === "string" ? requestedCamera + : card._events?.find(event => cameras.includes(event.camera))?.camera || cameras[0]; + if (!camera || !cameras.includes(camera)) return; + if (!card.isConnected) return; + card._removeTimelineModal(); + await card._showTimelineModal(camera); + } + + function findCard(root) { + const card = root.querySelector("frigate-events-card"); + if (card) return card; + for (const element of root.querySelectorAll("*")) { + if (element.shadowRoot) { + const found = findCard(element.shadowRoot); + if (found) return found; + } + } + } + + // Home Assistant's fire-dom-event action supplies the configured payload. + document.addEventListener("ll-custom", (event) => { + const camera = event.detail?.frigate_timeline; + if (camera !== true && typeof camera !== "string") return; + const card = findCard(document); + if (card?.hass) { + event.stopPropagation(); + openCamera(card, camera).catch(reportError); + } + }); + + Card.prototype._showTimelineModal = function (...args) { + if (this._config?.show_timeline === false) return; + if (activeCard && activeCard !== this) activeCard._removeTimelineModal(); + activeCard = this; + return showTimeline.apply(this, args); + }; + Card.prototype._removeTimelineModal = function (...args) { + if (activeCard === this) activeCard = undefined; + return removeTimeline.apply(this, args); + }; + Card.prototype.updated = function (...args) { + updated.apply(this, args); + if (this._config?.auto_open_timeline && this.hass && this.isConnected && !autoOpened.has(this)) { + autoOpened.add(this); + openCamera(this, this._config.timeline_camera).catch(reportError); + } + }; + Card.prototype._cleanup = function (...args) { + autoOpened.delete(this); + this._removeTimelineModal(); + return cleanup.apply(this, args); + }; + + // Nine cameras should remain discoverable on both desktop and phones. + const style = document.createElement("style"); + style.textContent = ` + .frigate-timeline-modal .timeline-camera-tabs { flex-wrap: wrap; overflow-x: visible; } + .frigate-timeline-modal .timeline-transport-controls { flex-wrap: wrap; min-width: 0; } + `; + document.head.appendChild(style); +} diff --git a/config/www/frigate-timeline-time.js b/config/www/frigate-timeline-time.js new file mode 100644 index 00000000..5aa2b463 --- /dev/null +++ b/config/www/frigate-timeline-time.js @@ -0,0 +1,264 @@ +// Keep one wall-clock selection across cameras, including recording gaps. +import "/local/frigate-events-auth.js?v=2"; + +const Card = customElements.get("frigate-events-card"); +const installed = Symbol.for("frigate-events-card.shared-time"); +const RECORDING_DELAY = 30; + +if (Card?.prototype._showTimelineModal && !Card.prototype[installed]) { + Card.prototype[installed] = true; + const show = Card.prototype._showTimelineModal; + const render = Card.prototype._renderTimelineContent; + const load = Card.prototype._loadTimelineVideo; + const updatePlayhead = Card.prototype._updateTimelinePlayheadUI; + const offsetToClock = Card.prototype._videoOffsetToWallClock; + const advance = Card.prototype._advanceToNextTimelineWindow; + const states = new WeakMap(); + const containers = new WeakSet(); + + function selectedTime(card, state) { + const video = card._timelineVideoEl; + if (!state.loading && !state.gap && video?.readyState >= 2) { + return card._videoOffsetToWallClock(video.currentTime); + } + return state.target; + } + + function showMode(card, state) { + card._timelineContainer?.querySelector('[data-jump="now"]') + ?.setAttribute("aria-pressed", String(state.now)); + } + + async function changeCamera(card, camera) { + const state = states.get(card); + if (!state || camera === card._timelineCamera) return; + state.target = state.now ? Date.now() / 1000 - RECORDING_DELAY : selectedTime(card, state); + state.pauseAfterLoad = state.loading ? state.pauseAfterLoad : !!card._timelineVideoEl?.paused; + state.loading = true; + const request = ++state.request; + const container = card._timelineContainer; + // Do not interpret an outgoing video's offset using the incoming camera's + // recording map, or expose its first frame before the requested seek. + card._timelineVideoEl?.pause(); + card._clearTimelineSpeedInterval(); + if (card._timelineVideoEl) card._timelineVideoEl.hidden = true; + const loading = container.querySelector(".timeline-player-loading"); + loading.style.display = "flex"; + loading.textContent = "Loading camera at the selected time..."; + card._isAdvancingTimeline = false; + card._timelineCamera = camera; + container.querySelectorAll(".timeline-camera-tab").forEach(tab => { + tab.classList.toggle("active", tab.dataset.camera === camera); + }); + container.querySelector(".timeline-camera-badge").textContent = card._formatCameraName(camera); + if (state.now) { + card._timelineEndTs = Math.floor(Date.now() / 1000); + card._timelineStartTs = card._timelineEndTs - card._timelineWindowDurationSec; + } + card._updateTimelineWindowUI(); + await card._fetchTimelineEvents(); + if (request !== state.request || card._timelineContainer !== container) return; + card._updateTimelineScrubberEvents(); + await card._loadTimelineVideo(state.target); + } + + Card.prototype._showTimelineModal = function (camera, timestamp, event) { + const now = !Number.isFinite(timestamp); + const state = { now, target: now ? Date.now() / 1000 - RECORDING_DELAY : timestamp, + loading: true, gap: false, request: 0, loadRequest: 0 }; + states.set(this, state); + const result = show.call(this, camera, state.target, event); + showMode(this, state); + return result; + }; + + Card.prototype._advanceToNextTimelineWindow = async function () { + const state = states.get(this); + if (!state || !this.hass) return advance.call(this); + if (state.advancePending || this._isAdvancingTimeline || !this._timelineContainer) return; + const container = this._timelineContainer; + const camera = this._timelineCamera; + const end = this._timelineEndTs; + const request = state.request; + state.advancePending = true; + try { + const now = Math.floor(Date.now() / 1000); + let hasLaterFootage = false; + if (end < now - RECORDING_DELAY) { + const response = await this.hass.callWS({ type: "frigate/recordings/get", + instance_id: this._config.frigate_client_id || "frigate", camera, + after: Math.floor(end), before: now }); + const recordings = typeof response === "string" ? JSON.parse(response) : response; + hasLaterFootage = recordings.some(recording => recording.end_time > end); + } + if (states.get(this) !== state || state.request !== request || this._timelineContainer !== container + || this._timelineCamera !== camera || this._timelineEndTs !== end) return; + // Keep the last frame when saved footage ends. The vendor otherwise + // requests an empty window as real time advances during playback. + if (hasLaterFootage) return advance.call(this); + this._timelineVideoEl?.pause(); + this._clearTimelineSpeedInterval(); + this._updateTimelinePlayheadUI(); + } catch (error) { + console.warn("Unable to check for later footage", error?.message); + } finally { + state.advancePending = false; + } + }; + + Card.prototype._renderTimelineContent = function (container) { + render.call(this, container); + if (containers.has(container)) return; + containers.add(container); + container.addEventListener("playing", event => { + const state = states.get(this); + const video = event.target; + const hls = this._timelineActiveVideo === "a" ? this._timelineHlsA : this._timelineHlsB; + const source = hls?.url || video.currentSrc || video.src || ""; + if (state && video === this._timelineVideoEl && !video.paused + && source.includes(`/${encodeURIComponent(this._timelineCamera)}/`)) { + state.loading = false; + video.hidden = false; + const loading = container.querySelector(".timeline-player-loading"); + if (loading) loading.style.display = "none"; + if (state.pauseAfterLoad) { + state.pauseAfterLoad = false; + this._timelineVideoEl?.pause(); + } + } + }, true); + container.addEventListener("click", event => { + const state = states.get(this); + if (!state) return; + const tab = event.target.closest(".timeline-camera-tab"); + if (tab) { + event.stopImmediatePropagation(); + changeCamera(this, tab.dataset.camera).catch(error => console.warn("Camera time selection failed", error?.message)); + return; + } + const jump = event.target.closest("[data-jump]"); + if (jump?.dataset.jump === "now") { + event.stopImmediatePropagation(); + state.now = true; + state.target = Date.now() / 1000 - RECORDING_DELAY; + state.loading = true; + this._isAdvancingTimeline = false; + this._timelineEndTs = Math.floor(Date.now() / 1000); + this._timelineStartTs = this._timelineEndTs - this._timelineWindowDurationSec; + this._updateTimelineWindowUI(); + showMode(this, state); + const request = ++state.request; + this._fetchTimelineEvents().then(() => { + if (request !== state.request || this._timelineContainer !== container) return; + this._updateTimelineScrubberEvents(); + return this._loadTimelineVideo(state.target); + }).catch(error => console.warn("Timeline Now selection failed", error?.message)); + return; + } + const skip = event.target.closest("[data-skip]"); + const marker = event.target.closest(".timeline-event-marker"); + if (state.gap && (skip || marker)) { + event.stopImmediatePropagation(); + state.now = false; + const target = marker ? Number(marker.dataset.eventStart) : state.target + Number(skip.dataset.skip); + this._loadTimelineVideo(Math.max(this._timelineStartTs, Math.min(this._timelineEndTs, target))); + return; + } + if (jump || event.target.closest("[data-skip],.timeline-event-marker")) { + state.now = false; + showMode(this, state); + } + if (event.target.closest('[data-action="toggle-play"],.timeline-player-container') + && !this._timelineVideoEl?.paused) { + state.target = selectedTime(this, state); + state.now = false; + showMode(this, state); + } + }, true); + container.addEventListener("pointerdown", event => { + if (event.target.closest(".timeline-track-container")) { + const state = states.get(this); + state.now = false; + showMode(this, state); + } + }, true); + container.addEventListener("pointerup", event => { + const state = states.get(this); + const track = event.target.closest(".timeline-track-container"); + if (!state?.gap || !track || event.target.closest(".timeline-event-marker")) return; + const bounds = track.getBoundingClientRect(); + const fraction = Math.max(0, Math.min(1, (event.clientX - bounds.left) / bounds.width)); + this._loadTimelineVideo(this._timelineStartTs + fraction * (this._timelineEndTs - this._timelineStartTs)); + }, true); + container.addEventListener("change", event => { + if (event.target.matches(".timeline-datetime-input")) { + const state = states.get(this); + state.now = false; + showMode(this, state); + } + }, true); + }; + + Card.prototype._loadTimelineVideo = async function (target, autoAdvance = false) { + const state = states.get(this); + const request = state ? ++state.loadRequest : 0; + if (state && Number.isFinite(target) && !autoAdvance) { + state.target = target; + state.loading = true; + state.gap = !(this._timelineRecordings || []).some(recording => + target >= recording.start_time - 0.05 && target <= recording.end_time + 0.05); + } + if (!autoAdvance) for (const video of [this._timelineVideoA, this._timelineVideoB]) { + if (video) { video.pause(); video.hidden = true; } + } + if (!autoAdvance) this._clearTimelineSpeedInterval(); + await load.call(this, target, autoAdvance); + if (!state || states.get(this) !== state || request !== state.loadRequest || !this._timelineContainer) return; + if (state.gap && !autoAdvance) { + for (const field of ["_timelineHlsA", "_timelineHlsB"]) { + this[field]?.destroy(); + this[field] = null; + } + for (const video of [this._timelineVideoA, this._timelineVideoB]) { + if (!video) continue; + video.pause(); + video.hidden = true; + video.removeAttribute("src"); + video.load(); + } + for (const field of ["_timelineLoadingTimeout", "_timelineAdvanceTimeout", "_timelineSlotTransitionTimeout"]) { + clearTimeout(this[field]); + this[field] = undefined; + } + this._isAdvancingTimeline = false; + const loading = this._timelineContainer.querySelector(".timeline-player-loading"); + loading.style.display = "flex"; + loading.textContent = `No saved footage at ${new Date(state.target * 1000).toLocaleTimeString()}. Choose another camera or time.`; + this._updateTimelinePlayheadUI(); + } + showMode(this, state); + }; + + Card.prototype._updateTimelinePlayheadUI = function (...args) { + const state = states.get(this); + if ((!state?.gap && !state?.loading) || !this._timelineContainer) return updatePlayhead.apply(this, args); + if (state.loading && !state.gap) { + const loading = this._timelineContainer.querySelector(".timeline-player-loading"); + if (loading) loading.style.display = "flex"; + } + const span = this._timelineEndTs - this._timelineStartTs; + const head = this._timelineContainer.querySelector(".timeline-playhead"); + if (head && span > 0) head.style.left = `${Math.max(0, Math.min(100, (state.target - this._timelineStartTs) / span * 100))}%`; + const badge = this._timelineContainer.querySelector("[data-timeline-current-time]"); + if (badge) badge.textContent = new Date(state.target * 1000).toLocaleTimeString(); + }; + + Card.prototype._videoOffsetToWallClock = function (offset) { + const state = states.get(this); + return state?.gap || state?.loading ? state.target : offsetToClock.call(this, offset); + }; + + const style = document.createElement("style"); + style.textContent = '.frigate-timeline-modal [data-jump="now"][aria-pressed="true"] { background: #1e3a5f; border-color: #3b82f6; color: #bfdbfe; } .frigate-timeline-modal video[hidden] { display: none; }'; + document.head.appendChild(style); +}