@hashsome/ui 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/dist/.tsbuildinfo +1 -1
  2. package/dist/entities/artwork-ring.js +39 -13
  3. package/dist/entities/media-browser.js +69 -25
  4. package/dist/entities/media-player-bar.js +2 -1
  5. package/dist/entities/media-player-column.js +2 -1
  6. package/dist/entities/media-player-full.d.ts +3 -1
  7. package/dist/entities/media-player-full.js +2 -2
  8. package/dist/entities/media-queue.d.ts +14 -0
  9. package/dist/entities/media-queue.js +151 -0
  10. package/dist/entities/top-bar.js +8 -2
  11. package/dist/entities/weather-forecast.js +3 -16
  12. package/dist/gallery/fixtures.d.ts +8 -2
  13. package/dist/gallery/fixtures.js +15 -2
  14. package/dist/gallery/gallery.js +2 -1
  15. package/dist/gallery/props-data.js +17 -0
  16. package/dist/hooks.d.ts +6 -0
  17. package/dist/hooks.js +18 -1
  18. package/dist/index.d.ts +1 -0
  19. package/dist/index.js +1 -0
  20. package/dist/layout/drawer-controls.d.ts +2 -0
  21. package/dist/layout/drawer-controls.js +11 -1
  22. package/dist/layout/marquee-text.d.ts +11 -0
  23. package/dist/layout/marquee-text.js +67 -0
  24. package/dist/layout/reveal.d.ts +11 -0
  25. package/dist/layout/reveal.js +19 -0
  26. package/dist/layout/series-chart.js +7 -2
  27. package/dist/layout/skeleton.d.ts +11 -0
  28. package/dist/layout/skeleton.js +16 -0
  29. package/dist/use-entity-history.js +5 -1
  30. package/dist/use-entity-logbook.js +5 -1
  31. package/dist/use-media-browser.d.ts +4 -0
  32. package/dist/use-media-browser.js +11 -3
  33. package/dist/use-media-queue.d.ts +15 -0
  34. package/dist/use-media-queue.js +44 -0
  35. package/dist/use-weather-forecast.js +5 -1
  36. package/package.json +2 -2
@@ -10,6 +10,7 @@ import { MediaBrowser } from '../entities/media-browser.js';
10
10
  import { MediaPlayerBar } from '../entities/media-player-bar.js';
11
11
  import { MediaPlayerColumn } from '../entities/media-player-column.js';
12
12
  import { MediaPlayerFull } from '../entities/media-player-full.js';
13
+ import { MediaQueue } from '../entities/media-queue.js';
13
14
  import { WeatherForecast } from '../entities/weather-forecast.js';
14
15
  import { NavDock } from '../entities/nav-dock.js';
15
16
  import { NavRail } from '../entities/nav-rail.js';
