diff --git a/frontend/e2e/channel.spec.ts b/frontend/e2e/channel.spec.ts
index 71479b98..b3b46db9 100644
--- a/frontend/e2e/channel.spec.ts
+++ b/frontend/e2e/channel.spec.ts
@@ -111,7 +111,7 @@ test.describe("channel view", () => {
test("the channel chrome stays fixed while the video grid scrolls", async ({ page }) => {
await openApp(page);
await page.getByTestId("video-card-channel").first().click();
- const count = page.getByTestId("feed-result-count");
+ const count = page.getByTestId("item-count");
const title = page.getByTestId("channel-title");
await expect(count).toBeVisible();
await expect(page.getByTestId("video-card").first()).toBeVisible();
diff --git a/frontend/e2e/feed.spec.ts b/frontend/e2e/feed.spec.ts
index 61c045d1..55700b6f 100644
--- a/frontend/e2e/feed.spec.ts
+++ b/frontend/e2e/feed.spec.ts
@@ -8,7 +8,7 @@ test.describe("feed", () => {
await openApp(page);
await expect(page.getByTestId("video-card").first()).toBeVisible();
- const count = page.getByTestId("feed-result-count");
+ const count = page.getByTestId("item-count");
await expect(count).toHaveText(/\d/);
const baseline = await count.innerText();
@@ -55,7 +55,7 @@ test.describe("feed", () => {
// pins in the shell's toolbar band above the scroller, so it stays put while the cards scroll.
test("the feed toolbar stays pinned while cards scroll", async ({ page }) => {
await openApp(page);
- const count = page.getByTestId("feed-result-count");
+ const count = page.getByTestId("item-count");
await expect(count).toBeVisible();
const topBefore = await count.evaluate((el) => el.getBoundingClientRect().top);
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 5af82e6e..8f625ea6 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -136,6 +136,19 @@ export default function App() {
if (meQuery.data && getActiveAccount() == null) setActiveAccount(meQuery.data.id);
}, [meQuery.data?.id]);
+ // Measure the scrollbar's gutter width once and publish it as --sbw, so the floating header and the
+ // toolbar band can pad their right edge to line up with the scrolled cards (the app scroller reserves
+ // that gutter via scrollbar-gutter: stable). It's a device constant, so a one-shot mount measure is enough.
+ useEffect(() => {
+ const probe = document.createElement("div");
+ probe.style.cssText =
+ "position:absolute;top:-9999px;width:100px;height:100px;overflow:scroll;scrollbar-gutter:stable";
+ document.body.appendChild(probe);
+ const w = probe.offsetWidth - probe.clientWidth;
+ document.body.removeChild(probe);
+ document.documentElement.style.setProperty("--sbw", `${w}px`);
+ }, []);
+
// Once signed in, drop any leftover ONE-TIME query params from the pre-login flow (?access, ?verify,
// ?reset) — but NOT the nav/filter params, which the logged-in app now reads from and keeps in the
// URL. Gated on auth so the logged-out Welcome page still reads its params first.
diff --git a/frontend/src/components/Feed.tsx b/frontend/src/components/Feed.tsx
index d9358ab2..2d46c475 100644
--- a/frontend/src/components/Feed.tsx
+++ b/frontend/src/components/Feed.tsx
@@ -41,6 +41,7 @@ import { PageToolbar } from "./PageShell";
import { useWizard } from "./WizardProvider";
import SortControl from "./SortControl";
import FilterSelect from "./FilterSelect";
+import ItemCount from "./ItemCount";
import ViewSwitcher from "./ViewSwitcher";
import VirtualFeed from "./VirtualFeed";
// Lazy: the in-app YouTube player (IFrame API) loads only when a video is first opened.
@@ -570,6 +571,9 @@ export default function Feed({
const toolbar = (
+ {/* The item count is the fixed leading element — a fixed-width pill so a changing number
+ never shoves the controls beside it (see ItemCount). */}
+
{/* Watch state is single-select (always exactly one); content types are an independent
multi-select. Both compress to one labeled dropdown pill via FilterSelect. */}
-
-
- setFilters({
- ...filters,
- librarySource: next[0] as FeedFilters["librarySource"],
- })
- }
- />
- >
- )}
-
-
- {summaryQuery.data
- ? t("feed.videoCount", {
- count: summaryQuery.data.count,
- formattedCount: summaryQuery.data.count.toLocaleString(),
+
+ setFilters({
+ ...filters,
+ librarySource: next[0] as FeedFilters["librarySource"],
})
- : " "}
-
-
+ }
+ />
+ )}
+
{search}
{/* Library scope (movie/show/both) sits right of the search box on Plex — relocated here from
diff --git a/frontend/src/components/ItemCount.tsx b/frontend/src/components/ItemCount.tsx
new file mode 100644
index 00000000..58f07710
--- /dev/null
+++ b/frontend/src/components/ItemCount.tsx
@@ -0,0 +1,35 @@
+import { useTranslation } from "react-i18next";
+import { Layers } from "lucide-react";
+
+// At a million and above we stop printing the exact figure — six digits is the readable ceiling, so
+// the pill shows "1M+" and the tooltip carries the precise number.
+const OVERFLOW_AT = 1_000_000;
+
+// A compact, FIXED-WIDTH count pill for a toolbar: the filtered item count in the same glass pill as
+// the filter dropdowns, minus the chevron and the interactivity. The fixed width is the point — a
+// changing number must never shove the controls beside it, so the box is sized to the widest value
+// ("999,999 items") and the content is centred within it. A count of a million or more collapses to
+// "1M+", with the exact figure in the title tooltip. Deliberately generic ("item(s)", dynamic plural)
+// so any surface can reuse it for whatever it counts.
+export default function ItemCount({ count }: { count?: number }) {
+ const { t } = useTranslation();
+ // `count` is undefined only on a first, un-cached load — keep the fixed box (nothing shifts), just
+ // show the icon until the number arrives.
+ const label =
+ count == null
+ ? ""
+ : t("common.itemCount", {
+ count,
+ formattedCount: count >= OVERFLOW_AT ? "1M+" : count.toLocaleString(),
+ });
+ return (
+
+
+ {label}
+
+ );
+}
diff --git a/frontend/src/components/PageShell.tsx b/frontend/src/components/PageShell.tsx
index 210e3c75..ad7548ff 100644
--- a/frontend/src/components/PageShell.tsx
+++ b/frontend/src/components/PageShell.tsx
@@ -103,7 +103,9 @@ export default function PageShell({
className={`flex-1 min-w-0 min-h-0 flex flex-col ${header ? "pt-(--hdr-h)" : ""}`}
>
{banners}
-
+ {/* pr-[--sbw]: the toolbar band isn't the scroller, so without this it would run the
+ scrollbar-gutter's width past the scrolled cards on the right. Pad it back to card width. */}
+
{children}
diff --git a/frontend/src/i18n/locales/en/common.json b/frontend/src/i18n/locales/en/common.json
index f476fe5f..2b1a652d 100644
--- a/frontend/src/i18n/locales/en/common.json
+++ b/frontend/src/i18n/locales/en/common.json
@@ -12,6 +12,8 @@
"loading": "Loading…",
"language": "Language",
"somethingWrong": "Something went wrong.",
+ "itemCount_one": "{{formattedCount}} item",
+ "itemCount_other": "{{formattedCount}} items",
"accessRequestsTitle": "Access requests",
"accessRequestsMessage": "{{count}} pending — review them on the Users page.",
"accessRequestsReview": "Review",
diff --git a/frontend/src/i18n/locales/hu/common.json b/frontend/src/i18n/locales/hu/common.json
index b40d4281..32f80ee7 100644
--- a/frontend/src/i18n/locales/hu/common.json
+++ b/frontend/src/i18n/locales/hu/common.json
@@ -12,6 +12,8 @@
"loading": "Betöltés…",
"language": "Nyelv",
"somethingWrong": "Hiba történt.",
+ "itemCount_one": "{{formattedCount}} elem",
+ "itemCount_other": "{{formattedCount}} elem",
"accessRequestsTitle": "Hozzáférési kérések",
"accessRequestsMessage": "{{count}} függőben — nézd át a Felhasználók oldalon.",
"accessRequestsReview": "Áttekintés",
diff --git a/frontend/src/index.css b/frontend/src/index.css
index d7769c43..c90dfd05 100644
--- a/frontend/src/index.css
+++ b/frontend/src/index.css
@@ -98,6 +98,12 @@
place since App and Playlists both reference it. */
--hdr-h: 4.25rem;
+ /* Width of the app scroller's scrollbar gutter (scrollbar-gutter: stable reserves it on the right).
+ Measured once at startup (App) and written here; the floating header and the toolbar band pad
+ their right edge by it so the fixed chrome lines up with the scrolled cards, not 15px past them.
+ Defaults to 0 for overlay-scrollbar platforms where the gutter is empty. */
+ --sbw: 0px;
+
/* ===== Glass tunables — every value that materially drives the look lives here so it can be
tuned in ONE place. ===== */
/* GLOBAL baseline = "Adaptive": solid-enough dark glass that reads well on content-less chrome