Compare commits

..
Author SHA1 Message Date
ajnart dd99dd6d7b style: auto-fix lint and format 2026-07-21 14:02:05 +00:00
ajnart e93e9ed839 fix(media-server): guard against undefined playback from stale cache
PR #6315 added playback/location fields to StreamSession but cached
query data may still lack these fields, causing 'can't access property
state, playback is undefined' errors on the media server widget.
2026-07-21 16:00:54 +02:00
3 changed files with 72 additions and 19 deletions

No files matched your search

+56 -13
View File
@@ -1,4 +1,6 @@
import { Box, Container, Flex, HoverCard, Text, useMantineTheme } from "@mantine/core"; import { useCallback, useState } from "react";
import { Box, Container, Flex, Popover, Text, useMantineTheme } from "@mantine/core";
import { useElementSize } from "@mantine/hooks";
import { useRequiredBoard } from "@homarr/boards/context"; import { useRequiredBoard } from "@homarr/boards/context";
import type { CalendarEvent } from "@homarr/integrations/types"; import type { CalendarEvent } from "@homarr/integrations/types";
@@ -11,9 +13,14 @@ interface CalendarDayProps {
disabled: boolean; disabled: boolean;
rootWidth: number; rootWidth: number;
rootHeight: number; rootHeight: number;
onOpen: (close: () => void) => void;
} }
export const CalendarDay = ({ date, events, disabled, rootHeight, rootWidth }: CalendarDayProps) => { export const CalendarDay = ({ date, events, disabled, rootHeight, rootWidth, onOpen }: CalendarDayProps) => {
const [opened, setOpened] = useState(false);
const [pinned, setPinned] = useState(false);
const { primaryColor } = useMantineTheme();
const { ref, height } = useElementSize();
const board = useRequiredBoard(); const board = useRequiredBoard();
const mantineTheme = useMantineTheme(); const mantineTheme = useMantineTheme();
const actualItemRadius = mantineTheme.radius[board.itemRadius]; const actualItemRadius = mantineTheme.radius[board.itemRadius];
@@ -22,19 +29,39 @@ export const CalendarDay = ({ date, events, disabled, rootHeight, rootWidth }: C
const shouldScaleDown = minAxisSize < 350; const shouldScaleDown = minAxisSize < 350;
const isSmall = rootHeight < 256; const isSmall = rootHeight < 256;
const isTooSmallForIndicators = height < 30;
const handleMouseEnter = useCallback(() => {
if (disabled) return;
onOpen(() => {
setOpened(false);
setPinned(false);
});
setOpened(true);
}, [disabled, onOpen]);
const handleMouseLeave = useCallback(() => {
if (!pinned) setOpened(false);
}, [pinned]);
return ( return (
<HoverCard <Popover
position="bottom" position="bottom"
withArrow withArrow
withinPortal withinPortal
radius="lg" radius="lg"
shadow="sm" shadow="sm"
transitionProps={{ transition: "pop" }} transitionProps={{
openDelay={350} transition: "pop",
closeDelay={400} }}
onChange={(value) => {
setOpened(value);
if (!value) setPinned(false);
}}
opened={opened}
disabled={disabled} disabled={disabled}
> >
<HoverCard.Target> <Popover.Target>
<Container <Container
h="100%" h="100%"
w="100%" w="100%"
@@ -42,23 +69,38 @@ export const CalendarDay = ({ date, events, disabled, rootHeight, rootWidth }: C
pt={isSmall ? 0 : 10} pt={isSmall ? 0 : 10}
pb={isSmall ? 0 : 10} pb={isSmall ? 0 : 10}
m={0} m={0}
pos="relative" ref={ref}
bd={`2px solid ${opened && !disabled ? primaryColor : "transparent"}`}
style={{ style={{
alignContent: "center", alignContent: "center",
borderRadius: actualItemRadius, borderRadius: actualItemRadius,
cursor: disabled ? "default" : "pointer", cursor: disabled ? "default" : "pointer",
}} }}
onClick={() => {
if (!disabled) setPinned((prev) => !prev);
}}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
> >
<Text ta={"center"} size={shouldScaleDown ? "xs" : "md"} lh={1}> <Text ta={"center"} size={shouldScaleDown ? "xs" : "md"} lh={1}>
{date.getDate()} {date.getDate()}
</Text> </Text>
<NotificationIndicator events={events} isSmall={isSmall} /> {!isTooSmallForIndicators && <NotificationIndicator events={events} isSmall={isSmall} />}
</Container> </Container>
</HoverCard.Target> </Popover.Target>
<HoverCard.Dropdown maw="calc(100vw - 24px)" w={512} pe={4} pb={0} style={{ overflow: "hidden" }}> {/* Popover has some offset on the left side, padding is removed because of scrollarea paddings */}
<Popover.Dropdown
maw="calc(100vw - 24px)"
w={512}
pe={4}
pb={0}
style={{ overflow: "hidden" }}
onMouseEnter={() => setOpened(true)}
onMouseLeave={handleMouseLeave}
>
<CalendarEventList events={events} /> <CalendarEventList events={events} />
</HoverCard.Dropdown> </Popover.Dropdown>
</HoverCard> </Popover>
); );
}; };
@@ -69,6 +111,7 @@ interface NotificationIndicatorProps {
const NotificationIndicator = ({ events, isSmall }: NotificationIndicatorProps) => { const NotificationIndicator = ({ events, isSmall }: NotificationIndicatorProps) => {
const notificationEvents = [...new Set(events.map((event) => event.indicatorColor))].filter(String); const notificationEvents = [...new Set(events.map((event) => event.indicatorColor))].filter(String);
/* position bottom is lower when small to not be on top of number*/
return ( return (
<Flex <Flex
w="75%" w="75%"
+7 -1
View File
@@ -1,6 +1,6 @@
"use client"; "use client";
import { useMemo, useState } from "react"; import { useCallback, useMemo, useRef, useState } from "react";
import { useParams } from "next/navigation"; import { useParams } from "next/navigation";
import { useMantineTheme } from "@mantine/core"; import { useMantineTheme } from "@mantine/core";
import { Calendar } from "@mantine/dates"; import { Calendar } from "@mantine/dates";
@@ -65,6 +65,11 @@ const CalendarBase = ({ isEditMode, events, month, setMonth, options }: Calendar
const isSmall = width < 256; const isSmall = width < 256;
const normalizedEvents = useMemo(() => splitEvents(events), [events]); const normalizedEvents = useMemo(() => splitEvents(events), [events]);
const activeCloseRef = useRef<(() => void) | null>(null);
const onDayOpen = useCallback((close: () => void) => {
activeCloseRef.current?.();
activeCloseRef.current = close;
}, []);
return ( return (
<Calendar <Calendar
@@ -141,6 +146,7 @@ const CalendarBase = ({ isEditMode, events, month, setMonth, options }: Calendar
disabled={isEditMode || eventsForDate.length === 0} disabled={isEditMode || eventsForDate.length === 0}
rootWidth={width} rootWidth={width}
rootHeight={height} rootHeight={height}
onOpen={onDayOpen}
/> />
); );
}} }}
@@ -66,16 +66,20 @@ export default function MediaServerWidget({ options, integrationIds }: WidgetCom
const currentlyPlaying = row.original.currentlyPlaying; const currentlyPlaying = row.original.currentlyPlaying;
if (!currentlyPlaying) return null; if (!currentlyPlaying) return null;
const isPaused = currentlyPlaying.playback.state === "paused"; const playback = currentlyPlaying.playback;
const isPaused = playback?.state === "paused";
const Icon = isPaused ? IconPlayerPause : mediaTypeIconMap[currentlyPlaying.type]; const Icon = isPaused ? IconPlayerPause : mediaTypeIconMap[currentlyPlaying.type];
const { positionMs, durationMs } = currentlyPlaying.playback; const positionMs = playback?.positionMs ?? null;
const durationMs = playback?.durationMs ?? null;
const progressPercent = const progressPercent =
positionMs !== null && durationMs !== null && durationMs > 0 positionMs !== null && durationMs !== null && durationMs > 0
? Math.min(100, Math.round((positionMs / durationMs) * 100)) ? Math.min(100, Math.round((positionMs / durationMs) * 100))
: null; : null;
const remainingMinutes = const remainingMinutes =
positionMs !== null && durationMs !== null ? Math.max(0, Math.round((durationMs - positionMs) / 60_000)) : null; positionMs !== null && durationMs !== null
? Math.max(0, Math.round((durationMs - positionMs) / 60_000))
: null;
return ( return (
<Stack gap={4} style={{ minWidth: 0 }}> <Stack gap={4} style={{ minWidth: 0 }}>
@@ -122,8 +126,8 @@ export default function MediaServerWidget({ options, integrationIds }: WidgetCom
const isTranscoding = Boolean( const isTranscoding = Boolean(
currentlyPlaying.metadata?.transcoding.target.videoCodec ?? currentlyPlaying.metadata?.transcoding.target.videoCodec ??
currentlyPlaying.metadata?.transcoding.target.audioCodec ?? currentlyPlaying.metadata?.transcoding.target.audioCodec ??
currentlyPlaying.metadata?.transcoding.container, currentlyPlaying.metadata?.transcoding.container,
); );
return ( return (