@@ -348,7 +349,7 @@ export function Gallery({ density = 'comfortable' }) {
348
349
  boxShadow: '0 1px 3px rgba(0, 0, 0, 0.25)',
349
350
  transform: mode === 'dark' ? 'translate(28px, -50%)' : 'translateY(-50%)',
350
351
  transition: 'transform 0.2s ease',
351
- }) }), _jsx(Icon, { name: "lu:sun", size: 14 }), _jsx(Icon, { name: "lu:moon", size: 14 })] })] }), _jsx(ComponentDoc, { title: "Grid", components: ['Grid'], description: "Lays tiles out in equal-width columns (`columns`, default 2). Cells share the width and never overflow it.", children: _jsxs(Flex, { direction: "column", gap: 5, children: [_jsxs(Grid, { columns: 2, children: [_jsx(Tile, { label: "Two", icon: "lu:layout-grid", secondary: "columns={2}" }), _jsx(Tile, { label: "Columns", icon: "lu:layout-grid", secondary: "equal width" })] }), _jsxs(Grid, { columns: 3, children: [_jsx(Tile, { label: "Three", icon: "lu:layout-grid", secondary: "columns={3}" }), _jsx(Tile, { label: "Columns", icon: "lu:layout-grid", secondary: "equal width" }), _jsx(Tile, { label: "Wide", icon: "lu:layout-grid", secondary: "fits the row" })] })] }) }), _jsx(ComponentDoc, { title: "Room Header", components: ['RoomHeader'], description: "The header that starts a room: a muted icon and title, a hairline and readouts on the right, placed above a Grid (or any tiles). It adds its own space above, so consecutive rooms read as groups.", children: _jsxs(Flex, { direction: "column", gap: 4, children: [_jsx(RoomHeader, { title: "Kitchen", icon: "lu:utensils-crossed" }), _jsx(RoomHeader, { title: "Porch", icon: "lu:house", readouts: _jsxs(_Fragment, { children: [_jsx(SensorReadout, { entity: "ha:sensor.temperature" }), _jsx(SensorReadout, { entity: "ha:sensor.humidity" })] }) })] }) }), _jsx(ComponentDoc, { title: "Tile", components: ['Tile'], description: "The base card every entity tile is built from: icon, label, status line, optional fill bar (drag to change it), trailing controls, a hold-to-open drawer and pending / done / error feedback. Use it directly for something that isn't an entity.", children: _jsxs(Grid, { children: [_jsx(Tile, { label: "Plain", icon: "lu:lamp", secondary: "Off" }), _jsx(Tile, { label: "Active", icon: "lu:lamp", active: true }), _jsx(TileFillDemo, {}), _jsx(Tile, { label: "With controls", icon: "lu:fan", trailing: _jsx(Typography, { as: "span", variant: "secondary", color: "textMuted", children: "trailing" }) }), _jsx(Tile, { label: "Pending", icon: "lu:lamp", feedback: "pending" }), _jsx(Tile, { label: "Error", icon: "lu:lamp", feedback: "error" }), _jsx(Tile, { label: "Unavailable", icon: "lu:lamp", status: "unavailable" }), _jsx(Tile, { label: "Not found", icon: "lu:lamp", status: "missing" })] }) }), _jsx(ComponentDoc, { title: "Icon", components: ['Icon'], description: "A plain prefixed string, no import: `lu:` is Lucide, `tb:` is Tabler outline. It takes the surrounding text color and scales with `size` (default 24px).", children: _jsxs(Flex, { direction: "column", gap: 4, children: [_jsxs(Flex, { align: "center", gap: 4, children: [_jsx(Icon, { name: "lu:lightbulb" }), _jsx(Icon, { name: "lu:thermometer", size: 16 }), _jsx(Icon, { name: "lu:sofa", size: 32 }), _jsx(Icon, { name: "tb:vacuum-cleaner" }), _jsx(Icon, { name: "tb:car-fan", size: 16 })] }), _jsxs(Flex, { align: "center", gap: 4, css: { flexWrap: 'wrap' }, children: [_jsx(Typography, { as: "span", variant: "body", color: "textMuted", children: "Find an icon:" }), ICON_PACKS.map((pack) => (_jsx(Typography, { as: "span", variant: "body", children: _jsxs("a", { css: ({ palette }) => ({ color: palette.accent, textUnderlineOffset: 3 }), href: pack.href, target: "_blank", rel: "noreferrer", children: ["Search ", pack.name, " icons (", _jsxs("span", { css: codeStyles, children: [pack.prefix, ":"] }), ")"] }) }, pack.prefix)))] })] }) }), _jsx(ComponentDoc, { title: "Energy Chart", components: ['EnergyChart'], description: "A power section for a drawer, which tiles pass through `energy`: a live power reading, usage tiles and a sparkline. Expanded in a drawer it becomes the full chart with a range switcher.", children: _jsx(EnergyChart, { power: "ha:sensor.heater_power", energy: "ha:sensor.heater_energy", usage: heaterUsage, seriesByRange: heaterSeries }) }), _jsx(ComponentDoc, { title: "History Section", components: ['HistorySection'], description: "Recent activity for a drawer, which tiles pass through `history`: who or what changed the entity, and when. An empty list renders nothing.", children: _jsx(HistorySection, { entries: lampHistory }) }), _jsx(ComponentDoc, { title: "Light Tile", components: ['LightTile'], description: "Tap to toggle, hold to open its detail drawer. Dimmable lights drag to set brightness; color-capable lights add a palette button that opens swatches in the card (white temperatures by default, overridable with `colors`), and the card's icon shows the light's current color. The drawer has the full temperature and hue bars.", children: _jsxs(Grid, { children: [_jsx(LightTile, { entity: "ha:light.plain_on", energy: lampEnergy, history: lampHistory }), _jsx(LightTile, { entity: "ha:light.plain_off", history: stairsHistory }), _jsx(LightTile, { entity: "ha:light.dimmable", icon: "lu:sparkles", energy: ledEnergy, history: ledHistory }), _jsx(LightTile, { entity: "ha:light.color", icon: "lu:lamp-desk", history: nightLampHistory }), _jsx(LightTile, { entity: "ha:light.cct", icon: "lu:lamp-desk" }), _jsx(LightTile, { entity: "ha:light.unavailable" }), _jsx(LightTile, { entity: "ha:light.missing" })] }) }), _jsx(ComponentDoc, { title: "Climate Tile", components: ['ClimateTile'], description: "A heater/thermostat tile: a mode button that opens mode swatches in the card (like light colors), plus a target-temperature stepper. Hold for the drawer: modes, a target bar with fine +/- (hold to repeat), presets, energy and history.", children: _jsxs(Grid, { columns: 1, children: [_jsx(ClimateTile, { entity: "ha:climate.heater", energy: heaterEnergy, history: heaterHistory }), _jsx(ClimateTile, { entity: "ha:climate.off", energy: thermostatEnergy, history: heaterHistory }), _jsx(ClimateTile, { entity: "ha:climate.unavailable" })] }) }), _jsx(ComponentDoc, { title: "Sensor Readout", components: ['SensorReadout'], description: "An inline value with icon and unit, defaulting its icon by device class. Readings outside a safe range get a warning (humidity defaults to 30\u201360 %; override with `safeRange`). Pass `history` to make it tappable: the drawer charts it with min/max for today, this week and this month, and a custom date and time range when expanded.", children: _jsxs(Grid, { columns: 3, children: [_jsx(SensorReadout, { entity: "ha:sensor.temperature", history: temperatureHistory }), _jsx(SensorReadout, { entity: "ha:sensor.humidity", history: humidityHistory }), _jsx(SensorReadout, { entity: "ha:sensor.bedroom_humidity", history: bedroomHumidityHistory }), _jsx(SensorReadout, { entity: "ha:sensor.office_humidity", history: officeHumidityHistory }), _jsx(SensorReadout, { entity: "ha:sensor.unavailable" }), _jsx(SensorReadout, { entity: "ha:sensor.unknown" })] }) }), _jsx(ComponentDoc, { title: "Scene Button & Action Button", components: ['SceneButton', 'ActionButton'], description: "A scene is just an action entity. An action button triggers an action (or flips a switch) and shows pending / done / error feedback after a press; `onPress` runs your own logic instead.", children: _jsxs(Grid, { columns: 3, children: [_jsx(SceneButton, { entity: "ha:scene.tv_time" }), _jsx(ActionButton, { label: "Shower", icon: "tb:bath", entity: "ha:script.shower" }), _jsx(ActionButton, { label: "Always Fails", onPress: () => Promise.reject(new Error('That went wrong')) })] }) }), _jsx(ComponentDoc, { title: "Media Player Bar", components: ['MediaPlayerBar'], description: "Track info (with a small shuffle toggle by the title) and the time beside it, then volume, browse, previous, play/pause and next (the artwork opens the browser too), with a thin progress line along the bottom while something plays. The browse button opens the player's own library in the drawer; pass `browse` content to replace it, or `false` for no button. The volume button opens a volume slider in the bar (with a mute swatch, like the light colors and climate modes). Shows album art, or a note glyph when there is none.", children: _jsxs(Flex, { direction: "column", gap: 3, children: [_jsx(MediaPlayerBar, { entity: "ha:media_player.living_room" }), _jsx(MediaPlayerBar, { entity: "ha:media_player.off" })] }) }), _jsx(ComponentDoc, { title: "Media Player Column", components: ['MediaPlayerColumn'], description: "The player as an upright card for a narrow column beside a dashboard: the artwork in a ring that shows how far along playback is (drag around it to seek), title, transport, and a volume bar that is always visible. The browse button opens the library in the drawer.", children: _jsx(Flex, { width: 320, children: _jsx(MediaPlayerColumn, { entity: "ha:media_player.living_room" }) }) }), _jsx(ComponentDoc, { title: "Media Player Full", components: ['MediaPlayerFull'], description: "The big player: the player centered, and its library below it, so songs are picked right there instead of in a drawer. The media cards' drawers show it too. A dashboard puts it in a page of its own, around it whatever it likes.", children: _jsx(Flex, { direction: "column", children: _jsx(MediaPlayerFull, { entity: "ha:media_player.living_room", wide: true, browser: _jsx(MediaBrowser, { entity: "ha:media_player.living_room", layout: "theater" }) }) }) }), _jsx(ComponentDoc, { title: "Weather Forecast", components: ['WeatherForecast'], description: "A weather entity's forecast: the weather now, the next 24 hours as a chart (hover it for everything about an hour) and the days ahead. The top bar's weather chip shows it in a drawer; a dashboard can put it in a page of its own. Narrow, then with `expanded` (readings tiles, a card for each hour).", children: _jsxs(Flex, { direction: "column", gap: 6, children: [_jsx(Flex, { direction: "column", width: 380, children: _jsx(WeatherForecast, { entity: "ha:weather.home", expanded: false }) }), _jsx(WeatherForecast, { entity: "ha:weather.home", expanded: true })] }) }), _jsx(ComponentDoc, { title: "Media Browser", components: ['MediaBrowser'], description: "A player's own library in the app's style: tap a folder to open it, a track or station to play it, or the play button on an album, playlist or artist for all of it. The search box appears when the library can be searched. The `theater` layout (second demo) is one row of large cards that scrolls sideways, for a wide space; `list` is the compact default.", children: _jsxs(Flex, { direction: "column", gap: 6, children: [_jsx(Flex, { direction: "column", maxHeight: 420, overflow: "auto", children: _jsx(MediaBrowser, { entity: "ha:media_player.living_room" }) }), _jsx(MediaBrowser, { entity: "ha:media_player.living_room", layout: "theater" })] }) }), _jsx(ComponentDoc, { title: "Navigation", components: ['NavRail', 'NavDock', 'Page'], description: "Routes within one dashboard. A dashboard includes one itself, usually in its layout route: NavRail is fixed to the left and NavDock floats at the bottom, and both reserve the space they take in the Page around them, which pads so content never sits under them.", children: _jsxs(Flex, { direction: "column", gap: 5, children: [_jsx(SubPreview, { label: "NavRail, in a Page", height: 240, children: _jsxs(Page, { height: "100%", children: [_jsx(NavRail, { base: "/gallery", items: NAV_ITEMS }), _jsx(Typography, { as: "p", variant: "body", color: "textMuted", children: "Page content, padded clear of the rail." })] }) }), _jsx(SubPreview, { label: "NavDock", height: 140, children: _jsx(NavDock, { base: "/gallery", items: NAV_ITEMS }) })] }) }), _jsx(ComponentDoc, { title: "Top Bar", components: ['TopBar'], description: "Page chrome above a dashboard's content: a title (a dropdown to switch dashboards, once there's more than one to switch to), scene shortcuts, weather (tap it for the forecast), presence, and a clock.", children: _jsx(TopBar, { title: "Living Room", dashboards: [
352
+ }) }), _jsx(Icon, { name: "lu:sun", size: 14 }), _jsx(Icon, { name: "lu:moon", size: 14 })] })] }), _jsx(ComponentDoc, { title: "Grid", components: ['Grid'], description: "Lays tiles out in equal-width columns (`columns`, default 2). Cells share the width and never overflow it.", children: _jsxs(Flex, { direction: "column", gap: 5, children: [_jsxs(Grid, { columns: 2, children: [_jsx(Tile, { label: "Two", icon: "lu:layout-grid", secondary: "columns={2}" }), _jsx(Tile, { label: "Columns", icon: "lu:layout-grid", secondary: "equal width" })] }), _jsxs(Grid, { columns: 3, children: [_jsx(Tile, { label: "Three", icon: "lu:layout-grid", secondary: "columns={3}" }), _jsx(Tile, { label: "Columns", icon: "lu:layout-grid", secondary: "equal width" }), _jsx(Tile, { label: "Wide", icon: "lu:layout-grid", secondary: "fits the row" })] })] }) }), _jsx(ComponentDoc, { title: "Room Header", components: ['RoomHeader'], description: "The header that starts a room: a muted icon and title, a hairline and readouts on the right, placed above a Grid (or any tiles). It adds its own space above, so consecutive rooms read as groups.", children: _jsxs(Flex, { direction: "column", gap: 4, children: [_jsx(RoomHeader, { title: "Kitchen", icon: "lu:utensils-crossed" }), _jsx(RoomHeader, { title: "Porch", icon: "lu:house", readouts: _jsxs(_Fragment, { children: [_jsx(SensorReadout, { entity: "ha:sensor.temperature" }), _jsx(SensorReadout, { entity: "ha:sensor.humidity" })] }) })] }) }), _jsx(ComponentDoc, { title: "Tile", components: ['Tile'], description: "The base card every entity tile is built from: icon, label, status line, optional fill bar (drag to change it), trailing controls, a hold-to-open drawer and pending / done / error feedback. Use it directly for something that isn't an entity.", children: _jsxs(Grid, { children: [_jsx(Tile, { label: "Plain", icon: "lu:lamp", secondary: "Off" }), _jsx(Tile, { label: "Active", icon: "lu:lamp", active: true }), _jsx(TileFillDemo, {}), _jsx(Tile, { label: "With controls", icon: "lu:fan", trailing: _jsx(Typography, { as: "span", variant: "secondary", color: "textMuted", children: "trailing" }) }), _jsx(Tile, { label: "Pending", icon: "lu:lamp", feedback: "pending" }), _jsx(Tile, { label: "Error", icon: "lu:lamp", feedback: "error" }), _jsx(Tile, { label: "Unavailable", icon: "lu:lamp", status: "unavailable" }), _jsx(Tile, { label: "Not found", icon: "lu:lamp", status: "missing" })] }) }), _jsx(ComponentDoc, { title: "Icon", components: ['Icon'], description: "A plain prefixed string, no import: `lu:` is Lucide, `tb:` is Tabler outline. It takes the surrounding text color and scales with `size` (default 24px).", children: _jsxs(Flex, { direction: "column", gap: 4, children: [_jsxs(Flex, { align: "center", gap: 4, children: [_jsx(Icon, { name: "lu:lightbulb" }), _jsx(Icon, { name: "lu:thermometer", size: 16 }), _jsx(Icon, { name: "lu:sofa", size: 32 }), _jsx(Icon, { name: "tb:vacuum-cleaner" }), _jsx(Icon, { name: "tb:car-fan", size: 16 })] }), _jsxs(Flex, { align: "center", gap: 4, css: { flexWrap: 'wrap' }, children: [_jsx(Typography, { as: "span", variant: "body", color: "textMuted", children: "Find an icon:" }), ICON_PACKS.map((pack) => (_jsx(Typography, { as: "span", variant: "body", children: _jsxs("a", { css: ({ palette }) => ({ color: palette.accent, textUnderlineOffset: 3 }), href: pack.href, target: "_blank", rel: "noreferrer", children: ["Search ", pack.name, " icons (", _jsxs("span", { css: codeStyles, children: [pack.prefix, ":"] }), ")"] }) }, pack.prefix)))] })] }) }), _jsx(ComponentDoc, { title: "Energy Chart", components: ['EnergyChart'], description: "A power section for a drawer, which tiles pass through `energy`: a live power reading, usage tiles and a sparkline. Expanded in a drawer it becomes the full chart with a range switcher.", children: _jsx(EnergyChart, { power: "ha:sensor.heater_power", energy: "ha:sensor.heater_energy", usage: heaterUsage, seriesByRange: heaterSeries }) }), _jsx(ComponentDoc, { title: "History Section", components: ['HistorySection'], description: "Recent activity for a drawer, which tiles pass through `history`: who or what changed the entity, and when. An empty list renders nothing.", children: _jsx(HistorySection, { entries: lampHistory }) }), _jsx(ComponentDoc, { title: "Light Tile", components: ['LightTile'], description: "Tap to toggle, hold to open its detail drawer. Dimmable lights drag to set brightness; color-capable lights add a palette button that opens swatches in the card (white temperatures by default, overridable with `colors`), and the card's icon shows the light's current color. The drawer has the full temperature and hue bars.", children: _jsxs(Grid, { children: [_jsx(LightTile, { entity: "ha:light.plain_on", energy: lampEnergy, history: lampHistory }), _jsx(LightTile, { entity: "ha:light.plain_off", history: stairsHistory }), _jsx(LightTile, { entity: "ha:light.dimmable", icon: "lu:sparkles", energy: ledEnergy, history: ledHistory }), _jsx(LightTile, { entity: "ha:light.color", icon: "lu:lamp-desk", history: nightLampHistory }), _jsx(LightTile, { entity: "ha:light.cct", icon: "lu:lamp-desk" }), _jsx(LightTile, { entity: "ha:light.unavailable" }), _jsx(LightTile, { entity: "ha:light.missing" })] }) }), _jsx(ComponentDoc, { title: "Climate Tile", components: ['ClimateTile'], description: "A heater/thermostat tile: a mode button that opens mode swatches in the card (like light colors), plus a target-temperature stepper. Hold for the drawer: modes, a target bar with fine +/- (hold to repeat), presets, energy and history.", children: _jsxs(Grid, { columns: 1, children: [_jsx(ClimateTile, { entity: "ha:climate.heater", energy: heaterEnergy, history: heaterHistory }), _jsx(ClimateTile, { entity: "ha:climate.off", energy: thermostatEnergy, history: heaterHistory }), _jsx(ClimateTile, { entity: "ha:climate.unavailable" })] }) }), _jsx(ComponentDoc, { title: "Sensor Readout", components: ['SensorReadout'], description: "An inline value with icon and unit, defaulting its icon by device class. Readings outside a safe range get a warning (humidity defaults to 30\u201360 %; override with `safeRange`). Pass `history` to make it tappable: the drawer charts it with min/max for today, this week and this month, and a custom date and time range when expanded.", children: _jsxs(Grid, { columns: 3, children: [_jsx(SensorReadout, { entity: "ha:sensor.temperature", history: temperatureHistory }), _jsx(SensorReadout, { entity: "ha:sensor.humidity", history: humidityHistory }), _jsx(SensorReadout, { entity: "ha:sensor.bedroom_humidity", history: bedroomHumidityHistory }), _jsx(SensorReadout, { entity: "ha:sensor.office_humidity", history: officeHumidityHistory }), _jsx(SensorReadout, { entity: "ha:sensor.unavailable" }), _jsx(SensorReadout, { entity: "ha:sensor.unknown" })] }) }), _jsx(ComponentDoc, { title: "Scene Button & Action Button", components: ['SceneButton', 'ActionButton'], description: "A scene is just an action entity. An action button triggers an action (or flips a switch) and shows pending / done / error feedback after a press; `onPress` runs your own logic instead.", children: _jsxs(Grid, { columns: 3, children: [_jsx(SceneButton, { entity: "ha:scene.tv_time" }), _jsx(ActionButton, { label: "Shower", icon: "tb:bath", entity: "ha:script.shower" }), _jsx(ActionButton, { label: "Always Fails", onPress: () => Promise.reject(new Error('That went wrong')) })] }) }), _jsx(ComponentDoc, { title: "Media Player Bar", components: ['MediaPlayerBar'], description: "Track info (with a small shuffle toggle by the title) and the time beside it, then volume, browse, previous, play/pause and next (the artwork opens the browser too), with a thin progress line along the bottom while something plays. The browse button opens the player's own library in the drawer; pass `browse` content to replace it, or `false` for no button. The volume button opens a volume slider in the bar (with a mute swatch, like the light colors and climate modes). Shows album art, or a note glyph when there is none.", children: _jsxs(Flex, { direction: "column", gap: 3, children: [_jsx(MediaPlayerBar, { entity: "ha:media_player.living_room" }), _jsx(MediaPlayerBar, { entity: "ha:media_player.off" })] }) }), _jsx(ComponentDoc, { title: "Media Player Column", components: ['MediaPlayerColumn'], description: "The player as an upright card for a narrow column beside a dashboard: the artwork in a ring that shows how far along playback is (drag around it to seek), title, transport, and a volume bar that is always visible. The browse button opens the library in the drawer.", children: _jsx(Flex, { width: 320, children: _jsx(MediaPlayerColumn, { entity: "ha:media_player.living_room" }) }) }), _jsx(ComponentDoc, { title: "Media Player Full", components: ['MediaPlayerFull', 'MediaQueue'], description: "The big player: the player centered with its queue beside it (`MediaQueue`: tap a track to jump to it, the cross takes it out, Clear empties it), and its library below, where every track can be played, played next or added to the queue, and an open album or playlist can be played, shuffled or added whole. The media cards' drawers show the player and library too. A dashboard puts it in a page of its own, around it whatever it likes.", children: _jsx(Flex, { direction: "column", children: _jsx(MediaPlayerFull, { entity: "ha:media_player.living_room", wide: true, queue: _jsx(MediaQueue, { entity: "ha:media_player.living_room" }), browser: _jsx(MediaBrowser, { entity: "ha:media_player.living_room", layout: "theater" }) }) }) }), _jsx(ComponentDoc, { title: "Weather Forecast", components: ['WeatherForecast'], description: "A weather entity's forecast: the weather now, the next 24 hours as a chart (hover it for everything about an hour) and the days ahead. The top bar's weather chip shows it in a drawer; a dashboard can put it in a page of its own. Narrow, then with `expanded` (readings tiles, a card for each hour).", children: _jsxs(Flex, { direction: "column", gap: 6, children: [_jsx(Flex, { direction: "column", width: 380, children: _jsx(WeatherForecast, { entity: "ha:weather.home", expanded: false }) }), _jsx(WeatherForecast, { entity: "ha:weather.home", expanded: true })] }) }), _jsx(ComponentDoc, { title: "Media Browser", components: ['MediaBrowser'], description: "A player's own library in the app's style: tap a folder to open it, a track or station to play it, or the play button on an album, playlist or artist for all of it. The search box appears when the library can be searched. The `theater` layout (second demo) is one row of large cards that scrolls sideways, for a wide space; `list` is the compact default. While a shelf loads, placeholders take the room the cards will (third demo, a shelf that never arrives), so nothing moves.", children: _jsxs(Flex, { direction: "column", gap: 6, children: [_jsx(Flex, { direction: "column", maxHeight: 420, overflow: "auto", children: _jsx(MediaBrowser, { entity: "ha:media_player.living_room" }) }), _jsx(MediaBrowser, { entity: "ha:media_player.living_room", layout: "theater" }), _jsx(MediaBrowser, { entity: "ha:media_player.loading", layout: "theater" })] }) }), _jsx(ComponentDoc, { title: "Navigation", components: ['NavRail', 'NavDock', 'Page'], description: "Routes within one dashboard. A dashboard includes one itself, usually in its layout route: NavRail is fixed to the left and NavDock floats at the bottom, and both reserve the space they take in the Page around them, which pads so content never sits under them.", children: _jsxs(Flex, { direction: "column", gap: 5, children: [_jsx(SubPreview, { label: "NavRail, in a Page", height: 240, children: _jsxs(Page, { height: "100%", children: [_jsx(NavRail, { base: "/gallery", items: NAV_ITEMS }), _jsx(Typography, { as: "p", variant: "body", color: "textMuted", children: "Page content, padded clear of the rail." })] }) }), _jsx(SubPreview, { label: "NavDock", height: 140, children: _jsx(NavDock, { base: "/gallery", items: NAV_ITEMS }) })] }) }), _jsx(ComponentDoc, { title: "Top Bar", components: ['TopBar'], description: "Page chrome above a dashboard's content: a title (a dropdown to switch dashboards, once there's more than one to switch to), scene shortcuts, weather (tap it for the forecast), presence, and a clock.", children: _jsx(TopBar, { title: "Living Room", dashboards: [
352
353
  { id: 'living-room', title: 'Living Room', icon: 'lu:sofa' },
353
354
  { id: 'bedroom', title: 'Bedroom', icon: 'lu:bed' },
354
355
  { id: 'bathroom', title: 'Bathroom', icon: 'tb:bath' },
@@ -486,6 +486,12 @@ export const COMPONENT_PROPS = {
486
486
  optional: true,
487
487
  type: 'ReactNode',
488
488
  },
489
+ {
490
+ doc: 'What goes beside the player in a wide space, usually the player\'s queue: `<MediaQueue entity="ma:living_room" />`. Only used when `wide` and the player has a queue; the narrow layout has no room for it.',
491
+ name: 'queue',
492
+ optional: true,
493
+ type: 'ReactNode',
494
+ },
489
495
  {
490
496
  doc: 'Calls the player this instead of the name it reports.',
491
497
  name: 'name',
@@ -523,6 +529,17 @@ export const COMPONENT_PROPS = {
523
529
  },
524
530
  ],
525
531
  },
532
+ MediaQueue: {
533
+ doc: "A player's queue: the tracks that have just played (dimmed), the one playing, and what comes next, as a list that scrolls. Tap a track to jump to it, the cross takes it out, and Clear takes out everything after the track that is playing, which carries on. What comes next can be put in another order by dragging a track by its handle (or with the arrow keys on the handle). Once the last track has played and the player has stopped, that track is shown as played, not as playing. It is made to sit beside the player in a wide space (see `MediaPlayerFull`'s `queue`).",
534
+ props: [
535
+ {
536
+ doc: 'The media player, as a ref like `ma:living_room`. Shows nothing for a player with no queue.',
537
+ name: 'entity',
538
+ optional: false,
539
+ type: 'EntityRef',
540
+ },
541
+ ],
542
+ },
526
543
  NavRail: {
527
544
  doc: "Fixed left sidebar for switching between a dashboard's pages. Must be rendered inside a router. It is `position: fixed` and reserves its width in the surrounding `Page`, which pads for it so content never sits under it.",
528
545
  props: [
package/dist/hooks.d.ts CHANGED
@@ -11,6 +11,12 @@ export declare function useCommand(ref: EntityRef | undefined): CommandSender;
11
11
  export declare function useEntityHandle<K extends EntityKind>(kind: K, source: EntityRef | EntityHandle<K>): EntityHandle<K>;
12
12
  /** Connection between this browser and the runtime. */
13
13
  export declare function useConnectionStatus(): LinkStatus;
14
+ /**
15
+ * How many times the connection to the runtime has opened. A hook that asked for something while it
16
+ * was down (and got an error) depends on this, so it asks again when the connection is back: a read
17
+ * that failed because the page was ahead of the connection, or because it dropped, is not final.
18
+ */
19
+ export declare function useReconnects(): number;
14
20
  /** Status of the runtime's connection to a backend (`ha`, `ma`, ...). */
15
21
  export declare function useIntegrationStatus(integration: string): ConnectionStatus | undefined;
16
22
  /** Status of every backend integration the runtime reports, keyed by integration id. */
package/dist/hooks.js CHANGED
@@ -1,4 +1,4 @@
1
- import { useCallback, useMemo, useSyncExternalStore } from 'react';
1
+ import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react';
2
2
  import { toHandle } from './entity-handle.js';
3
3
  import { useClient } from './provider.js';
4
4
  // Every `subscribe` handed to `useSyncExternalStore` below is memoized: React resubscribes whenever
@@ -33,6 +33,23 @@ export function useConnectionStatus() {
33
33
  const subscribe = useCallback((onChange) => client.onLinkChange(onChange), [client]);
34
34
  return useSyncExternalStore(subscribe, () => client.link, () => 'closed');
35
35
  }
36
+ /**
37
+ * How many times the connection to the runtime has opened. A hook that asked for something while it
38
+ * was down (and got an error) depends on this, so it asks again when the connection is back: a read
39
+ * that failed because the page was ahead of the connection, or because it dropped, is not final.
40
+ */
41
+ export function useReconnects() {
42
+ const link = useConnectionStatus();
43
+ const [opened, setOpened] = useState(0);
44
+ const was = useRef(link);
45
+ useEffect(() => {
46
+ if (link === 'open' && was.current !== 'open') {
47
+ setOpened((count) => count + 1);
48
+ }
49
+ was.current = link;
50
+ }, [link]);
51
+ return opened;
52
+ }
36
53
  /** Status of the runtime's connection to a backend (`ha`, `ma`, ...). */
37
54
  export function useIntegrationStatus(integration) {
38
55
  const client = useClient();
package/dist/index.d.ts CHANGED
@@ -25,6 +25,7 @@ export * from './entities/media-player-column.tsx';
25
25
  export * from './entities/media-player-full.tsx';
26
26
  export * from './entities/weather-forecast.tsx';
27
27
  export * from './entities/media-browser.tsx';
28
+ export * from './entities/media-queue.tsx';
28
29
  export { formatDuration } from './entities/media-progress.ts';
29
30
  export * from './entities/nav-rail.tsx';
30
31
  export * from './entities/nav-dock.tsx';
package/dist/index.js CHANGED
@@ -19,6 +19,7 @@ export * from './entities/media-player-column.js';
19
19
  export * from './entities/media-player-full.js';
20
20
  export * from './entities/weather-forecast.js';
21
21
  export * from './entities/media-browser.js';
22
+ export * from './entities/media-queue.js';
22
23
  export { formatDuration } from './entities/media-progress.js';
23
24
  export * from './entities/nav-rail.js';
24
25
  export * from './entities/nav-dock.js';
@@ -31,6 +31,8 @@ export declare function PowerButton({ on, onToggle }: {
31
31
  on: boolean;
32
32
  onToggle: () => void;
33
33
  }): import("@emotion/react/jsx-runtime").JSX.Element;
34
+ /** The height of every chip, whatever its label holds. */
35
+ export declare const CHIP_HEIGHT = 28;
34
36
  /** A wrapping row of pill choices (modes, presets) with the selected one filled. With `tabs` it is
35
37
  * a tab list (the selected chip is the open tab) that never wraps: too many to fit scroll sideways. */
36
38
  export declare function ChipRow({ options, value, onChange, tabs, }: {
@@ -2,6 +2,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "@emotion/react/jsx-runtime";
2
2
  /** @jsxImportSource @emotion/react */
3
3
  import { Box, Flex, Typography } from 'e-prim';
4
4
  import { motion } from 'motion/react';
5
+ import { MarqueeText } from './marquee-text.js';
5
6
  import { useEffect, useRef } from 'react';
6
7
  import { Icon } from '../icon.js';
7
8
  import { FadeScroll } from './fade-scroll.js';
@@ -79,6 +80,8 @@ export function ValueBar({ label, value, min, max, keyStep, round = 1, gradient,
79
80
  export function PowerButton({ on, onToggle }) {
80
81
  return (_jsx(Flex, { as: "button", type: "button", "aria-pressed": on, "aria-label": on ? 'Turn off' : 'Turn on', title: on ? 'Turn off' : 'Turn on', onClick: onToggle, align: "center", justify: "center", radius: "full", cursor: "pointer", width: 36, height: 36, p: 0, background: on ? 'accent' : 'surface', color: on ? 'accentText' : 'line', css: { flex: 'none', transition: 'background-color 0.2s ease, color 0.2s ease' }, children: _jsx(Icon, { name: "lu:power", size: 16 }) }));
81
82
  }
83
+ /** The height of every chip, whatever its label holds. */
84
+ export const CHIP_HEIGHT = 28;
82
85
  /** A wrapping row of pill choices (modes, presets) with the selected one filled. With `tabs` it is
83
86
  * a tab list (the selected chip is the open tab) that never wraps: too many to fit scroll sideways. */
84
87
  export function ChipRow({ options, value, onChange, tabs = false, }) {
@@ -88,6 +91,13 @@ export function ChipRow({ options, value, onChange, tabs = false, }) {
88
91
  ? { flexShrink: 0, scrollbarWidth: 'none', '&::-webkit-scrollbar': { display: 'none' } }
89
92
  : { flexWrap: 'wrap' }, children: options.map((option) => {
90
93
  const selected = option.value === value;
91
- return (_jsxs(Flex, { as: "button", type: "button", ...(tabs ? { role: 'tab', 'aria-selected': selected } : { 'aria-pressed': selected }), ...(option.ariaLabel ? { 'aria-label': option.ariaLabel } : {}), onClick: () => onChange(option.value), align: "center", gap: 1.5, radius: "full", cursor: "pointer", py: 1.5, px: 3, background: selected ? 'accent' : 'surface', color: selected ? 'accentText' : 'text', css: { flex: 'none', whiteSpace: 'nowrap' }, children: [option.icon ? _jsx(Icon, { name: option.icon, size: 14 }) : null, _jsx(Typography, { as: "span", variant: "body", noWrap: true, textOverflow: "ellipsis", css: { maxWidth: 220 }, children: option.label })] }, option.value));
94
+ return (_jsxs(Flex, { as: "button", type: "button", ...(tabs ? { role: 'tab', 'aria-selected': selected } : { 'aria-pressed': selected }), ...(option.ariaLabel ? { 'aria-label': option.ariaLabel } : {}), onClick: () => onChange(option.value), align: "center", gap: 1.5, radius: "full", cursor: "pointer",
95
+ // A fixed height, not the text's: an emoji is drawn from another font with a taller line,
96
+ // which would make this one chip taller than the rest.
97
+ height: CHIP_HEIGHT, px: 3, background: selected ? 'accent' : 'surface', color: selected ? 'accentText' : 'text', css: {
98
+ flex: 'none',
99
+ whiteSpace: 'nowrap',
100
+ transition: 'background-color 160ms ease, color 160ms ease',
101
+ }, children: [option.icon ? _jsx(Icon, { name: option.icon, size: 14 }) : null, _jsx(Typography, { as: "span", variant: "body", css: { minWidth: 0, maxWidth: 220 }, children: _jsx(MarqueeText, { children: option.label }) })] }, option.value));
92
102
  }) }));
93
103
  }
@@ -0,0 +1,11 @@
1
+ import { type ReactNode } from 'react';
2
+ /**
3
+ * One line of text that fits the width it is given: when it is too long it rolls sideways, resting at
4
+ * each end, so all of it can be read; when it fits it just sits there. Whoever it is in decides the
5
+ * width (a `max-width` on it or on a parent), so a long title can never push its tile out of shape,
6
+ * whatever it holds (an emoji at the end included). Where the person asks their device for less
7
+ * motion it stays still and ends in an ellipsis.
8
+ */
9
+ export declare function MarqueeText({ children }: {
10
+ children: ReactNode;
11
+ }): import("@emotion/react/jsx-runtime").JSX.Element;
@@ -0,0 +1,67 @@
1
+ import { jsx as _jsx } from "@emotion/react/jsx-runtime";
2
+ /** @jsxImportSource @emotion/react */
3
+ import { motion, useReducedMotion } from 'motion/react';
4
+ import { useEffect, useRef, useState } from 'react';
5
+ /** How fast the text rolls, in px a second. */
6
+ const SPEED = 36;
7
+ /** How long it waits at each end, in seconds. */
8
+ const PAUSE = 1.4;
9
+ /**
10
+ * One line of text that fits the width it is given: when it is too long it rolls sideways, resting at
11
+ * each end, so all of it can be read; when it fits it just sits there. Whoever it is in decides the
12
+ * width (a `max-width` on it or on a parent), so a long title can never push its tile out of shape,
13
+ * whatever it holds (an emoji at the end included). Where the person asks their device for less
14
+ * motion it stays still and ends in an ellipsis.
15
+ */
16
+ export function MarqueeText({ children }) {
17
+ const outer = useRef(null);
18
+ const inner = useRef(null);
19
+ const [overflow, setOverflow] = useState(0);
20
+ const still = useReducedMotion();
21
+ useEffect(() => {
22
+ const frame = outer.current;
23
+ const text = inner.current;
24
+ if (!frame || !text) {
25
+ return;
26
+ }
27
+ const measure = () => setOverflow(Math.max(0, Math.ceil(text.scrollWidth - frame.clientWidth)));
28
+ measure();
29
+ if (typeof ResizeObserver === 'undefined') {
30
+ return;
31
+ }
32
+ // The frame changes with the room it has; the text with what it says.
33
+ const watch = new ResizeObserver(measure);
34
+ watch.observe(frame);
35
+ watch.observe(text);
36
+ return () => watch.disconnect();
37
+ }, []);
38
+ const rolling = overflow > 0 && !still;
39
+ const seconds = overflow / SPEED;
40
+ return (_jsx("span", { ref: outer, css: {
41
+ display: 'block',
42
+ // A line height of its own, not the font's: an emoji comes from another font with a taller
43
+ // line, and would make a row with one in its title taller than the rest.
44
+ lineHeight: 1.3,
45
+ minWidth: 0,
46
+ maxWidth: '100%',
47
+ overflow: 'hidden',
48
+ whiteSpace: 'nowrap',
49
+ textOverflow: rolling ? 'clip' : 'ellipsis',
50
+ }, children: _jsx(motion.span, { ref: inner, css: { display: 'inline-block' }, ...(rolling
51
+ ? {
52
+ animate: { x: [0, 0, -overflow, -overflow, 0] },
53
+ transition: {
54
+ duration: 2 * seconds + 2 * PAUSE,
55
+ times: [
56
+ 0,
57
+ PAUSE / (2 * seconds + 2 * PAUSE),
58
+ (PAUSE + seconds) / (2 * seconds + 2 * PAUSE),
59
+ (2 * PAUSE + seconds) / (2 * seconds + 2 * PAUSE),
60
+ 1,
61
+ ],
62
+ ease: 'linear',
63
+ repeat: Infinity,
64
+ },
65
+ }
66
+ : {}), children: children }) }));
67
+ }
@@ -0,0 +1,11 @@
1
+ import type { ReactNode } from 'react';
2
+ /**
3
+ * Fades its content in as it appears, rising a few pixels, a little later for each one after the
4
+ * first (the first handful only, so a long list does not take long to arrive). It only moves what is
5
+ * drawn inside it, never the room it takes, so nothing around it shifts. Where the person asks their
6
+ * device for less motion it just appears.
7
+ */
8
+ export declare function Reveal({ index, children }: {
9
+ index?: number;
10
+ children: ReactNode;
11
+ }): import("@emotion/react/jsx-runtime").JSX.Element;
@@ -0,0 +1,19 @@
1
+ import { Fragment as _Fragment, jsx as _jsx } from "@emotion/react/jsx-runtime";
2
+ /** @jsxImportSource @emotion/react */
3
+ import { motion, useReducedMotion } from 'motion/react';
4
+ /** How much later each following item starts, in seconds, and how many are staggered at all. */
5
+ const STAGGER = 0.03;
6
+ const STAGGERED = 8;
7
+ /**
8
+ * Fades its content in as it appears, rising a few pixels, a little later for each one after the
9
+ * first (the first handful only, so a long list does not take long to arrive). It only moves what is
10
+ * drawn inside it, never the room it takes, so nothing around it shifts. Where the person asks their
11
+ * device for less motion it just appears.
12
+ */
13
+ export function Reveal({ index = 0, children }) {
14
+ const still = useReducedMotion();
15
+ if (still) {
16
+ return _jsx(_Fragment, { children: children });
17
+ }
18
+ return (_jsx(motion.div, { initial: { opacity: 0, y: 8 }, animate: { opacity: 1, y: 0 }, transition: { duration: 0.22, ease: 'easeOut', delay: Math.min(index, STAGGERED) * STAGGER }, children: children }));
19
+ }
@@ -1,5 +1,4 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "@emotion/react/jsx-runtime";
2
- /** @jsxImportSource @emotion/react */
3
2
  import { Box, Flex, Typography, useColorByKey } from 'e-prim';
4
3
  import { useTypographySize } from '../theme/use-typography.js';
5
4
  import { Area, AreaChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis, } from 'recharts';
@@ -13,13 +12,19 @@ export function formatTick(range, iso) {
13
12
  }
14
13
  return date.toLocaleDateString([], { month: 'short', day: 'numeric' });
15
14
  }
15
+ /** The grid's dashed lines, but not one along the chart's bottom edge: that is the solid axis line. */
16
+ const dashedExceptBottom = (stroke) => ({ x1, x2, y1, offset, }) => y1 >= offset.top + offset.height - 1 ? (_jsx("g", {})) : (_jsx("line", { x1: x1, x2: x2, y1: y1, y2: y1, stroke: stroke, strokeOpacity: 0.3, strokeDasharray: "3 3" }));
16
17
  /** A bare sparkline, or — when `expanded` — the full chart with axes, grid and a tooltip. Shared by the power and sensor drawers. */
17
18
  export function SeriesChart({ samples, range, expanded, unit, fitDomain = false, height, }) {
18
19
  const accent = useColorByKey('accent') ?? '';
19
20
  const textMuted = useColorByKey('textMuted') ?? '';
20
21
  const line = useColorByKey('line') ?? '';
21
22
  const tickSize = useTypographySize('secondary');
22
- return (_jsx(Box, { height: height ?? (expanded ? 220 : 48), mt: 3, children: _jsx(ResponsiveContainer, { width: "100%", height: "100%", children: _jsxs(AreaChart, { data: samples, margin: { top: 4, right: 0, bottom: 0, left: 0 }, children: [expanded ? _jsx(CartesianGrid, { stroke: line, strokeDasharray: "3 3", vertical: false }) : null, expanded ? (_jsx(XAxis, { dataKey: "timestamp", tickFormatter: (value) => formatTick(range, value), tick: { fontSize: tickSize, fill: textMuted }, axisLine: false, tickLine: false, minTickGap: 24 })) : null, expanded ? (_jsx(YAxis, { width: 36, ...(fitDomain ? { domain: ['auto', 'auto'] } : {}), tick: { fontSize: tickSize, fill: textMuted }, axisLine: false, tickLine: false })) : null, _jsx(Tooltip, { content: _jsx(ChartTooltip, { unit: unit }), ...(expanded
23
+ return (_jsx(Box, { height: height ?? (expanded ? 220 : 48), mt: 3, children: _jsx(ResponsiveContainer, { width: "100%", height: "100%", children: _jsxs(AreaChart, { data: samples, margin: { top: 4, right: 0, bottom: 0, left: 0 }, children: [expanded ? (_jsx(CartesianGrid, { stroke: line, strokeOpacity: 0.3, strokeDasharray: "3 3", vertical: false,
24
+ // The chart's bottom edge is the solid axis line, so no dashed line goes under it.
25
+ horizontal: dashedExceptBottom(line) })) : null, expanded ? (_jsx(XAxis, { dataKey: "timestamp", tickFormatter: (value) => formatTick(range, value), tick: { fontSize: tickSize, fill: textMuted },
26
+ // The bottom of the chart is a solid line; the grid above it is dashed and lighter.
27
+ axisLine: { stroke: line, strokeOpacity: 0.5, strokeWidth: 0.5 }, tickLine: false, minTickGap: 24 })) : null, expanded ? (_jsx(YAxis, { width: 36, ...(fitDomain ? { domain: ['auto', 'auto'] } : {}), tick: { fontSize: tickSize, fill: textMuted }, axisLine: false, tickLine: false })) : null, _jsx(Tooltip, { content: _jsx(ChartTooltip, { unit: unit }), ...(expanded
23
28
  ? {}
24
29
  : { allowEscapeViewBox: { x: false, y: true }, position: { y: -52 } }) }), _jsx(Area, { type: "monotone", dataKey: "value", stroke: accent, fill: accent, fillOpacity: 0.2, strokeWidth: 2, dot: false, isAnimationActive: false })] }) }) }));
25
30
  }
@@ -0,0 +1,11 @@
1
+ /** @jsxImportSource @emotion/react */
2
+ import { type BoxProps } from 'e-prim';
3
+ /** A placeholder block for something that is on its way, in the size it will have, so what is around
4
+ * it does not move when the real thing arrives. It pulses gently, and holds still where the person
5
+ * asks their device for less motion. Decorative: say what is loading around it, with `aria-busy`. */
6
+ export declare function Skeleton({ width, height, radius, }: {
7
+ /** px, or any CSS length. */
8
+ width: number | string;
9
+ height: number | string;
10
+ radius?: BoxProps['radius'];
11
+ }): import("@emotion/react/jsx-runtime").JSX.Element;
@@ -0,0 +1,16 @@
1
+ import { jsx as _jsx } from "@emotion/react/jsx-runtime";
2
+ /** @jsxImportSource @emotion/react */
3
+ import { Box } from 'e-prim';
4
+ import { motion, useReducedMotion } from 'motion/react';
5
+ /** A placeholder block for something that is on its way, in the size it will have, so what is around
6
+ * it does not move when the real thing arrives. It pulses gently, and holds still where the person
7
+ * asks their device for less motion. Decorative: say what is loading around it, with `aria-busy`. */
8
+ export function Skeleton({ width, height, radius = 'small', }) {
9
+ const still = useReducedMotion();
10
+ return (_jsx(Box, { "aria-hidden": "true", background: "surfaceRaised", radius: radius, css: { width, height, flex: 'none', overflow: 'hidden' }, children: _jsx(motion.span, { css: { display: 'block', width: '100%', height: '100%', background: 'currentColor' }, initial: { opacity: 0.1 }, ...(still
11
+ ? {}
12
+ : {
13
+ animate: { opacity: [0.04, 0.14, 0.04] },
14
+ transition: { duration: 1.4, repeat: Infinity, ease: 'easeInOut' },
15
+ }) }) }));
16
+ }
@@ -1,4 +1,5 @@
1
1
  import { useEffect, useState } from 'react';
2
+ import { useReconnects } from './hooks.js';
2
3
  import { useClient } from './provider.js';
3
4
  /** How long an answer is reused: opening a drawer twice, or switching a range back, does not ask again. */
4
5
  const FRESH_MS = 60_000;
@@ -24,6 +25,7 @@ function load(client, ref, query) {
24
25
  */
25
26
  export function useEntityHistory(ref, query) {
26
27
  const client = useClient();
28
+ const reconnects = useReconnects();
27
29
  const range = query?.range;
28
30
  const bucket = query?.bucket;
29
31
  const [state, setState] = useState();
@@ -37,7 +39,9 @@ export function useEntityHistory(ref, query) {
37
39
  return () => {
38
40
  current = false;
39
41
  };
40
- }, [client, ref, range, bucket, key]);
42
+ // `reconnects` is not read inside: the connection coming back is the reason to ask again.
43
+ // oxlint-disable-next-line react/exhaustive-effect-dependencies
44
+ }, [client, ref, range, bucket, key, reconnects]);
41
45
  const answered = state !== undefined && state.key === key;
42
46
  return { result: answered ? state.result : undefined, loading: key !== undefined && !answered };
43
47
  }
@@ -1,4 +1,5 @@
1
1
  import { useEffect, useState } from 'react';
2
+ import { useReconnects } from './hooks.js';
2
3
  import { useClient } from './provider.js';
3
4
  /** How long an answer is reused: closing and reopening a drawer does not ask again. */
4
5
  const FRESH_MS = 30_000;
@@ -26,6 +27,7 @@ function load(client, ref) {
26
27
  */
27
28
  export function useEntityLogbook(ref) {
28
29
  const client = useClient();
30
+ const reconnects = useReconnects();
29
31
  const [state, setState] = useState();
30
32
  useEffect(() => {
31
33
  if (!ref) {
@@ -36,7 +38,9 @@ export function useEntityLogbook(ref) {
36
38
  return () => {
37
39
  current = false;
38
40
  };
39
- }, [client, ref]);
41
+ // `reconnects` is not read inside: the connection coming back is the reason to ask again.
42
+ // oxlint-disable-next-line react/exhaustive-effect-dependencies
43
+ }, [client, ref, reconnects]);
40
44
  const answered = state !== undefined && state.ref === ref;
41
45
  return { entries: answered ? state.entries : undefined, loading: ref !== undefined && !answered };
42
46
  }
@@ -2,6 +2,8 @@ import type { BrowseItem, EntityRef } from '@hashsome/core';
2
2
  export interface MediaBrowserState {
3
3
  /** What is listed now. */
4
4
  items: BrowseItem[];
5
+ /** Which list that is: it changes when another one is shown (another tab, an album opened, a search), so a view can tell the same list updating from a new one arriving. */
6
+ listKey: string;
5
7
  /** A name for the list: the opened folder's, or what was searched for. Absent on a tab. */
6
8
  title: string | undefined;
7
9
  loading: boolean;
@@ -14,6 +16,8 @@ export interface MediaBrowserState {
14
16
  activeTab: string | undefined;
15
17
  /** Goes to a shelf: leaves whatever was open inside one, and any search. */
16
18
  selectTab(id: string): void;
19
+ /** The album, playlist or artist that is open: what a "play all" would play, and what the tracks in the list belong to. Absent at the top, on a shelf and in search results. */
20
+ inside: BrowseItem | undefined;
17
21
  /** Opened a folder (or searching), so there is somewhere to go back to. */
18
22
  canGoBack: boolean;
19
23
  searching: boolean;
@@ -1,4 +1,5 @@
1
1
  import { useCallback, useEffect, useState } from 'react';
2
+ import { useReconnects } from './hooks.js';
2
3
  import { useClient } from './provider.js';
3
4
  /** A top level that is nothing but folders to look inside is a set of shelves, which read better
4
5
  * as tabs than as a list that always looks the same. */
@@ -10,6 +11,8 @@ const messageOf = (error) => (error instanceof Error ? error.message : String(er
10
11
  * the browser was. */
11
12
  export function useMediaBrowser(ref) {
12
13
  const client = useClient();
14
+ // A level that could not be read because the connection was not there is asked for again when it is.
15
+ const reconnects = useReconnects();
13
16
  const [trail, setTrail] = useState([{}]);
14
17
  const [text, setText] = useState('');
15
18
  const [tab, setTab] = useState();
@@ -22,7 +25,9 @@ export function useMediaBrowser(ref) {
22
25
  return () => {
23
26
  current = false;
24
27
  };
25
- }, [client, ref]);
28
+ // `reconnects` is not read inside: the connection coming back is the reason to ask again.
29
+ // oxlint-disable-next-line react/exhaustive-effect-dependencies
30
+ }, [client, ref, reconnects]);
26
31
  const needle = text.trim();
27
32
  const searching = needle !== '';
28
33
  const here = trail.at(-1) ?? {};
@@ -49,8 +54,9 @@ export function useMediaBrowser(ref) {
49
54
  current = false;
50
55
  clearTimeout(timer);
51
56
  };
52
- }, [client, ref, key, searching, needle, path]);
53
- const open = useCallback((item) => setTrail((levels) => [...levels, { path: item.id, title: item.title }]), []);
57
+ // oxlint-disable-next-line react/exhaustive-effect-dependencies
58
+ }, [client, ref, key, searching, needle, path, reconnects]);
59
+ const open = useCallback((item) => setTrail((levels) => [...levels, { path: item.id, title: item.title, item }]), []);
54
60
  const selectTab = useCallback((id) => {
55
61
  setTab(id);
56
62
  setText('');
@@ -67,6 +73,7 @@ export function useMediaBrowser(ref) {
67
73
  const listing = !searching && atTop && !tabs ? top : result;
68
74
  return {
69
75
  items: listing?.items ?? [],
76
+ listKey: searching ? 'search' : `path:${path ?? 'top'}`,
70
77
  title: searching
71
78
  ? (result?.title ?? `Results for “${needle}”`)
72
79
  : atTop
@@ -77,6 +84,7 @@ export function useMediaBrowser(ref) {
77
84
  tabs,
78
85
  activeTab: searching ? undefined : selected,
79
86
  selectTab,
87
+ inside: searching || atTop ? undefined : here.item,
80
88
  canGoBack: searching || !atTop,
81
89
  searching,
82
90
  open,
@@ -0,0 +1,15 @@
1
+ import type { EntityRef, QueueResult } from '@hashsome/core';
2
+ export interface MediaQueueState {
3
+ /** `undefined` until the first answer, and always for a player with no queue. */
4
+ queue: QueueResult | undefined;
5
+ loading: boolean;
6
+ /** Reads the queue again, e.g. after something was added to it or taken out. */
7
+ refresh(): void;
8
+ }
9
+ /**
10
+ * A player's queue, read when `ref` changes and again whenever the track that plays changes (the
11
+ * window of the queue moves with it), after `refresh()`, and every so often. A player with no queue,
12
+ * or an error, leaves `queue` undefined: the queue is an extra, so a component just shows nothing.
13
+ * Pass `undefined` to not ask at all.
14
+ */
15
+ export declare function useMediaQueue(ref: EntityRef | undefined): MediaQueueState;
@@ -0,0 +1,44 @@
1
+ import { useCallback, useEffect, useState } from 'react';
2
+ import { useEntity, useReconnects } from './hooks.js';
3
+ import { useClient } from './provider.js';
4
+ /** How often the queue is read again while it is on screen, to pick up what another app changed. */
5
+ const POLL_MS = 15_000;
6
+ /**
7
+ * A player's queue, read when `ref` changes and again whenever the track that plays changes (the
8
+ * window of the queue moves with it), after `refresh()`, and every so often. A player with no queue,
9
+ * or an error, leaves `queue` undefined: the queue is an extra, so a component just shows nothing.
10
+ * Pass `undefined` to not ask at all.
11
+ */
12
+ export function useMediaQueue(ref) {
13
+ const client = useClient();
14
+ const player = useEntity(ref);
15
+ const [state, setState] = useState();
16
+ const [tick, setTick] = useState(0);
17
+ const reconnects = useReconnects();
18
+ const refresh = useCallback(() => setTick((now) => now + 1), []);
19
+ // What changes when the queue's window does: the track playing and the order it plays in.
20
+ const track = player?.kind === 'mediaPlayer' ? player.media?.title : undefined;
21
+ const shuffle = player?.kind === 'mediaPlayer' ? player.shuffle : undefined;
22
+ const has = player?.kind === 'mediaPlayer' && player.capabilities.queue;
23
+ useEffect(() => {
24
+ if (!ref || !has) {
25
+ return;
26
+ }
27
+ let current = true;
28
+ const read = () => client.queue(ref, {}).then((queue) => current && setState({ ref, queue }), () => current && setState({ ref, queue: undefined }));
29
+ void read();
30
+ const timer = setInterval(() => void read(), POLL_MS);
31
+ return () => {
32
+ current = false;
33
+ clearInterval(timer);
34
+ };
35
+ // `track`, `shuffle`, `tick` and `reconnects` are not read inside: a change to any of them is the reason to read again.
36
+ // oxlint-disable-next-line react/exhaustive-effect-dependencies
37
+ }, [client, ref, has, track, shuffle, tick, reconnects]);
38
+ const answered = state !== undefined && state.ref === ref;
39
+ return {
40
+ queue: answered ? state.queue : undefined,
41
+ loading: ref !== undefined && has === true && !answered,
42
+ refresh,
43
+ };
44
+ }
@@ -1,4 +1,5 @@
1
1
  import { useEffect, useState } from 'react';
2
+ import { useReconnects } from './hooks.js';
2
3
  import { useClient } from './provider.js';
3
4
  /** How long an answer is reused: a forecast changes every half hour or so at most. */
4
5
  const FRESH_MS = 10 * 60_000;
@@ -24,6 +25,7 @@ function load(client, ref, type) {
24
25
  */
25
26
  export function useWeatherForecast(ref, type) {
26
27
  const client = useClient();
28
+ const reconnects = useReconnects();
27
29
  const [state, setState] = useState();
28
30
  const key = ref ? `${ref}|${type}` : undefined;
29
31
  useEffect(() => {
@@ -35,7 +37,9 @@ export function useWeatherForecast(ref, type) {
35
37
  return () => {
36
38
  current = false;
37
39
  };
38
- }, [client, ref, type, key]);
40
+ // `reconnects` is not read inside: the connection coming back is the reason to ask again.
41
+ // oxlint-disable-next-line react/exhaustive-effect-dependencies
42
+ }, [client, ref, type, key, reconnects]);
39
43
  const answered = state !== undefined && state.key === key;
40
44
  return { result: answered ? state.result : undefined, loading: key !== undefined && !answered };
41
45
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hashsome/ui",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "description": "Design system components and hooks",
5
5
  "type": "module",
6
6
  "exports": {
@@ -16,7 +16,7 @@
16
16
  },
17
17
  "dependencies": {
18
18
  "@emotion/react": "11.14.0",
19
- "@hashsome/core": "0.2.0",
19
+ "@hashsome/core": "0.4.0",
20
20
  "e-prim": "2.0.1",
21
21
  "motion": "13.4.5",
22
22
  "react": "19.3.0",