Files
jellyfin/web/ServerSwitcher.tsx
officer a7f1a71785 jellyfin, extracted from the platform as a plugin
Eighth plugin out, and the second resurrection: like soulseek, hono.ts carried
the router import and its mount commented out since 2026-08-13, with the
permission reduced to api: [], so the server booted clean while every panel
request 404'd. jellyfin_servers was also absent from the drizzle barrel, so
bun db:push would have dropped it.

Easier than soulseek in one specific way: mountPrefix derives /jellyfin from
the appName, which is exactly what the router already hardcoded, so no frontend
call site needed repointing. Soulseek needed 47 because its appName and its old
prefix differed. The frontend also already routed through one helper, so there
were five call sites rather than forty-seven.

Self-contained, unlike transmission and soulseek: jellyfin_servers is a
registry this plugin owns outright — label, URL, encrypted token, one active —
because more than one Jellyfin is a thing someone has, and more than one
Transmission is not.

Carries its own .prettierignore for the vendored hls.mjs, rather than leaving a
stale rule behind in the platform naming a path that no longer exists.
2026-08-19 03:17:32 +00:00

82 lines
3.2 KiB
TypeScript

import { Link } from 'react-router';
import { Check, ChevronsUpDown, Loader2, Settings2 } from 'lucide-react';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { jellyfinSectionPath } from './shared';
import { useJellyfinHealth, useJellyfinServerActions, useJellyfinServers } from './useJellyfinData';
// Which Jellyfin you are looking at, and how to change it.
//
// This machine really does run several instances, so the switcher is not hypothetical — but only the video
// one is expected here; the album and DJ-set instances are the music player's business, and the sidecar
// already drops music libraries from every listing.
//
// Switching invalidates the whole ['jellyfin'] key, because it changes the answer to every query in the
// workspace without changing any of their inputs. Anything less and the previous server's posters stay up.
export const ServerSwitcher = () => {
const { data } = useJellyfinServers();
const { data: health } = useJellyfinHealth();
const { activate } = useJellyfinServerActions();
const servers = data?.servers ?? [];
const active = servers.find((server) => server.isActive) ?? null;
// Before the registry answers, fall back to health — it is the query that was already driving this line.
const status = active?.label ?? (health?.ok ? (health.serverName ?? 'connected') : 'not connected');
if (servers.length < 2) {
return (
<Link
to={jellyfinSectionPath('settings')}
className="block truncate text-xs text-muted-foreground hover:text-foreground"
>
{status}
</Link>
);
}
return (
<DropdownMenu>
<DropdownMenuTrigger className="flex w-full items-center gap-1 text-xs text-muted-foreground hover:text-foreground">
<span className="truncate">{status}</span>
{activate.isPending ? (
<Loader2 className="h-3 w-3 shrink-0 animate-spin" />
) : (
<ChevronsUpDown className="h-3 w-3 shrink-0" />
)}
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-64">
<DropdownMenuLabel className="text-xs font-normal text-muted-foreground">Jellyfin servers</DropdownMenuLabel>
{servers.map((server) => (
<DropdownMenuItem
key={server.id}
disabled={server.isActive || activate.isPending}
onSelect={() => void activate.mutateAsync(server.id).catch(() => undefined)}
className="gap-2"
>
<Check className={`h-3.5 w-3.5 shrink-0 ${server.isActive ? 'opacity-100' : 'opacity-0'}`} />
<span className="min-w-0 flex-1">
<span className="block truncate">{server.label}</span>
<span className="block truncate text-[11px] text-muted-foreground">{server.url}</span>
</span>
</DropdownMenuItem>
))}
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Link to={jellyfinSectionPath('settings')} className="gap-2">
<Settings2 className="h-3.5 w-3.5" />
Manage servers
</Link>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
};