Compare commits

..
Author SHA1 Message Date
ajnart 37ef86f32e Embed integration editing in widget tab 2026-08-19 09:46:58 +02:00
Bergasha 60aec370d6 Merge pull request #6627 from homarr-labs/fix/v2-media-server-widget
fix(v2 media widget): updated media server widget
2026-08-19 16:56:30 +10:00
Bergasha 6a76b9eafe Merge branch 'release/v2' into fix/v2-media-server-widget 2026-08-19 16:55:15 +10:00
Bergasha 76ba13fbad Address Codex review feedback on PR #6627
- Classify IPv4/IPv6 link-local addresses (169.254.0.0/16, fe80::/10) as
  LAN in Jellyfin location parsing - they can't traverse a router, so a
  false WAN reading was possible for common autoconfig/link-local sessions.
- Fix portrait video resolution mislabeling - a 1080x1920 clip was
  classified off its long-axis height (1920) and showed as 1440p. Classify
  by the short axis instead so orientation doesn't flip the result.
- Raise the compact-mode status column breakpoint from 420 to 540px - at
  420 the fixed-width user (200px) and status (190px) columns left almost
  no room for the currentlyPlaying column, the widget's primary content.
- Document the new resolution indicator in the media-server widget docs.
2026-08-19 16:45:09 +10:00
Thomas 150d38be62 feat(docker): add endpoint filtering and streamline setup assistant (#6625)
* feat(docker): add endpoint filtering and setup assistant

* fix(docker): simplify environment setup

* test(docker): cover demo endpoint filtering

* refactor(docker): use accordion for setup suggestions

* fix(docker): use docker icon for setup assistant

* refactor(docker): simplify assisted service setup

* refactor(docker): reuse shared translations

* fix(docker): address review feedback
2026-08-19 08:45:07 +02:00
Bergasha a4bbed0cb8 Fixed resolution showing the raw cropped height (e.g. 804p) instead of the nominal class Plex/Jellyfin display (1080p) - letterboxed/cinemascope sources are shorter than their resolution tier, so classify off the width-derived 16:9 height instead 2026-08-19 14:54:48 +10:00
Bergasha 90fa9e8741 Added tests for the LAN/WAN parsing and fixed IPv4-mapped IPv6 addresses being misclassified as WAN - a dual-stack server can report a LAN client as ::ffff:192.168.x.x, which the private-range check was missing since it only matched literal IPv6 ranges 2026-08-19 13:52:14 +10:00
Bergasha 4b20d6ecd0 Widened the status column from 140 to 190px - the Np resolution format is shorter so line 2 had room, but Transcoding video/audio badges are noticeably longer than Direct play and needed the space on line 1 2026-08-19 13:33:21 +10:00
Bergasha 6cac34e968 Changed resolution to show the actual streaming resolution (transcode target) instead of the source file's, formatted as e.g. 1080p instead of 1920x1080 2026-08-19 13:33:14 +10:00
Bergasha c6f77cec2e Added resolution to the status column, restructured into two lines: status + LAN/WAN on top, resolution + bitrate underneath 2026-08-19 13:33:08 +10:00
Bergasha acc97da84b Added LAN/WAN detection to Jellyfin, matching what Plex already reports - Jellyfin's sessions endpoint only gives a client IP (RemoteEndPoint), so classify it against the standard private IP ranges ourselves 2026-08-19 13:32:40 +10:00
Bergasha 6b10ac2ab5 Gave User a fixed 200px width so Currently playing (the busiest column) gets the largest share of the row instead of splitting it evenly with User 2026-08-19 13:32:34 +10:00
Bergasha c7563f2c39 Fixed Jellyfin bitrate showing null on direct play - the sessions endpoint only reliably fills in MediaSources bitrate while transcoding, so sum the individual media streams' own bitrates as a fallback 2026-08-19 13:32:28 +10:00
Bergasha 96676182f4 Gave the status column a fixed 140px width - it had no explicit width so it shared an equal, unconstrained share of the row with the other columns despite only holding a small badge 2026-08-19 13:32:21 +10:00
Bergasha 3769d88ebb Dimmed the column header labels and added a bottom border under the sticky header, so it reads as its own band instead of the same weight as the row data 2026-08-19 13:32:15 +10:00
Bergasha d9c35744f6 Only show a server's badge in the footer when it actually has an active session - selecting both Plex and Jellyfin always showed both badges even if only one was streaming. Also gave the footer badges a pill style and added a separator between the stream count and bitrate 2026-08-19 13:31:58 +10:00
Bergasha 33991f4e22 Added flexShrink to the session avatar so it stops getting squished in a tight row 2026-08-19 13:31:33 +10:00
Bergasha df300919fc Gated season/episode fields on episode type - was showing S/E labels on movies and music too, using the episode number as season 2026-08-19 13:31:27 +10:00
14 changed files with 666 additions and 354 deletions

No files matched your search

@@ -63,9 +63,9 @@ When you connect a service from a board, Homarr keeps you in the same setup flow
confirms whether an application shortcut was linked and offers the widgets that support the new integration. Selecting a confirms whether an application shortcut was linked and offers the widgets that support the new integration. Selecting a
widget returns to its settings with the new integration already selected. widget returns to its settings with the new integration already selected.
Users with full access can also test and update a selected integration from **Edit widget → Edit integration** on a Users with full access can also test and update a selected integration directly in the **Edit widget → Edit integration**
board. Failed tests keep the editor open and show the same connection diagnostics as the management page. Successful tab on a board. Failed tests keep the editor open and show the same connection diagnostics as the management page.
changes apply to every app and widget that uses the integration. Successful changes apply to every app and widget that uses the integration.
When app creation is not permitted, the setup form offers linking an existing accessible app instead of attempting to When app creation is not permitted, the setup form offers linking an existing accessible app instead of attempting to
create one. create one.
@@ -22,7 +22,7 @@ import { plexIntegration } from "@site/docs/integrations/plex";
<WidgetHeader widget={mediaServerWidget} categories={["Media server"]} /> <WidgetHeader widget={mediaServerWidget} categories={["Media server"]} />
The media server widget shows active sessions across your media servers. The media server widget shows active sessions across your media servers.
It shows playback progress, pause state, time remaining, and whether each session is direct play, direct stream, or transcoding audio or video. Location and bitrate can be toggled independently. It shows playback progress, pause state, time remaining, the streaming resolution (e.g. `1080p`), and whether each session is direct play, direct stream, or transcoding audio or video. Location and bitrate can be toggled independently.
Select a session to inspect its media metadata in an anchored popover. Advanced mode adds search, sorting, location, bitrate, and a summary of playing and transcoding sessions while keeping separate integration instances identifiable. Compact visibility switches do not hide location or bitrate from the complete advanced session view. Select a session to inspect its media metadata in an anchored popover. Advanced mode adds search, sorting, location, bitrate, and a summary of playing and transcoding sessions while keeping separate integration instances identifiable. Compact visibility switches do not hide location or bitrate from the complete advanced session view.
If one selected server is unavailable, a warning indicator identifies the degraded result while sessions from healthy servers remain usable. If one selected server is unavailable, a warning indicator identifies the degraded result while sessions from healthy servers remain usable.
@@ -1,6 +1,6 @@
"use client"; "use client";
import { useRef, useState } from "react"; import { useCallback, useImperativeHandle, useRef, useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { Alert, Anchor, Button, ButtonGroup, Fieldset, Group, Stack, Text, TextInput } from "@mantine/core"; import { Alert, Anchor, Button, ButtonGroup, Fieldset, Group, Stack, Text, TextInput } from "@mantine/core";
import { IconInfoCircle, IconPencil, IconPlus, IconUnlink } from "@tabler/icons-react"; import { IconInfoCircle, IconPencil, IconPlus, IconUnlink } from "@tabler/icons-react";
@@ -27,8 +27,14 @@ import type { AnyMappedTestConnectionError } from "../../_components/test-connec
interface EditIntegrationFormProps { interface EditIntegrationFormProps {
integration: RouterOutputs["integration"]["byId"]; integration: RouterOutputs["integration"]["byId"];
embedded?: boolean; hideButtons?: boolean;
onSuccess?: () => void; onSuccess?: () => void;
formRef?: React.Ref<EditIntegrationFormHandle>;
}
export interface EditIntegrationFormHandle {
submit: () => Promise<boolean>;
isDirty: () => boolean;
} }
const formSchema = integrationUpdateSchema.omit({ id: true, appId: true }).and( const formSchema = integrationUpdateSchema.omit({ id: true, appId: true }).and(
@@ -44,7 +50,12 @@ const formSchema = integrationUpdateSchema.omit({ id: true, appId: true }).and(
}), }),
); );
export const EditIntegrationForm = ({ integration, embedded = false, onSuccess }: EditIntegrationFormProps) => { export const EditIntegrationForm = ({
integration,
hideButtons = false,
onSuccess,
formRef,
}: EditIntegrationFormProps) => {
const t = useI18n(); const t = useI18n();
const { openConfirmModal } = useConfirmModal(); const { openConfirmModal } = useConfirmModal();
const allSecretKinds = getAllSecretKindOptions(integration.kind); const allSecretKinds = getAllSecretKindOptions(integration.kind);
@@ -82,147 +93,172 @@ export const EditIntegrationForm = ({ integration, embedded = false, onSuccess }
const secretsMap = new Map(integration.secrets.map((secret) => [secret.kind, secret])); const secretsMap = new Map(integration.secrets.map((secret) => [secret.kind, secret]));
const handleSubmitAsync = async ({ app, ...values }: FormType) => { const handleSubmitAsync = useCallback(
setError(null); async ({ app, ...values }: FormType) => {
let url: string; setError(null);
try { let url: string;
url = hasUrlSecret try {
? new URL(values.secrets.find((secret) => secret.kind === "url")?.value ?? values.url).origin url = hasUrlSecret
: values.url; ? new URL(values.secrets.find((secret) => secret.kind === "url")?.value ?? values.url).origin
} catch { : values.url;
showErrorNotification({ } catch {
title: t("integration.page.edit.notification.error.title"), showErrorNotification({
message: t("integration.page.edit.notification.error.message"), title: t("integration.page.edit.notification.error.title"),
message: t("integration.page.edit.notification.error.message"),
});
return false;
}
const data = await mutateAsync({
id: integration.id,
...values,
url,
secrets: values.secrets.map((secret) => ({
kind: secret.kind,
value: secret.value === "" ? null : secret.value,
})),
appId: app?.id ?? null,
}); });
return;
}
const data = await mutateAsync({ // We do it this way as we are unable to send a typesafe error through onError
id: integration.id, if (data?.error) {
...values, setError(data.error);
url, showErrorNotification({
secrets: values.secrets.map((secret) => ({ title: t("integration.page.edit.notification.error.title"),
kind: secret.kind, message: t("integration.page.edit.notification.error.message"),
value: secret.value === "" ? null : secret.value, });
})), requestAnimationFrame(() => errorRef.current?.focus());
appId: app?.id ?? null, return false;
}); }
// We do it this way as we are unable to send a typesafe error through onError showSuccessNotification({
if (data?.error) { title: t("integration.page.edit.notification.success.title"),
setError(data.error); message: t("integration.page.edit.notification.success.message"),
showErrorNotification({
title: t("integration.page.edit.notification.error.title"),
message: t("integration.page.edit.notification.error.message"),
}); });
requestAnimationFrame(() => errorRef.current?.focus()); void Promise.allSettled([utils.integration.invalidate(), utils.widget.invalidate()]);
return; onSuccess?.();
} if (!hideButtons) {
void revalidatePathActionAsync("/manage/integrations").then(() => router.push("/manage/integrations"));
}
return true;
},
[hasUrlSecret, hideButtons, integration.id, mutateAsync, onSuccess, router, t, utils.integration, utils.widget],
);
showSuccessNotification({ useImperativeHandle(
title: t("integration.page.edit.notification.success.title"), formRef,
message: t("integration.page.edit.notification.success.message"), () => ({
}); submit: () =>
void Promise.allSettled([utils.integration.invalidate(), utils.widget.invalidate()]); new Promise<boolean>((resolve) => {
onSuccess?.(); form.onSubmit(
if (!embedded) { async (values) => {
void revalidatePathActionAsync("/manage/integrations").then(() => router.push("/manage/integrations")); try {
} resolve(await handleSubmitAsync(values));
}; } catch {
resolve(false);
}
},
() => resolve(false),
)();
}),
isDirty: () => form.isDirty(),
}),
[form, handleSubmitAsync],
);
const isInitialSecretKinds = const isInitialSecretKinds =
initialSecretsKinds.every((kind) => form.values.secrets.some((secret) => secret.kind === kind)) && initialSecretsKinds.every((kind) => form.values.secrets.some((secret) => secret.kind === kind)) &&
form.values.secrets.length === initialSecretsKinds.length; form.values.secrets.length === initialSecretsKinds.length;
return ( const formFields = (
<form onSubmit={form.onSubmit(async (values) => await handleSubmitAsync(values))}> <Stack>
<Stack> <TextInput withAsterisk label={t("integration.field.name.label")} {...form.getInputProps("name")} />
<TextInput withAsterisk label={t("integration.field.name.label")} {...form.getInputProps("name")} />
{hasUrlSecret ? null : ( {hasUrlSecret ? null : (
<TextInput withAsterisk label={t("integration.field.url.label")} {...form.getInputProps("url")} /> <TextInput withAsterisk label={t("integration.field.url.label")} {...form.getInputProps("url")} />
)} )}
<Fieldset legend={t("integration.secrets.title")}> <Fieldset legend={t("integration.secrets.title")}>
<Stack gap="sm"> <Stack gap="sm">
{allSecretKinds.length > 1 && ( {allSecretKinds.length > 1 && (
<SecretKindsSegmentedControl <SecretKindsSegmentedControl defaultKinds={initialSecretsKinds} secretKinds={allSecretKinds} form={form} />
defaultKinds={initialSecretsKinds} )}
secretKinds={allSecretKinds} {!isInitialSecretKinds
form={form} ? null
/> : form.values.secrets.map((secret, index) => (
)} <SecretCard
{!isInitialSecretKinds key={secret.kind}
? null secret={secretsMap.get(secret.kind) ?? { kind: secret.kind, value: null, updatedAt: null }}
: form.values.secrets.map((secret, index) => ( onCancel={() =>
<SecretCard new Promise((resolve) => {
key={secret.kind} // When nothing changed, just close the secret card
secret={secretsMap.get(secret.kind) ?? { kind: secret.kind, value: null, updatedAt: null }} if ((secret.value ?? "") === (secretsMap.get(secret.kind)?.value ?? "")) {
onCancel={() => return resolve(true);
new Promise((resolve) => { }
// When nothing changed, just close the secret card openConfirmModal({
if ((secret.value ?? "") === (secretsMap.get(secret.kind)?.value ?? "")) { title: t("integration.secrets.reset.title"),
return resolve(true); children: t("integration.secrets.reset.message"),
} onCancel: () => resolve(false),
openConfirmModal({ onConfirm: () => {
title: t("integration.secrets.reset.title"), form.setFieldValue(`secrets.${index}.value`, secretsMap.get(secret.kind)?.value ?? "");
children: t("integration.secrets.reset.message"), resolve(true);
onCancel: () => resolve(false), },
onConfirm: () => { });
form.setFieldValue(`secrets.${index}.value`, secretsMap.get(secret.kind)?.value ?? ""); })
resolve(true); }
}, >
});
})
}
>
<IntegrationSecretInput
label={t(`integration.secrets.kind.${secret.kind}.newLabel`)}
key={secret.kind}
kind={secret.kind}
{...form.getInputProps(`secrets.${index}.value`)}
/>
</SecretCard>
))}
{isInitialSecretKinds
? null
: form.values.secrets.map(({ kind }, index) => (
<IntegrationSecretInput <IntegrationSecretInput
withAsterisk label={t(`integration.secrets.kind.${secret.kind}.newLabel`)}
key={kind} key={secret.kind}
kind={kind} kind={secret.kind}
{...form.getInputProps(`secrets.${index}.value`)} {...form.getInputProps(`secrets.${index}.value`)}
/> />
))} </SecretCard>
{form.values.secrets.length === 0 && ( ))}
<Alert icon={<IconInfoCircle size={"1rem"} />} color={"blue"}> {isInitialSecretKinds
<Text c={"blue"}>{t("integration.secrets.noSecretsRequired.text")}</Text> ? null
</Alert> : form.values.secrets.map(({ kind }, index) => (
)} <IntegrationSecretInput
</Stack> withAsterisk
</Fieldset> key={kind}
kind={kind}
<IntegrationLinkApp value={form.values.app} onChange={(app) => form.setFieldValue("app", app)} /> {...form.getInputProps(`secrets.${index}.value`)}
/>
{error !== null && ( ))}
<div ref={errorRef} role="alert" tabIndex={-1}> {form.values.secrets.length === 0 && (
<IntegrationTestConnectionError error={error} url={form.values.url} /> <Alert icon={<IconInfoCircle size={"1rem"} />} color={"blue"}>
</div> <Text c={"blue"}>{t("integration.secrets.noSecretsRequired.text")}</Text>
)} </Alert>
<Group justify="end" align="center">
{!embedded && (
<Button variant="default" component={Link} href="/manage/integrations">
{t("common.action.backToOverview")}
</Button>
)} )}
</Stack>
</Fieldset>
<IntegrationLinkApp value={form.values.app} onChange={(app) => form.setFieldValue("app", app)} />
{error !== null && (
<div ref={errorRef} role="alert" tabIndex={-1}>
<IntegrationTestConnectionError error={error} url={form.values.url} />
</div>
)}
{!hideButtons && (
<Group justify="end" align="center">
<Button variant="default" component={Link} href="/manage/integrations">
{t("common.action.backToOverview")}
</Button>
<Button type="submit" loading={isPending}> <Button type="submit" loading={isPending}>
{t("integration.testConnection.action.edit")} {t("integration.testConnection.action.edit")}
</Button> </Button>
</Group> </Group>
</Stack> )}
</form> </Stack>
); );
if (hideButtons) {
return formFields;
}
return <form onSubmit={form.onSubmit(async (values) => await handleSubmitAsync(values))}>{formFields}</form>;
}; };
type FormType = z.infer<typeof formSchema>; type FormType = z.infer<typeof formSchema>;
@@ -3,16 +3,16 @@
import { Suspense, use } from "react"; import { Suspense, use } from "react";
import { Center, Loader } from "@mantine/core"; import { Center, Loader } from "@mantine/core";
import { ErrorBoundary } from "react-error-boundary"; import { ErrorBoundary } from "react-error-boundary";
import type { FallbackProps } from "react-error-boundary";
import type { WidgetKind } from "@homarr/definitions"; import type { WidgetKind } from "@homarr/definitions";
import { createModal, modalSizeForm } from "@homarr/modals"; import { createModal, modalSizeForm } from "@homarr/modals";
import { useModalAction } from "@homarr/modals";
import { WidgetError } from "@homarr/widgets/errors"; import { WidgetError } from "@homarr/widgets/errors";
import type * as WidgetModalsModule from "@homarr/widgets/modals"; import type * as WidgetModalsModule from "@homarr/widgets/modals";
import type { WidgetEditModalProps } from "@homarr/widgets/modals"; import type { WidgetEditModalProps } from "@homarr/widgets/modals";
import type * as IntegrationEditModalModule from "~/components/integration/embedded-integration-edit-modal"; import type * as IntegrationEditFormModule from "~/components/integration/embedded-integration-edit-form";
import type { EmbeddedIntegrationEditModalProps } from "~/components/integration/embedded-integration-edit-modal"; import type { EmbeddedIntegrationEditFormProps } from "~/components/integration/embedded-integration-edit-form";
let widgetEditModalPromise: Promise<typeof WidgetModalsModule> | undefined; let widgetEditModalPromise: Promise<typeof WidgetModalsModule> | undefined;
@@ -29,42 +29,38 @@ export const loadWidgetEditModal = () => {
export const preloadWidgetEditModal = () => void loadWidgetEditModal().catch(() => undefined); export const preloadWidgetEditModal = () => void loadWidgetEditModal().catch(() => undefined);
let integrationEditModalPromise: Promise<typeof IntegrationEditModalModule> | undefined; let integrationEditFormPromise: Promise<typeof IntegrationEditFormModule> | undefined;
const loadIntegrationEditModal = () => { const loadIntegrationEditForm = () => {
if (integrationEditModalPromise) return integrationEditModalPromise; if (integrationEditFormPromise) return integrationEditFormPromise;
const currentPromise = import("~/components/integration/embedded-integration-edit-modal"); const currentPromise = import("~/components/integration/embedded-integration-edit-form");
integrationEditModalPromise = currentPromise; integrationEditFormPromise = currentPromise;
void currentPromise.catch(() => { void currentPromise.catch(() => {
if (integrationEditModalPromise === currentPromise) integrationEditModalPromise = undefined; if (integrationEditFormPromise === currentPromise) integrationEditFormPromise = undefined;
}); });
return currentPromise; return currentPromise;
}; };
const LazyIntegrationEditModalContent = (props: { const LazyIntegrationEditFormContent = (props: EmbeddedIntegrationEditFormProps) => {
actions: { closeModal: () => void }; const { EmbeddedIntegrationEditForm } = use(loadIntegrationEditForm());
innerProps: EmbeddedIntegrationEditModalProps; return <EmbeddedIntegrationEditForm {...props} />;
}) => {
const { EmbeddedIntegrationEditModal } = use(loadIntegrationEditModal());
const Component = EmbeddedIntegrationEditModal.component;
return <Component {...props} />;
}; };
const LazyIntegrationEditModal = createModal<EmbeddedIntegrationEditModalProps>((props) => ( const IntegrationEditErrorFallback = ({ error, resetErrorBoundary }: FallbackProps) => (
<ErrorBoundary <Center py="xl">
fallbackRender={({ error, resetErrorBoundary }) => ( <WidgetError
<Center py="xl"> error={error}
<WidgetError resetErrorBoundary={() => {
error={error} integrationEditFormPromise = undefined;
resetErrorBoundary={() => { resetErrorBoundary();
integrationEditModalPromise = undefined; }}
resetErrorBoundary(); />
}} </Center>
/> );
</Center>
)} const LazyIntegrationEditForm = (props: EmbeddedIntegrationEditFormProps) => (
> <ErrorBoundary FallbackComponent={IntegrationEditErrorFallback}>
<Suspense <Suspense
fallback={ fallback={
<Center py="xl"> <Center py="xl">
@@ -72,15 +68,10 @@ const LazyIntegrationEditModal = createModal<EmbeddedIntegrationEditModalProps>(
</Center> </Center>
} }
> >
<LazyIntegrationEditModalContent {...props} /> <LazyIntegrationEditFormContent {...props} />
</Suspense> </Suspense>
</ErrorBoundary> </ErrorBoundary>
)).withOptions({ );
defaultTitle: (t) => t("item.edit.tab.integration"),
size: modalSizeForm,
presentation: "inspector",
closeOnClickOutside: true,
});
interface LazyWidgetEditModalContentProps { interface LazyWidgetEditModalContentProps {
actions: { closeModal: () => void }; actions: { closeModal: () => void };
@@ -90,17 +81,12 @@ interface LazyWidgetEditModalContentProps {
const LazyWidgetEditModalContent = (props: LazyWidgetEditModalContentProps) => { const LazyWidgetEditModalContent = (props: LazyWidgetEditModalContentProps) => {
const { WidgetEditModal } = use(loadWidgetEditModal()); const { WidgetEditModal } = use(loadWidgetEditModal());
const Component = WidgetEditModal.component; const Component = WidgetEditModal.component;
const { openModal: openIntegrationEditModal } = useModalAction(LazyIntegrationEditModal);
return ( return (
<Component <Component
{...props} {...props}
innerProps={{ innerProps={{
...props.innerProps, ...props.innerProps,
onEditIntegration: (integrationId) => integrationEditForm: LazyIntegrationEditForm,
openIntegrationEditModal({
integrationId,
onSuccess: props.innerProps.onIntegrationSaved,
}),
}} }}
/> />
); );
@@ -0,0 +1,90 @@
"use client";
import { useImperativeHandle, useRef } from "react";
import { Alert, Button, Group, Skeleton, Stack, Text } from "@mantine/core";
import { IconInfoCircle } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { getIntegrationName } from "@homarr/definitions";
import { useI18n } from "@homarr/translation/client";
import { IntegrationAvatar } from "@homarr/ui";
import type { EmbeddedIntegrationEditFormProps } from "@homarr/widgets/modals";
import type { EditIntegrationFormHandle } from "~/app/[locale]/manage/integrations/edit/[id]/_integration-edit-form";
import { EditIntegrationForm } from "~/app/[locale]/manage/integrations/edit/[id]/_integration-edit-form";
export type { EmbeddedIntegrationEditFormProps } from "@homarr/widgets/modals";
export const EmbeddedIntegrationEditForm = ({
integrationId,
handleRef,
onSuccess,
}: EmbeddedIntegrationEditFormProps) => {
const t = useI18n();
const integrationFormRef = useRef<EditIntegrationFormHandle>(null);
const {
data: integration,
isPending,
isError,
refetch,
} = clientApi.integration.byId.useQuery({
id: integrationId,
});
useImperativeHandle(
handleRef,
() => ({
submitIfDirty: async () => {
if (!integrationFormRef.current?.isDirty()) {
return true;
}
return integrationFormRef.current.submit();
},
}),
[],
);
if (isPending) {
return (
<Stack gap="sm" aria-busy="true" aria-label={t("item.edit.integration.loading")}>
<Skeleton height={36} width="60%" />
<Skeleton height={60} />
<Skeleton height={120} />
</Stack>
);
}
if (isError || !integration) {
return (
<Alert color="red" title={t("common.error")}>
<Stack gap="sm">
<Text size="sm">{t("item.edit.integration.loadError")}</Text>
<Button variant="light" color="red" onClick={() => void refetch()}>
{t("common.action.tryAgain")}
</Button>
</Stack>
</Alert>
);
}
return (
<Stack>
<Group gap="sm" wrap="nowrap">
<IntegrationAvatar kind={integration.kind} size="sm" />
<Stack gap={0} style={{ minWidth: 0 }}>
<Text fw={600} truncate>
{integration.name}
</Text>
<Text size="xs" c="dimmed">
{getIntegrationName(integration.kind)}
</Text>
</Stack>
</Group>
<Alert icon={<IconInfoCircle size={16} />} color="blue" variant="light">
{t("item.edit.integration.propagationNotice")}
</Alert>
<EditIntegrationForm integration={integration} hideButtons formRef={integrationFormRef} onSuccess={onSuccess} />
</Stack>
);
};
@@ -1,86 +0,0 @@
"use client";
import { Alert, Button, Group, Skeleton, Stack, Text } from "@mantine/core";
import { IconInfoCircle } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { getIntegrationName } from "@homarr/definitions";
import { createModal, modalSizeForm } from "@homarr/modals";
import { useI18n } from "@homarr/translation/client";
import { IntegrationAvatar } from "@homarr/ui";
import { EditIntegrationForm } from "~/app/[locale]/manage/integrations/edit/[id]/_integration-edit-form";
export interface EmbeddedIntegrationEditModalProps {
integrationId: string;
onSuccess?: () => void;
}
export const EmbeddedIntegrationEditModal = createModal<EmbeddedIntegrationEditModalProps>(
({ actions, innerProps }) => {
const t = useI18n();
const {
data: integration,
isPending,
isError,
refetch,
} = clientApi.integration.byId.useQuery({
id: innerProps.integrationId,
});
if (isPending) {
return (
<Stack gap="sm" aria-busy="true" aria-label={t("item.edit.integration.loading")}>
<Skeleton height={36} width="60%" />
<Skeleton height={60} />
<Skeleton height={120} />
</Stack>
);
}
if (isError || !integration) {
return (
<Alert color="red" title={t("common.error")}>
<Stack gap="sm">
<Text size="sm">{t("item.edit.integration.loadError")}</Text>
<Button variant="light" color="red" onClick={() => void refetch()}>
{t("common.action.tryAgain")}
</Button>
</Stack>
</Alert>
);
}
return (
<Stack>
<Group gap="sm" wrap="nowrap">
<IntegrationAvatar kind={integration.kind} size="sm" />
<Stack gap={0} style={{ minWidth: 0 }}>
<Text fw={600} truncate>
{integration.name}
</Text>
<Text size="xs" c="dimmed">
{getIntegrationName(integration.kind)}
</Text>
</Stack>
</Group>
<Alert icon={<IconInfoCircle size={16} />} color="blue" variant="light">
{t("item.edit.integration.propagationNotice")}
</Alert>
<EditIntegrationForm
integration={integration}
embedded
onSuccess={() => {
innerProps.onSuccess?.();
actions.closeModal();
}}
/>
</Stack>
);
},
).withOptions({
defaultTitle: (t) => t("item.edit.tab.integration"),
size: modalSizeForm,
presentation: "inspector",
closeOnClickOutside: true,
});
@@ -112,13 +112,15 @@ export class EmbyIntegration extends Integration implements IMediaServerIntegrat
let currentlyPlaying: StreamSession["currentlyPlaying"] | null = null; let currentlyPlaying: StreamSession["currentlyPlaying"] | null = null;
if (sessionInfo.NowPlayingItem) { if (sessionInfo.NowPlayingItem) {
const isEpisode = sessionInfo.NowPlayingItem.Type === BaseItemKind.Episode;
currentlyPlaying = { currentlyPlaying = {
type: convertJellyfinType(sessionInfo.NowPlayingItem.Type), type: convertJellyfinType(sessionInfo.NowPlayingItem.Type),
name: sessionInfo.NowPlayingItem.SeriesName ?? sessionInfo.NowPlayingItem.Name ?? "", name: sessionInfo.NowPlayingItem.SeriesName ?? sessionInfo.NowPlayingItem.Name ?? "",
seasonName: sessionInfo.NowPlayingItem.SeasonName ?? "", seasonName: sessionInfo.NowPlayingItem.SeasonName ?? "",
seasonNumber: sessionInfo.NowPlayingItem.ParentIndexNumber ?? null, seasonNumber: isEpisode ? (sessionInfo.NowPlayingItem.ParentIndexNumber ?? null) : null,
episodeName: sessionInfo.NowPlayingItem.EpisodeTitle, episodeName: isEpisode ? sessionInfo.NowPlayingItem.EpisodeTitle : null,
episodeNumber: sessionInfo.NowPlayingItem.IndexNumber ?? null, episodeNumber: isEpisode ? (sessionInfo.NowPlayingItem.IndexNumber ?? null) : null,
albumName: sessionInfo.NowPlayingItem.Album ?? "", albumName: sessionInfo.NowPlayingItem.Album ?? "",
episodeCount: sessionInfo.NowPlayingItem.EpisodeCount, episodeCount: sessionInfo.NowPlayingItem.EpisodeCount,
playback: { playback: {
@@ -0,0 +1,65 @@
import { describe, expect, test } from "vitest";
import { extractHost, parseLocation } from "./jellyfin-integration";
describe("extractHost", () => {
test("strips the port from an IPv4 endpoint", () => {
expect(extractHost("192.168.1.100:54321")).toBe("192.168.1.100");
});
test("returns an IPv4 address unchanged when there's no port", () => {
expect(extractHost("8.8.8.8")).toBe("8.8.8.8");
});
test("strips brackets and the port from a bracketed IPv6 endpoint", () => {
expect(extractHost("[::1]:8096")).toBe("::1");
});
test("returns a bare IPv6 address unchanged when there's no port", () => {
expect(extractHost("::1")).toBe("::1");
});
});
describe("parseLocation", () => {
test("returns null when there's no endpoint to classify", () => {
expect(parseLocation(null)).toBeNull();
expect(parseLocation(undefined)).toBeNull();
expect(parseLocation("")).toBeNull();
});
test("returns null for a value that isn't an IP", () => {
expect(parseLocation("not-an-ip:1234")).toBeNull();
});
test("classifies private IPv4 ranges as lan", () => {
expect(parseLocation("192.168.1.100:54321")).toBe("lan");
expect(parseLocation("10.0.0.5:1234")).toBe("lan");
expect(parseLocation("172.16.5.5:1234")).toBe("lan");
expect(parseLocation("127.0.0.1:8096")).toBe("lan");
});
test("classifies a public IPv4 address as wan", () => {
expect(parseLocation("8.8.8.8:1234")).toBe("wan");
});
test("classifies IPv6 loopback and unique-local addresses as lan", () => {
expect(parseLocation("[::1]:8096")).toBe("lan");
expect(parseLocation("[fd12:3456:789a::1]:8096")).toBe("lan");
});
test("classifies a public IPv6 address as wan", () => {
expect(parseLocation("[2606:4700:4700::1111]:8096")).toBe("wan");
});
test("unwraps an IPv4-mapped IPv6 address before classifying it", () => {
expect(parseLocation("[::ffff:192.168.1.5]:8096")).toBe("lan");
expect(parseLocation("[::ffff:8.8.8.8]:8096")).toBe("wan");
});
test("classifies link-local addresses as lan", () => {
// Link-local traffic can't traverse a router, so it's always LAN even though
// it isn't a private range in the traditional (RFC 1918 / ULA) sense.
expect(parseLocation("169.254.1.5:8096")).toBe("lan");
expect(parseLocation("[fe80::1]:8096")).toBe("lan");
});
});
@@ -1,3 +1,4 @@
import { BlockList, isIP } from "node:net";
import { Jellyfin } from "@jellyfin/sdk"; import { Jellyfin } from "@jellyfin/sdk";
import { BaseItemKind } from "@jellyfin/sdk/lib/generated-client/models"; import { BaseItemKind } from "@jellyfin/sdk/lib/generated-client/models";
import { getSessionApi } from "@jellyfin/sdk/lib/utils/api/session-api"; import { getSessionApi } from "@jellyfin/sdk/lib/utils/api/session-api";
@@ -21,6 +22,42 @@ function ticksToMs(ticks: number | null | undefined): number | null {
return ticks ? Math.round(ticks / 10_000) : null; return ticks ? Math.round(ticks / 10_000) : null;
} }
// Unlike Plex, Jellyfin doesn't report a session's network location directly - only the
// client's RemoteEndPoint (an IP, optionally with a port). Classify it ourselves.
const privateAddresses = new BlockList();
privateAddresses.addSubnet("10.0.0.0", 8, "ipv4");
privateAddresses.addSubnet("172.16.0.0", 12, "ipv4");
privateAddresses.addSubnet("192.168.0.0", 16, "ipv4");
privateAddresses.addSubnet("127.0.0.0", 8, "ipv4");
privateAddresses.addSubnet("169.254.0.0", 16, "ipv4");
privateAddresses.addSubnet("fc00::", 7, "ipv6");
privateAddresses.addSubnet("fe80::", 10, "ipv6");
privateAddresses.addAddress("::1", "ipv6");
export function extractHost(remoteEndPoint: string): string {
if (remoteEndPoint.startsWith("[")) {
const end = remoteEndPoint.indexOf("]");
return end === -1 ? remoteEndPoint : remoteEndPoint.slice(1, end);
}
// A bare IPv6 address (no brackets, no port) has more than one colon.
const isIPv4WithPort = remoteEndPoint.split(":").length === 2;
return isIPv4WithPort ? remoteEndPoint.slice(0, remoteEndPoint.lastIndexOf(":")) : remoteEndPoint;
}
export function parseLocation(remoteEndPoint: string | null | undefined): "lan" | "wan" | null {
if (!remoteEndPoint) return null;
let host = extractHost(remoteEndPoint);
let family = isIP(host);
// A dual-stack server can report an IPv4 client as an IPv4-mapped IPv6 address
// (e.g. "::ffff:192.168.1.5") - unwrap it so the IPv4 private ranges still apply.
if (family === 6 && host.toLowerCase().startsWith("::ffff:") && isIP(host.slice(7)) === 4) {
host = host.slice(7);
family = 4;
}
if (!family) return null;
return privateAddresses.check(host, family === 4 ? "ipv4" : "ipv6") ? "lan" : "wan";
}
@HandleIntegrationErrors([integrationAxiosHttpErrorHandler]) @HandleIntegrationErrors([integrationAxiosHttpErrorHandler])
export class JellyfinIntegration extends Integration implements IMediaServerIntegration, IMediaReleasesIntegration { export class JellyfinIntegration extends Integration implements IMediaServerIntegration, IMediaReleasesIntegration {
private readonly jellyfin: Jellyfin = new Jellyfin({ private readonly jellyfin: Jellyfin = new Jellyfin({
@@ -56,17 +93,27 @@ export class JellyfinIntegration extends Integration implements IMediaServerInte
if (sessionInfo.NowPlayingItem) { if (sessionInfo.NowPlayingItem) {
const positionMs = ticksToMs(sessionInfo.PlayState?.PositionTicks); const positionMs = ticksToMs(sessionInfo.PlayState?.PositionTicks);
const durationMs = ticksToMs(sessionInfo.NowPlayingItem.RunTimeTicks); const durationMs = ticksToMs(sessionInfo.NowPlayingItem.RunTimeTicks);
// Jellyfin only reliably fills in MediaSources[].Bitrate for a transcoding session -
// /Sessions has no `fields` selector to force it, so fall back to summing the individual
// media streams' own bitrates (populated from the source file's metadata) for direct play.
const mediaStreamsBitrateBps = sessionInfo.NowPlayingItem.MediaStreams?.reduce(
(sum, stream) => sum + (stream.BitRate ?? 0),
0,
);
const bitrateBps = const bitrateBps =
sessionInfo.TranscodingInfo?.Bitrate ?? sessionInfo.NowPlayingItem.MediaSources?.[0]?.Bitrate ?? null; sessionInfo.TranscodingInfo?.Bitrate ??
sessionInfo.NowPlayingItem.MediaSources?.[0]?.Bitrate ??
(mediaStreamsBitrateBps || null);
const bitrateKbps = bitrateBps !== null ? Math.round(bitrateBps / 1000) : null; const bitrateKbps = bitrateBps !== null ? Math.round(bitrateBps / 1000) : null;
const isEpisode = sessionInfo.NowPlayingItem.Type === BaseItemKind.Episode;
currentlyPlaying = { currentlyPlaying = {
type: convertJellyfinType(sessionInfo.NowPlayingItem.Type), type: convertJellyfinType(sessionInfo.NowPlayingItem.Type),
name: sessionInfo.NowPlayingItem.SeriesName ?? sessionInfo.NowPlayingItem.Name ?? "", name: sessionInfo.NowPlayingItem.SeriesName ?? sessionInfo.NowPlayingItem.Name ?? "",
seasonName: sessionInfo.NowPlayingItem.SeasonName ?? "", seasonName: sessionInfo.NowPlayingItem.SeasonName ?? "",
seasonNumber: sessionInfo.NowPlayingItem.ParentIndexNumber ?? null, seasonNumber: isEpisode ? (sessionInfo.NowPlayingItem.ParentIndexNumber ?? null) : null,
episodeName: sessionInfo.NowPlayingItem.EpisodeTitle, episodeName: isEpisode ? sessionInfo.NowPlayingItem.EpisodeTitle : null,
episodeNumber: sessionInfo.NowPlayingItem.IndexNumber ?? null, episodeNumber: isEpisode ? (sessionInfo.NowPlayingItem.IndexNumber ?? null) : null,
albumName: sessionInfo.NowPlayingItem.Album ?? "", albumName: sessionInfo.NowPlayingItem.Album ?? "",
episodeCount: sessionInfo.NowPlayingItem.EpisodeCount, episodeCount: sessionInfo.NowPlayingItem.EpisodeCount,
playback: { playback: {
@@ -74,7 +121,7 @@ export class JellyfinIntegration extends Integration implements IMediaServerInte
positionMs, positionMs,
durationMs, durationMs,
}, },
location: null, location: parseLocation(sessionInfo.RemoteEndPoint),
metadata: { metadata: {
video: { video: {
resolution: resolution:
@@ -115,9 +115,9 @@ export class PlexIntegration extends Integration implements IMediaServerIntegrat
type: mediaElement.$.live === "1" ? "tv" : PlexIntegration.getCurrentlyPlayingType(mediaElement.$.type), type: mediaElement.$.live === "1" ? "tv" : PlexIntegration.getCurrentlyPlayingType(mediaElement.$.type),
name: mediaElement.$.grandparentTitle ?? mediaElement.$.title ?? "Unknown", name: mediaElement.$.grandparentTitle ?? mediaElement.$.title ?? "Unknown",
seasonName: mediaElement.$.parentTitle, seasonName: mediaElement.$.parentTitle,
seasonNumber: parseOptionalNumber(mediaElement.$.parentIndex), seasonNumber: mediaElement.$.type === "episode" ? parseOptionalNumber(mediaElement.$.parentIndex) : null,
episodeName: mediaElement.$.title ?? null, episodeName: mediaElement.$.type === "episode" ? (mediaElement.$.title ?? null) : null,
episodeNumber: parseOptionalNumber(mediaElement.$.index), episodeNumber: mediaElement.$.type === "episode" ? parseOptionalNumber(mediaElement.$.index) : null,
albumName: mediaElement.$.type === "track" ? (mediaElement.$.parentTitle ?? null) : null, albumName: mediaElement.$.type === "track" ? (mediaElement.$.parentTitle ?? null) : null,
episodeCount: parseOptionalNumber(mediaElement.$.index), episodeCount: parseOptionalNumber(mediaElement.$.index),
playback: { playback: {
@@ -31,6 +31,7 @@
.tableViewport :global(th) { .tableViewport :global(th) {
background-color: var(--background-color); background-color: var(--background-color);
border-bottom: 1px solid var(--border-color);
} }
.sortButton { .sortButton {
@@ -9,7 +9,12 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import type { StreamSession } from "@homarr/integrations"; import type { StreamSession } from "@homarr/integrations";
import { getMediaServerColumnVisibility, getSeasonEpisodeParams, SessionDetailsPopover } from "./component"; import {
getMediaServerColumnVisibility,
getResolutionLabel,
getSeasonEpisodeParams,
SessionDetailsPopover,
} from "./component";
vi.mock("@homarr/translation/client", () => ({ vi.mock("@homarr/translation/client", () => ({
useScopedI18n: () => (key: string) => key, useScopedI18n: () => (key: string) => key,
@@ -104,7 +109,8 @@ describe("getMediaServerColumnVisibility", () => {
test("keeps a stable responsive column model from the zero-sized first render", () => { test("keeps a stable responsive column model from the zero-sized first render", () => {
expect(getMediaServerColumnVisibility(0, false)).toEqual({ user: false, status: false }); expect(getMediaServerColumnVisibility(0, false)).toEqual({ user: false, status: false });
expect(getMediaServerColumnVisibility(300, false)).toEqual({ user: true, status: false }); expect(getMediaServerColumnVisibility(300, false)).toEqual({ user: true, status: false });
expect(getMediaServerColumnVisibility(420, false)).toEqual({ user: true, status: true }); expect(getMediaServerColumnVisibility(420, false)).toEqual({ user: true, status: false });
expect(getMediaServerColumnVisibility(540, false)).toEqual({ user: true, status: true });
expect(getMediaServerColumnVisibility(0, true)).toEqual({ user: true, status: true }); expect(getMediaServerColumnVisibility(0, true)).toEqual({ user: true, status: true });
}); });
}); });
@@ -124,3 +130,30 @@ describe("getSeasonEpisodeParams", () => {
expect(getSeasonEpisodeParams(null, undefined)).toBeNull(); expect(getSeasonEpisodeParams(null, undefined)).toBeNull();
}); });
}); });
describe("getResolutionLabel", () => {
test("labels a standard 16:9 resolution by its exact height", () => {
expect(getResolutionLabel(1920, 1080)).toBe("1080p");
expect(getResolutionLabel(1280, 720)).toBe("720p");
});
test("classifies a cinemascope source by its width, not its cropped height", () => {
// A 1920x804 encode (2.35:1, cropped top and bottom) is still a 1080p source.
expect(getResolutionLabel(1920, 804)).toBe("1080p");
expect(getResolutionLabel(1280, 640)).toBe("720p");
});
test("rounds a near-standard resolution up to its nearest tier", () => {
expect(getResolutionLabel(1912, 796)).toBe("1080p");
});
test("falls back to the raw height when it doesn't clear any known tier", () => {
expect(getResolutionLabel(100, 100)).toBe("100p");
});
test("classifies a portrait video by its width, not its long-axis height", () => {
// A 1080x1920 portrait phone recording is still a 1080p source, not a 1440p one.
expect(getResolutionLabel(1080, 1920)).toBe("1080p");
expect(getResolutionLabel(720, 1280)).toBe("720p");
});
});
+92 -36
View File
@@ -51,7 +51,9 @@ type SortState = { column: SortColumn; descending: boolean } | null;
export const getMediaServerColumnVisibility = (width: number, isAdvanced: boolean) => ({ export const getMediaServerColumnVisibility = (width: number, isAdvanced: boolean) => ({
user: isAdvanced || width >= 300, user: isAdvanced || width >= 300,
status: isAdvanced || width >= 420, // The user (200px) and status (190px) columns together need a floor here, otherwise the
// currentlyPlaying column - the primary content - gets squeezed to almost nothing.
status: isAdvanced || width >= 540,
}); });
function getPlaybackStatus(transcoding: TranscodingDecision | undefined): PlaybackStatus { function getPlaybackStatus(transcoding: TranscodingDecision | undefined): PlaybackStatus {
@@ -77,6 +79,21 @@ function formatBitrate(bitrateKbps: number | null | undefined): string | null {
return bitrateKbps >= 1000 ? `${(bitrateKbps / 1000).toFixed(1)} Mbps` : `${Math.round(bitrateKbps)} kbps`; return bitrateKbps >= 1000 ? `${(bitrateKbps / 1000).toFixed(1)} Mbps` : `${Math.round(bitrateKbps)} kbps`;
} }
const RESOLUTION_TIER_HEIGHTS = [4320, 2160, 1440, 1080, 720, 480, 360, 240] as const;
export function getResolutionLabel(width: number, height: number): string {
// Classify off the short axis (height for landscape, width for portrait) so orientation
// doesn't flip which dimension drives the label. Letterboxed/cinemascope sources are cropped
// shorter than their nominal resolution class (a 1920x804 encode is still "1080p"), so derive
// a floor for the short axis from the long axis assuming 16:9 - matches how Plex/Jellyfin label these themselves.
const isPortrait = height > width;
const shortAxis = isPortrait ? width : height;
const longAxis = isPortrait ? height : width;
const effectiveShortAxis = Math.max(shortAxis, Math.round((longAxis * 9) / 16));
const tier = RESOLUTION_TIER_HEIGHTS.find((candidate) => effectiveShortAxis >= candidate * 0.9);
return `${tier ?? effectiveShortAxis}p`;
}
export function getSeasonEpisodeParams( export function getSeasonEpisodeParams(
seasonNumber: number | null | undefined, seasonNumber: number | null | undefined,
episodeNumber: number | null | undefined, episodeNumber: number | null | undefined,
@@ -117,27 +134,33 @@ function StreamTableHeader({
sortable, sortable,
sort, sort,
onSort, onSort,
width,
}: { }: {
column: SortColumn; column: SortColumn;
label: string; label: string;
sortable: boolean; sortable: boolean;
sort: SortState; sort: SortState;
onSort: (column: SortColumn) => void; onSort: (column: SortColumn) => void;
width?: number;
}) { }) {
const active = sort?.column === column; const active = sort?.column === column;
const SortIcon = !active ? IconArrowsSort : sort.descending ? IconChevronDown : IconChevronUp; const SortIcon = !active ? IconArrowsSort : sort.descending ? IconChevronDown : IconChevronUp;
return ( return (
<Table.Th aria-sort={active ? (sort.descending ? "descending" : "ascending") : "none"}> <Table.Th w={width} aria-sort={active ? (sort.descending ? "descending" : "ascending") : "none"}>
{sortable ? ( {sortable ? (
<UnstyledButton className={classes.sortButton} onClick={() => onSort(column)}> <UnstyledButton className={classes.sortButton} onClick={() => onSort(column)}>
<Text component="span" size="xs" fw={600} truncate> <Text component="span" size="xs" fw={600} c="dimmed" style={{ letterSpacing: "0.02em" }} truncate>
{label} {label}
</Text> </Text>
<SortIcon size="var(--mantine-font-size-xs)" aria-hidden /> <SortIcon
size="var(--mantine-font-size-xs)"
aria-hidden
color={active ? undefined : "var(--mantine-color-dimmed)"}
/>
</UnstyledButton> </UnstyledButton>
) : ( ) : (
<Text size="xs" fw={600} truncate> <Text size="xs" fw={600} c="dimmed" style={{ letterSpacing: "0.02em" }} truncate>
{label} {label}
</Text> </Text>
)} )}
@@ -195,12 +218,14 @@ export default function MediaServerWidget({
current?.column === column ? { column, descending: !current.descending } : { column, descending: false }, current?.column === column ? { column, descending: !current.descending } : { column, descending: false },
); );
const uniqueIntegrations = currentStreams.map((stream) => ({ const uniqueIntegrations = currentStreams
integrationId: stream.integrationId, .filter((stream) => stream.sessions.length > 0)
integrationKind: stream.integrationKind, .map((stream) => ({
integrationIcon: getIconUrl(stream.integrationKind), integrationId: stream.integrationId,
integrationName: stream.integrationName, integrationKind: stream.integrationKind,
})); integrationIcon: getIconUrl(stream.integrationKind),
integrationName: stream.integrationName,
}));
const playingCount = flatSessions.filter( const playingCount = flatSessions.filter(
(session) => session.currentlyPlaying && session.currentlyPlaying.playback?.state !== "paused", (session) => session.currentlyPlaying && session.currentlyPlaying.playback?.state !== "paused",
@@ -263,6 +288,7 @@ export default function MediaServerWidget({
sortable={isAdvanced} sortable={isAdvanced}
sort={sort} sort={sort}
onSort={toggleSort} onSort={toggleSort}
width={200}
/> />
)} )}
<StreamTableHeader <StreamTableHeader
@@ -279,6 +305,7 @@ export default function MediaServerWidget({
sortable={isAdvanced} sortable={isAdvanced}
sort={sort} sort={sort}
onSort={toggleSort} onSort={toggleSort}
width={190}
/> />
)} )}
</Table.Tr> </Table.Tr>
@@ -290,6 +317,12 @@ export default function MediaServerWidget({
const status = getPlaybackStatus(currentlyPlaying?.metadata?.transcoding); const status = getPlaybackStatus(currentlyPlaying?.metadata?.transcoding);
const location = showLocation ? currentlyPlaying?.location : null; const location = showLocation ? currentlyPlaying?.location : null;
const bitrateLabel = showBitrate ? formatBitrate(currentlyPlaying?.metadata?.bitrateKbps) : null; const bitrateLabel = showBitrate ? formatBitrate(currentlyPlaying?.metadata?.bitrateKbps) : null;
// The transcoding resolution reflects what's actually being streamed - fall back to the
// source's own resolution when it isn't set (direct play, or a transcode that only
// touches audio/container and leaves the video resolution untouched).
const resolution =
currentlyPlaying?.metadata?.transcoding.resolution ?? currentlyPlaying?.metadata?.video.resolution;
const resolutionLabel = resolution ? getResolutionLabel(resolution.width, resolution.height) : null;
const toggleDetails = () => setSelectedRowId((current) => (current === rowId ? null : rowId)); const toggleDetails = () => setSelectedRowId((current) => (current === rowId ? null : rowId));
return ( return (
@@ -315,7 +348,7 @@ export default function MediaServerWidget({
{columnVisibility.user && ( {columnVisibility.user && (
<Table.Td> <Table.Td>
<Group gap="xs" wrap="nowrap" w="100%"> <Group gap="xs" wrap="nowrap" w="100%">
<Avatar size={28} src={session.user?.profilePictureUrl} /> <Avatar size={28} src={session.user?.profilePictureUrl} style={{ flexShrink: 0 }} />
<Stack gap={2} className={classes.cellContent}> <Stack gap={2} className={classes.cellContent}>
<Text size="xs" truncate> <Text size="xs" truncate>
{session.user?.username ?? t("items.unknownUser")} {session.user?.username ?? t("items.unknownUser")}
@@ -340,24 +373,30 @@ export default function MediaServerWidget({
<Table.Td> <Table.Td>
{currentlyPlaying && ( {currentlyPlaying && (
<Stack gap={4} align="flex-start" w="100%" className={classes.cellContent}> <Stack gap={4} align="flex-start" w="100%" className={classes.cellContent}>
<Badge size="xs" variant="light" color={playbackStatusColorMap[status]}> <Group gap={4} align="center" justify="space-between" wrap="nowrap" w="100%">
{t(`items.${status}` as never)} <Badge size="xs" variant="light" color={playbackStatusColorMap[status]}>
</Badge> {t(`items.${status}` as never)}
{(location ?? bitrateLabel) && ( </Badge>
<Group gap={4} align="center" justify="space-between" wrap="nowrap" w="100%"> {location && (
<Group gap={4} align="center" wrap="nowrap"> <Group gap={4} align="center" wrap="nowrap">
{location && {location === "lan" ? (
(location === "lan" ? ( <IconWifi size="var(--mantine-font-size-xs)" />
<IconWifi size="var(--mantine-font-size-xs)" /> ) : (
) : ( <IconWorld size="var(--mantine-font-size-xs)" />
<IconWorld size="var(--mantine-font-size-xs)" />
))}
{location && (
<Text size="xs" c="dimmed" tt="uppercase">
{location}
</Text>
)} )}
<Text size="xs" c="dimmed" tt="uppercase">
{location}
</Text>
</Group> </Group>
)}
</Group>
{(resolutionLabel ?? bitrateLabel) && (
<Group gap={4} align="center" justify="space-between" wrap="nowrap" w="100%">
{resolutionLabel && (
<Text size="xs" c="dimmed" style={{ whiteSpace: "nowrap" }}>
{resolutionLabel}
</Text>
)}
{bitrateLabel && ( {bitrateLabel && (
<Text size="xs" c="dimmed" style={{ whiteSpace: "nowrap" }}> <Text size="xs" c="dimmed" style={{ whiteSpace: "nowrap" }}>
{bitrateLabel} {bitrateLabel}
@@ -386,25 +425,42 @@ export default function MediaServerWidget({
borderTop: "1px solid var(--border-color)", borderTop: "1px solid var(--border-color)",
}} }}
> >
<Group gap={4} wrap="nowrap"> <Group gap={6} wrap="nowrap">
<IconVideo size="var(--mantine-font-size-xs)" style={{ flexShrink: 0 }} /> <IconVideo size="var(--mantine-font-size-xs)" style={{ flexShrink: 0 }} />
<Text size="sm" style={{ whiteSpace: "nowrap" }}> <Text size="sm" fw={500} style={{ whiteSpace: "nowrap" }}>
{(t as unknown as (key: string, params?: { count: number }) => string)("footer.streams", { {(t as unknown as (key: string, params?: { count: number }) => string)("footer.streams", {
count: flatSessions.length, count: flatSessions.length,
})} })}
</Text> </Text>
{totalBitrateLabel && (isAdvanced || width >= 300) && ( {totalBitrateLabel && (isAdvanced || width >= 300) && (
<Text size="sm" c="dimmed" style={{ whiteSpace: "nowrap" }}> <>
{t("footer.totalBitrate", { bitrate: totalBitrateLabel })} <Text size="sm" c="dimmed" style={{ whiteSpace: "nowrap" }}>
</Text> •
</Text>
<Text size="sm" c="dimmed" style={{ whiteSpace: "nowrap" }}>
{t("footer.totalBitrate", { bitrate: totalBitrateLabel })}
</Text>
</>
)} )}
</Group> </Group>
<Group gap="xs"> <Group gap={6}>
{uniqueIntegrations.map((integration) => ( {uniqueIntegrations.map((integration) => (
<Group key={integration.integrationId} gap="xs" align="center"> <Group
<Avatar className="media-server-icon" src={integration.integrationIcon} radius={"xs"} size="xs" /> key={integration.integrationId}
gap={6}
align="center"
wrap="nowrap"
pl={2}
pr={8}
py={2}
style={{
backgroundColor: "var(--mantine-color-default-hover)",
borderRadius: 999,
}}
>
<Avatar className="media-server-icon" src={integration.integrationIcon} radius="xl" size={18} />
{(isAdvanced || width >= 480) && ( {(isAdvanced || width >= 480) && (
<Text className="media-server-name" size="sm" truncate="end"> <Text className="media-server-name" size="xs" fw={500} truncate="end">
{integration.integrationName} {integration.integrationName}
</Text> </Text>
)} )}
+131 -49
View File
@@ -1,10 +1,10 @@
"use client"; "use client";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import type { FormEvent } from "react"; import type { ComponentType, FormEvent } from "react";
import { Box, Button, Group, Stack, Tabs, Text } from "@mantine/core"; import { Box, Button, Group, Stack, Tabs, Text } from "@mantine/core";
import { schemaResolver } from "@mantine/form"; import { schemaResolver } from "@mantine/form";
import { IconPencil } from "@tabler/icons-react"; import { IconArrowLeft, IconPencil } from "@tabler/icons-react";
import { z } from "zod/v4"; import { z } from "zod/v4";
import { objectEntries } from "@homarr/common"; import { objectEntries } from "@homarr/common";
@@ -35,6 +35,16 @@ export interface WidgetEditModalState {
advancedOptions: BoardItemAdvancedOptions; advancedOptions: BoardItemAdvancedOptions;
} }
export interface EmbeddedIntegrationEditFormHandle {
submitIfDirty: () => Promise<boolean>;
}
export interface EmbeddedIntegrationEditFormProps {
integrationId: string;
handleRef: React.Ref<EmbeddedIntegrationEditFormHandle>;
onSuccess?: () => void;
}
export interface WidgetEditModalProps<TSort extends WidgetKind> { export interface WidgetEditModalProps<TSort extends WidgetKind> {
kind: TSort; kind: TSort;
definition: WidgetDefinition; definition: WidgetDefinition;
@@ -46,7 +56,7 @@ export interface WidgetEditModalProps<TSort extends WidgetKind> {
itemId?: string; itemId?: string;
boardId?: string; boardId?: string;
appId?: string; appId?: string;
onEditIntegration?: (integrationId: string) => void; integrationEditForm?: ComponentType<EmbeddedIntegrationEditFormProps>;
onIntegrationSaved?: () => void; onIntegrationSaved?: () => void;
} }
@@ -61,6 +71,12 @@ export const WidgetEditModal = createModal<WidgetEditModalProps<WidgetKind>>(({
const { data: session } = useSession(); const { data: session } = useSession();
const [advancedOptions, setAdvancedOptions] = useState<BoardItemAdvancedOptions>(innerProps.value.advancedOptions); const [advancedOptions, setAdvancedOptions] = useState<BoardItemAdvancedOptions>(innerProps.value.advancedOptions);
const appEditRef = useRef<EmbeddedAppEditFormHandle>(null); const appEditRef = useRef<EmbeddedAppEditFormHandle>(null);
const integrationEditHandles = useRef(new Map<string, EmbeddedIntegrationEditFormHandle>());
const integrationEditRefCallbacks = useRef(
new Map<string, (handle: EmbeddedIntegrationEditFormHandle | null) => void>(),
);
const [mountedIntegrationIds, setMountedIntegrationIds] = useState<string[]>([]);
const [activeIntegrationId, setActiveIntegrationId] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
z.config({ z.config({
@@ -118,7 +134,24 @@ export const WidgetEditModal = createModal<WidgetEditModalProps<WidgetKind>>(({
const showAppTab = innerProps.kind === "app" && canModifyApps && Boolean(appId); const showAppTab = innerProps.kind === "app" && canModifyApps && Boolean(appId);
const canModifyAllIntegrations = session?.user.permissions.includes("integration-full-all") ?? false; const canModifyAllIntegrations = session?.user.permissions.includes("integration-full-all") ?? false;
const selectedIntegrations = getSelectedWidgetIntegrations(innerProps.integrationData, form.values.integrationIds); const selectedIntegrations = getSelectedWidgetIntegrations(innerProps.integrationData, form.values.integrationIds);
const showIntegrationTab = Boolean(innerProps.onEditIntegration) && selectedIntegrations.length > 0; const editableIntegrationIds = selectedIntegrations
.filter((integration) => canModifyAllIntegrations || integration.permissions?.hasFullAccess === true)
.map((integration) => integration.id);
let soleEditableIntegrationId: string | null = null;
if (selectedIntegrations.length === 1) {
soleEditableIntegrationId = editableIntegrationIds[0] ?? null;
}
let displayedIntegrationId = soleEditableIntegrationId;
if (activeIntegrationId && editableIntegrationIds.includes(activeIntegrationId)) {
displayedIntegrationId = activeIntegrationId;
}
const integrationIdsToRender = mountedIntegrationIds.filter((id) => editableIntegrationIds.includes(id));
if (displayedIntegrationId && !integrationIdsToRender.includes(displayedIntegrationId)) {
integrationIdsToRender.push(displayedIntegrationId);
}
const IntegrationEditForm = innerProps.integrationEditForm;
const showIntegrationTab = Boolean(IntegrationEditForm) && selectedIntegrations.length > 0;
const showResourceTabs = showAppTab || showIntegrationTab; const showResourceTabs = showAppTab || showIntegrationTab;
const [activeTab, setActiveTab] = useState<string | null>("widget"); const [activeTab, setActiveTab] = useState<string | null>("widget");
@@ -127,15 +160,31 @@ export const WidgetEditModal = createModal<WidgetEditModalProps<WidgetKind>>(({
if (activeTab === "app" && !showAppTab) setActiveTab("widget"); if (activeTab === "app" && !showAppTab) setActiveTab("widget");
}, [activeTab, showAppTab, showIntegrationTab]); }, [activeTab, showAppTab, showIntegrationTab]);
const handleTabChange = (value: string | null) => { const getIntegrationEditRef = (integrationId: string) => {
if (value === "integration" && selectedIntegrations.length === 1) { const existingCallback = integrationEditRefCallbacks.current.get(integrationId);
const integration = selectedIntegrations[0]; if (existingCallback) {
if (integration) { return existingCallback;
innerProps.onEditIntegration?.(integration.id);
return;
}
} }
setActiveTab(value);
const callback = (handle: EmbeddedIntegrationEditFormHandle | null) => {
if (handle) {
integrationEditHandles.current.set(integrationId, handle);
} else {
integrationEditHandles.current.delete(integrationId);
}
};
integrationEditRefCallbacks.current.set(integrationId, callback);
return callback;
};
const beginEditingIntegration = (integrationId: string) => {
setMountedIntegrationIds((current) => {
if (current.includes(integrationId)) {
return current;
}
return [...current, integrationId];
});
setActiveIntegrationId(integrationId);
}; };
const handleSubmit = form.onSubmit(async (values) => { const handleSubmit = form.onSubmit(async (values) => {
@@ -147,6 +196,13 @@ export const WidgetEditModal = createModal<WidgetEditModalProps<WidgetKind>>(({
return; return;
} }
for (const integrationEditHandle of integrationEditHandles.current.values()) {
const integrationSaved = await integrationEditHandle.submitIfDirty();
if (!integrationSaved) {
return;
}
}
innerProps.onSuccessfulEdit({ innerProps.onSuccessfulEdit({
...values, ...values,
advancedOptions, advancedOptions,
@@ -240,7 +296,7 @@ export const WidgetEditModal = createModal<WidgetEditModalProps<WidgetKind>>(({
<FormProvider form={form}> <FormProvider form={form}>
{showResourceTabs ? ( {showResourceTabs ? (
<Stack> <Stack>
<Tabs value={activeTab} onChange={handleTabChange}> <Tabs value={activeTab} onChange={setActiveTab}>
<Tabs.List grow> <Tabs.List grow>
<Tabs.Tab value="widget">{t("item.edit.tab.widget")}</Tabs.Tab> <Tabs.Tab value="widget">{t("item.edit.tab.widget")}</Tabs.Tab>
{showAppTab && <Tabs.Tab value="app">{t("item.edit.tab.app")}</Tabs.Tab>} {showAppTab && <Tabs.Tab value="app">{t("item.edit.tab.app")}</Tabs.Tab>}
@@ -257,42 +313,68 @@ export const WidgetEditModal = createModal<WidgetEditModalProps<WidgetKind>>(({
{showIntegrationTab && ( {showIntegrationTab && (
<Tabs.Panel value="integration" pt="md"> <Tabs.Panel value="integration" pt="md">
<Stack> <Stack>
<Text size="sm" c="dimmed"> {displayedIntegrationId === null && (
{t("item.edit.integration.description")} <>
</Text> <Text size="sm" c="dimmed">
{selectedIntegrations.map((integration) => { {t("item.edit.integration.description")}
const canEdit = canModifyAllIntegrations || integration.permissions?.hasFullAccess === true; </Text>
return ( {selectedIntegrations.map((integration) => {
<Group key={integration.id} justify="space-between" wrap="nowrap" gap="sm"> const canEdit = editableIntegrationIds.includes(integration.id);
<Group gap="sm" wrap="nowrap" style={{ minWidth: 0 }}> return (
<IntegrationAvatar kind={integration.kind} size="sm" /> <Group key={integration.id} justify="space-between" wrap="nowrap" gap="sm">
<Stack gap={0} style={{ minWidth: 0 }}> <Group gap="sm" wrap="nowrap" style={{ minWidth: 0 }}>
<Text fw={600} size="sm" truncate> <IntegrationAvatar kind={integration.kind} size="sm" />
{integration.name} <Stack gap={0} style={{ minWidth: 0 }}>
</Text> <Text fw={600} size="sm" truncate>
<Text size="xs" c="dimmed" truncate> {integration.name}
{integration.url} </Text>
</Text> <Text size="xs" c="dimmed" truncate>
</Stack> {integration.url}
</Group> </Text>
{canEdit ? ( </Stack>
<Button </Group>
type="button" {canEdit ? (
variant="light" <Button
leftSection={<IconPencil size={16} />} type="button"
onClick={() => innerProps.onEditIntegration?.(integration.id)} variant="light"
aria-label={t("item.edit.integration.editLabel", { name: integration.name })} leftSection={<IconPencil size={16} />}
> onClick={() => beginEditingIntegration(integration.id)}
{t("item.edit.integration.action")} aria-label={t("item.edit.integration.editLabel", { name: integration.name })}
</Button> >
) : ( {t("item.edit.integration.action")}
<Text size="xs" c="dimmed" ta="end"> </Button>
{t("item.edit.integration.fullAccessRequired")} ) : (
</Text> <Text size="xs" c="dimmed" ta="end">
)} {t("item.edit.integration.fullAccessRequired")}
</Group> </Text>
); )}
})} </Group>
);
})}
</>
)}
{displayedIntegrationId !== null && selectedIntegrations.length > 1 && (
<Button
type="button"
variant="subtle"
leftSection={<IconArrowLeft size={16} />}
onClick={() => setActiveIntegrationId(null)}
style={{ alignSelf: "start" }}
>
{t("common.action.previous")}
</Button>
)}
{integrationIdsToRender.map((integrationId) => (
<Box key={integrationId} hidden={displayedIntegrationId !== integrationId}>
{IntegrationEditForm && (
<IntegrationEditForm
integrationId={integrationId}
handleRef={getIntegrationEditRef(integrationId)}
onSuccess={innerProps.onIntegrationSaved}
/>
)}
</Box>
))}
</Stack> </Stack>
</Tabs.Panel> </Tabs.Panel>
)} )}