+ {visibleStats.length > 0 && (
+
+ {visibleStats.map(({ optionKey, statKey, value, Icon }) => (
+
+
+ {value}
+ {t(statKey as never)}
+
+ ))}
+
+ )}
+
+ {showNowPlayingSection && navidromeStats && (
+
+
+
+ {t("nowPlaying" as never)}
+ {!options.showNowPlayingList && (
+
+ {(t as unknown as (key: string, params?: { count: number }) => string)("nowPlayingCount", {
+ count: navidromeStats.nowPlaying.length,
+ })}
+
+ )}
+
+ {options.showNowPlayingList && (
+
+ {nowPlayingTracks.map((track, index) => (
+
+
+ {track.title}
+
+ {[track.artist, track.album].filter(Boolean).join(" · ")}
+
+
+
+ {track.username || track.playerName}
+
+
+ ))}
+
+ )}
+
+ )}
+
+ {!hasContent && (
+
+
+ —
+
+
+ )}
+
+ );
+}
diff --git a/packages/widgets/src/audio-stats/audio-stats-shared.spec.ts b/packages/widgets/src/audio-stats/audio-stats-shared.spec.ts
new file mode 100644
index 000000000..3aab75060
--- /dev/null
+++ b/packages/widgets/src/audio-stats/audio-stats-shared.spec.ts
@@ -0,0 +1,94 @@
+import { describe, expect, test } from "vitest";
+
+import type { AudiobookshelfDashboardData, NavidromeDashboardData } from "@homarr/integrations/types";
+
+import { getGridCols, getIconSize, getVisibleStats } from "./shared";
+
+describe("getGridCols", () => {
+ test("caps columns to visible stat count", () => {
+ expect(getGridCols(500, 2, false)).toBe(2);
+ });
+
+ test("returns max cols when many stats visible", () => {
+ expect(getGridCols(500, 10, false)).toBe(5);
+ });
+
+ test("compact mode uses smaller breakpoints", () => {
+ expect(getGridCols(290, 5, true)).toBe(5);
+ expect(getGridCols(290, 5, false)).toBe(2);
+ });
+
+ test("returns at least 1 col even with 0 visible stats", () => {
+ expect(getGridCols(500, 0, false)).toBe(1);
+ });
+});
+
+describe("getIconSize", () => {
+ test("returns large size for wide container", () => {
+ expect(getIconSize(400, false)).toBe(22);
+ });
+
+ test("returns compact size for narrow container in compact mode", () => {
+ expect(getIconSize(100, true)).toBe(14);
+ });
+
+ test("returns fallback for very narrow non-compact container", () => {
+ expect(getIconSize(50, false)).toBe(16);
+ });
+});
+
+describe("getVisibleStats", () => {
+ const navidromeStats: NavidromeDashboardData = {
+ artistCount: 100,
+ albumCount: 200,
+ songCount: 3000,
+ nowPlaying: [],
+ };
+
+ const audiobookshelfStats: AudiobookshelfDashboardData = {
+ libraryCount: 3,
+ totalAudiobooks: 150,
+ totalPodcasts: 20,
+ totalListeningTimeSeconds: 86400,
+ activeSessions: 2,
+ };
+
+ test("returns only enabled navidrome stats", () => {
+ const result = getVisibleStats(
+ "navidrome",
+ { showArtists: true, showAlbums: false, showSongs: true },
+ navidromeStats,
+ );
+ expect(result).toHaveLength(2);
+ expect(result.map((s) => s.statKey)).toEqual(["artists", "songs"]);
+ expect(result[0]?.value).toBe(100);
+ expect(result[1]?.value).toBe(3000);
+ });
+
+ test("returns only enabled audiobookshelf stats", () => {
+ const result = getVisibleStats(
+ "audiobookshelf",
+ {
+ showLibraryCount: true,
+ showAudiobooks: true,
+ showPodcasts: false,
+ showListeningTime: false,
+ showActiveSessions: true,
+ },
+ audiobookshelfStats,
+ );
+ expect(result).toHaveLength(3);
+ expect(result.map((s) => s.statKey)).toEqual(["libraryCount", "audiobooks", "activeSessions"]);
+ });
+
+ test("returns empty array when all options disabled", () => {
+ const result = getVisibleStats("navidrome", {}, navidromeStats);
+ expect(result).toEqual([]);
+ });
+
+ test("formats listening time as duration string", () => {
+ const result = getVisibleStats("audiobookshelf", { showListeningTime: true }, audiobookshelfStats);
+ expect(result).toHaveLength(1);
+ expect(typeof result[0]?.value).toBe("string");
+ });
+});
diff --git a/packages/widgets/src/audio-stats/component.module.css b/packages/widgets/src/audio-stats/component.module.css
new file mode 100644
index 000000000..f665c8028
--- /dev/null
+++ b/packages/widgets/src/audio-stats/component.module.css
@@ -0,0 +1,185 @@
+.root {
+ container-type: size;
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+ min-height: 0;
+ padding: clamp(6px, 3cqi, 12px);
+ gap: clamp(4px, 2cqi, 8px);
+ overflow: hidden;
+}
+
+.rootCompact {
+ padding: clamp(4px, 2cqi, 8px);
+ gap: clamp(2px, 1cqi, 4px);
+}
+
+.grid {
+ display: grid;
+ flex: 1;
+ width: 100%;
+ min-height: 0;
+ gap: clamp(4px, 2cqi, 8px);
+ grid-template-columns: repeat(var(--stat-cols, 2), minmax(0, 1fr));
+ grid-auto-rows: 1fr;
+ align-content: stretch;
+}
+
+.gridCompact {
+ gap: clamp(2px, 1cqi, 4px);
+}
+
+.statTile {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ min-height: 0;
+ min-width: 0;
+ height: 100%;
+ gap: clamp(2px, 1.5cqi, 4px);
+ padding: clamp(4px, 2cqi, 8px);
+ border-radius: var(--mantine-radius-md);
+ background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6));
+ overflow: hidden;
+}
+
+.statTileCompact {
+ gap: clamp(1px, 0.8cqi, 2px);
+ padding: clamp(2px, 1.2cqi, 5px);
+ border-radius: var(--mantine-radius-sm);
+}
+
+.statIcon {
+ color: var(--mantine-color-dimmed);
+ flex-shrink: 0;
+ line-height: 0;
+}
+
+.statLabel {
+ font-size: clamp(8px, 2.5cqi, 11px);
+ color: var(--mantine-color-dimmed);
+ line-height: 1.2;
+ text-align: center;
+ max-width: 100%;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.statTileCompact .statLabel {
+ font-size: clamp(7px, 2cqi, 9px);
+}
+
+.statValue {
+ font-size: clamp(13px, 5cqi, 20px);
+ font-weight: 700;
+ color: var(--mantine-primary-color-filled);
+ line-height: 1.1;
+ text-align: center;
+ max-width: 100%;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.statTileCompact .statValue {
+ font-size: clamp(11px, 4cqi, 16px);
+}
+
+.nowPlaying {
+ display: flex;
+ flex-direction: column;
+ gap: clamp(3px, 1.2cqi, 5px);
+ flex: 1;
+ min-height: 0;
+ overflow: hidden;
+}
+
+.nowPlayingHeader {
+ display: flex;
+ align-items: center;
+ gap: clamp(4px, 1.5cqi, 6px);
+ font-size: clamp(10px, 2.8cqi, 12px);
+ font-weight: 600;
+ flex-shrink: 0;
+ min-width: 0;
+}
+
+.nowPlayingTitle {
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.nowPlayingCount {
+ flex-shrink: 0;
+ margin-inline-start: auto;
+}
+
+.nowPlayingList {
+ display: flex;
+ flex-direction: column;
+ gap: clamp(2px, 0.8cqi, 4px);
+ flex: 1;
+ min-height: 0;
+ overflow: hidden;
+}
+
+.nowPlayingItem {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: clamp(4px, 1.5cqi, 6px);
+ padding: clamp(3px, 1.2cqi, 6px) clamp(4px, 1.5cqi, 8px);
+ border-radius: var(--mantine-radius-sm);
+ background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6));
+ min-width: 0;
+ flex-shrink: 0;
+}
+
+.trackInfo {
+ display: flex;
+ flex-direction: column;
+ gap: 1px;
+ min-width: 0;
+ flex: 1;
+}
+
+.trackTitle {
+ font-size: clamp(9px, 2.6cqi, 12px);
+ font-weight: 500;
+ line-height: 1.2;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.trackArtist {
+ font-size: clamp(8px, 2.2cqi, 10px);
+ color: var(--mantine-color-dimmed);
+ line-height: 1.2;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.trackUser {
+ flex-shrink: 0;
+ max-width: clamp(40px, 22cqi, 88px);
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.emptyState {
+ display: flex;
+ flex: 1;
+ align-items: center;
+ justify-content: center;
+ min-height: 0;
+ color: var(--mantine-color-dimmed);
+ font-size: clamp(11px, 3cqi, 13px);
+ text-align: center;
+}
diff --git a/packages/widgets/src/audio-stats/component.tsx b/packages/widgets/src/audio-stats/component.tsx
new file mode 100644
index 000000000..ff2b56863
--- /dev/null
+++ b/packages/widgets/src/audio-stats/component.tsx
@@ -0,0 +1,14 @@
+"use client";
+
+import { clientApi } from "@homarr/api/client";
+
+import type { WidgetComponentProps } from "../definition";
+import { AudioStatsContent } from "./audio-stats-content";
+
+export default function AudioStatsWidget({ integrationIds, options, width }: WidgetComponentProps<"audioStats">) {
+ const [response] = clientApi.widget.audioStats.getStats.useSuspenseQuery({
+ integrationId: integrationIds[0] ?? "",
+ });
+
+ return