Parcourir la source

feat: add Jellyfin Quick Connect settings integration

Krasimir Kazakov il y a 6 jours
Parent
commit
eadfcff4ac

+ 5 - 4
AGENTS.md

@@ -35,11 +35,11 @@ ADB lives at `/opt/android-sdk/platform-tools/adb`; the SDK is at `/opt/android-
 
 - `lib/core/` — theme tokens (`AppColors`, `AccentChoice`), `AppTheme`, `ThemeController`, `DisplayController` (keep-awake), reusable widgets.
 - `lib/data/models/` — `AppEntry`, `SlotType`, `SlotAssignment`, `SystemStatus`, `VolumeInfo`, `MediaPlayback`, OBD/weather models.
-- `lib/data/services/` — `NativeBridge`, `TelemetryBridge`, `WeatherService`, `UpdateService`, `PinService`, secure-store adapter.
-- `lib/data/repositories/` — `SettingsRepository`, the SSOT for theme, night window, slot assignments, visibility flags, pinned apps, onboarding time, and speed/temperature choices.
+- `lib/data/services/` — `NativeBridge`, `TelemetryBridge`, `WeatherService`, `UpdateService`, `PinService`, Jellyfin Quick Connect gateway, secure-store adapter.
+- `lib/data/repositories/` — `SettingsRepository`, the SSOT for theme, night window, slot assignments, visibility flags, pinned apps, onboarding time, speed/temperature choices, and the Jellyfin server URL/enable flag.
 - `lib/features/home/` — home screen, `LauncherController`, `SystemController`, `ObdController`, `WeatherController`, `GpsSpeedController`, `TelemetrySettingsController` (speed/temperature visibility and sources), equalizer, night overlay.
 - `lib/features/apps/` — All Apps screen and app-picker sheet.
-- `lib/features/settings/` — General, Media, OBD-II, Speed, Temperature, Security and Updates cards.
+- `lib/features/settings/` — General, Media, Jellyfin, OBD-II, Speed, Temperature, Security and Updates cards; `JellyfinController` owns the Quick Connect session and its active/inactive state.
 - `lib/features/lock/` — PIN pad, lock screen, cold-start `LockController`.
 - `android/app/src/main/kotlin/com/wasp/cardash_launcher/` — `MainActivity`, `LauncherBridge`, `SystemBridge`, `TelemetryBridge`, `ObdService` (Classic SPP ELM327), `MediaBridge`, `CardashNotificationListener`; `SystemBridge` also dispatches media keys and applies the keep-awake flag.
 - `tool/` — emulator/install/check scripts. `docs/` — implementation plan and design notes.
@@ -77,7 +77,7 @@ notifyListeners();
 - NEVER change the application ID, HOME intent filter, landscape lock, or minSdk without human confirmation.
 - NEVER re-lock on resume, screen-off, or route changes; no "Lock now" action. PIN is requested only on cold start.
 - NEVER store, log, or display a plaintext PIN.
-- INTERNET, location and Bluetooth permissions are approved only for OBD, Open-Meteo weather and the GPS speed fallback; notification access only for MediaSession title/progress; no notification content, scanning or background location.
+- INTERNET, location and Bluetooth permissions are approved only for OBD, Open-Meteo weather, the GPS speed fallback, the Jellyfin server the user configures, and the public release check; notification access only for MediaSession title/progress; no notification content, scanning or background location.
 - NEVER commit `android/local.properties`, keystores, build outputs, APKs, or secrets.
 - REQUIRE human confirmation before changing signing configuration, package visibility permissions, or the repository remote.
 
@@ -100,6 +100,7 @@ notifyListeners();
 - Night mode is a full-screen black overlay with a dim clock. Long-press only on empty background; tap anywhere to dismiss. It does not change brightness or add a wake lock.
 - Speed is shown only when `TelemetrySettingsController.speedEnabled` is on. OBD-II is preferred when connected and fresh; the GPS fallback is used when OBD speed is off or unavailable. GPS starts only with already-granted fine location and GPS; never prompt from that path, and clamp stationary noise below 2 km/h to 0.
 - Temperature is shown only when `TelemetrySettingsController.temperatureEnabled` is on. OBD ambient is preferred when connected and fresh; Open-Meteo/location weather is the fallback, and the source switch only appears while an OBD-II adapter is selected.
+- Jellyfin lives only in Settings → Jellyfin. Quick Connect is the only sign-in path: the user enters the code in Jellyfin, and CarDash keeps the returned access token in secure storage and reuses it on the next start. A rejected token or server error disables the integration, clears the token and shows **Pair again**; an unreachable server keeps the token and retries with 5–60 s backoff, so never clear the token for a network error. Use `JellyfinController.connected` as the active/inactive state for later features; do not add a username/password form.
 - Updates use a non-blocking public Gogs release check. The Update action downloads the APK to cache and opens Android’s package installer; never install silently. REQUEST_INSTALL_PACKAGES + one-time unknown-apps permission is required. Show the yellow dot on the Settings gear whenever `UpdateController.updateAvailable` is true.
 - ADB installs: always try at most twice per connected device. If both attempts fail, stop silently rather than looping or switching install methods.
 - OBD is Bluetooth Classic SPP, bonded-only and read-only, and still needs a real-car test; weather uses Open-Meteo and one-shot location and stores no coordinates.

+ 29 - 5
README.md

@@ -80,6 +80,12 @@ live vehicle data.
   play/pause button. The song title and artist scroll along the bottom; paused
   audio stops the scroll and holds its position. When nothing is playing it
   reads **Nothing playing**. The volume slider, −/+, and Mute pill remain.
+- **Jellyfin (Settings only)** — optional music-server integration in
+  Settings → Jellyfin. Turn it on, enter the server URL and pair it with a
+  Quick Connect code; the saved token is reused on later starts. The card shows
+  connected, offline and error states. A rejected token is cleared and asks for
+  pairing again; a missing network pauses the connection and retries
+  automatically. Nothing on Home uses it yet.
 - **Update check** — on startup, CarDash queries the public Gogs release list
   without blocking the dashboard. Settings → Updates shows the current version,
   a Check button, and an Update action that downloads the APK and opens Android’s
@@ -122,7 +128,8 @@ live vehicle data.
 - **Standard Android home role** — no overlay, kiosk or root.
 - **Android 10+**, landscape-only, tablet-first (verified at 1280×800).
 - No accounts, no telemetry, no T&C screen. Network access is used only for the
-  optional Open-Meteo temperature fallback.
+  optional Open-Meteo temperature fallback, the Jellyfin server you configure,
+  and the public release check.
 
 ## Requirements
 
@@ -238,6 +245,22 @@ the app resumes.
 - Without access the ring stays empty and the card shows a generic Playing /
   Nothing playing state.
 
+**Jellyfin**
+- One switch enables the optional integration. While it is off, CarDash never
+  contacts a Jellyfin server.
+- When enabled and not connected, enter the server URL (`https://host`,
+  `host:port` or a path such as `https://host/jellyfin`) and press **Connect**.
+- Without a saved pairing, the card shows a **Quick Connect** code. Open
+  Jellyfin, choose Quick Connect, enter the code and approve it there.
+- A saved pairing is reused on later starts; the card shows the server name and
+  the signed-in user. **Remove pairing** clears it and turns the switch off.
+- A rejected token turns the switch off, clears the token and offers
+  **Pair again**.
+- Without a network the card shows **No internet — retrying** and retries with
+  backoff until the server is reachable again.
+- Nothing on Home uses the connection yet; the active state is exposed for a
+  later feature.
+
 **OBD-II**
 - Adapter dropdown: None plus paired Bluetooth Classic devices that look like
   OBD-II adapters. **Show all paired devices** reveals phones, headsets and
@@ -421,13 +444,14 @@ Android 15 AVD is left running headlessly after development; use
   Weather fallback uses Open-Meteo and the device location; it requires location
   permission and network access, and hides the row when unavailable.
 - **Location data is not stored.** Only the selected adapter address, the
-  speed/temperature choices and the weather preference are persisted.
+  speed/temperature choices, the weather preference and the Jellyfin URL are
+  persisted. The Jellyfin access token is kept in Keystore-backed storage.
 - **`QUERY_ALL_PACKAGES`** is used to list all launchable apps. It is reasonable
   for a launcher but requires a Play policy declaration if the app is ever
   distributed; this is a local/sideload build for now.
-- **Network access** is used for the optional Open-Meteo temperature fallback and
-  the Gogs release check. There are no accounts, ads, telemetry or silent
-  auto-updates.
+- **Network access** is used for the optional Open-Meteo temperature fallback,
+  the Jellyfin server you configure and the Gogs release check. There are no
+  accounts, ads, telemetry or silent auto-updates.
 - The launcher cannot replace Android's lock screen. The PIN does not protect a
   running session, by design.
 

+ 17 - 4
docs/IMPLEMENTATION_PLAN.md

@@ -108,9 +108,11 @@ Flutter
     GpsSpeedController  -> GPS speed fallback (permission already granted)
     TelemetrySettingsController -> speed/temperature visibility + sources
     UpdateController    -> public Gogs release check + release-page link
+    JellyfinController  -> Quick Connect pairing, token reuse, retry/offline state
     HomeScreen          -> NightModeOverlay on empty-background long press
   AllAppsScreen / AppPickerSheet
-  SettingsScreen -> General / OBD-II / Speed / Temperature / Security
+  SettingsScreen -> General / Media / Jellyfin / OBD-II / Speed / Temperature /
+                    Security / Updates
   PinSetupScreen / LockScreen -> LockController -> PinService
 Android (Kotlin)
   MainActivity -> keep-screen-on, volume keys, permission forwarding
@@ -123,14 +125,17 @@ Android (Kotlin)
 
 - `SettingsRepository` is the only persistence path for theme, night window,
   slot visibility/assignments, pinned apps, onboarding time, speed/temperature
-  visibility and source choices. `flutter_secure_storage` holds the salted PIN
-  hash.
+  visibility and source choices, and the Jellyfin switch/URL. The Jellyfin
+  access token and the salted PIN hash live in `flutter_secure_storage`.
 - Package enumeration and icon rendering happen on a worker thread in Kotlin and
   are returned as PNG bytes.
 - Timers for volume/status are stopped when the app leaves the foreground.
 - The PIN check is awaited before `runApp`, so there is no dashboard flash on a
   locked cold start.
-- Network access is limited to the optional Open-Meteo weather fallback; no telemetry.
+- Jellyfin uses Quick Connect only. The pairing check runs after `runApp`, and a
+  missing network retries with backoff instead of blocking the dashboard.
+- Network access is limited to the optional Open-Meteo weather fallback, the
+  user-configured Jellyfin server and the Gogs release check; no telemetry.
 
 ## 4. Milestones
 
@@ -222,6 +227,7 @@ Android (Kotlin)
 - [x] Onboarding hint is limited to ten minutes after a fresh install; upgraded installs never show it.
 - [x] 66 tests pass, including the onboarding window and source preferences.
 - [x] 1.0.3 universal APK built and published for in-app update.
+- [x] Jellyfin Settings card: enable switch, URL entry, Quick Connect code, saved-token reuse, status, offline retry and token invalidation covered by unit/widget tests.
 - [x] Tablet has the 1.0.0 installer build; unknown-app confirmation is user-controlled.
 - [x] Screenshots in `docs/screenshots/`; README/AGENTS/plan/OBD doc updated.
 
@@ -269,6 +275,13 @@ Android (Kotlin)
 
 ## 8. Change log
 
+- 2026-10-31: Added the optional Settings → Jellyfin integration. A switch and
+  server URL drive the Quick Connect flow; the returned token is kept in secure
+  storage and reused on later starts. The card shows connecting, waiting,
+  connected, offline and error states. A rejected token disables the integration
+  and clears the token; an unreachable server retries with 5–60 s backoff.
+  Nothing on Home consumes the connection yet. The controller exposes
+  `connected` as the active/inactive state for the next step. 84 tests pass.
 - 2026-10-29: Pivoted from an overlay/head-unit chrome design to a
   launcher-native single home page, no top/bottom bars, full-screen external
   apps. Implemented core UI, Kotlin bridges, settings, PIN and documentation.

+ 57 - 0
lib/data/models/jellyfin_models.dart

@@ -0,0 +1,57 @@
+/// Public identity of a Jellyfin server.
+class JellyfinServerInfo {
+  const JellyfinServerInfo({required this.name, required this.version});
+
+  final String name;
+  final String version;
+}
+
+/// A pending Quick Connect request. The code is shown to the user; the secret
+/// is exchanged for an access token once the user approves the device.
+class JellyfinQuickConnect {
+  const JellyfinQuickConnect({required this.code, required this.secret});
+
+  final String code;
+  final String secret;
+}
+
+/// The signed-in Jellyfin user for this device.
+class JellyfinUser {
+  const JellyfinUser({required this.id, required this.name});
+
+  final String id;
+  final String name;
+}
+
+/// An access token plus the user it belongs to.
+class JellyfinSession {
+  const JellyfinSession({
+    required this.accessToken,
+    required this.userId,
+    required this.userName,
+  });
+
+  final String accessToken;
+  final String userId;
+  final String userName;
+}
+
+/// A Jellyfin request failed. [JellyfinUnreachable] means the server could not
+/// be contacted at all; [JellyfinAuthException] means the saved token is no
+/// longer accepted.
+class JellyfinException implements Exception {
+  JellyfinException(this.message);
+
+  final String message;
+
+  @override
+  String toString() => message;
+}
+
+class JellyfinUnreachable extends JellyfinException {
+  JellyfinUnreachable(super.message);
+}
+
+class JellyfinAuthException extends JellyfinException {
+  JellyfinAuthException(super.message);
+}

+ 86 - 0
lib/data/repositories/settings_repository.dart

@@ -1,4 +1,5 @@
 import 'dart:convert';
+import 'dart:math';
 
 import 'package:shared_preferences/shared_preferences.dart';
 
@@ -31,6 +32,12 @@ class SettingsRepository {
   static const _speedUseObdKey = 'carData.speedUseObd';
   static const _temperatureEnabledKey = 'carData.temperatureEnabled';
   static const _temperatureUseObdKey = 'carData.temperatureUseObd';
+  static const _jellyfinEnabledKey = 'jellyfin.enabled';
+  static const _jellyfinServerUrlKey = 'jellyfin.serverUrl';
+  static const _jellyfinDeviceIdKey = 'jellyfin.deviceId';
+  static const _jellyfinServerNameKey = 'jellyfin.serverName';
+  static const _jellyfinUserIdKey = 'jellyfin.userId';
+  static const _jellyfinUserNameKey = 'jellyfin.userName';
 
   ThemePreference get themePreference {
     return ThemePreference.fromStorage(_prefs.getString(_themeKey));
@@ -94,6 +101,29 @@ class SettingsRepository {
   /// OBD-II ambient is preferred when available; weather is the fallback.
   bool get temperatureUseObd => _prefs.getBool(_temperatureUseObdKey) ?? true;
 
+  /// Whether the optional Jellyfin integration is enabled. The access token is
+  /// kept in secure storage; this flag and the URL are safe in preferences.
+  bool get jellyfinEnabled => _prefs.getBool(_jellyfinEnabledKey) ?? false;
+
+  String get jellyfinServerUrl {
+    return _prefs.getString(_jellyfinServerUrlKey)?.trim() ?? '';
+  }
+
+  String? get jellyfinServerName {
+    final value = _prefs.getString(_jellyfinServerNameKey)?.trim();
+    return value == null || value.isEmpty ? null : value;
+  }
+
+  String? get jellyfinUserId {
+    final value = _prefs.getString(_jellyfinUserIdKey)?.trim();
+    return value == null || value.isEmpty ? null : value;
+  }
+
+  String? get jellyfinUserName {
+    final value = _prefs.getString(_jellyfinUserNameKey)?.trim();
+    return value == null || value.isEmpty ? null : value;
+  }
+
   String? get obdAdapterAddress {
     final value = _prefs.getString(_obdAddressKey)?.trim();
     return value == null || value.isEmpty ? null : value;
@@ -136,6 +166,62 @@ class SettingsRepository {
     return _prefs.setBool(_temperatureUseObdKey, enabled);
   }
 
+  Future<void> setJellyfinEnabled(bool enabled) {
+    return _prefs.setBool(_jellyfinEnabledKey, enabled);
+  }
+
+  Future<void> setJellyfinServerUrl(String url) {
+    final value = url.trim();
+    if (value.isEmpty) return _prefs.remove(_jellyfinServerUrlKey);
+    return _prefs.setString(_jellyfinServerUrlKey, value);
+  }
+
+  Future<void> setJellyfinServerName(String? name) {
+    final value = name?.trim();
+    if (value == null || value.isEmpty) {
+      return _prefs.remove(_jellyfinServerNameKey);
+    }
+    return _prefs.setString(_jellyfinServerNameKey, value);
+  }
+
+  Future<void> setJellyfinUserName(String? name) {
+    final value = name?.trim();
+    if (value == null || value.isEmpty) {
+      return _prefs.remove(_jellyfinUserNameKey);
+    }
+    return _prefs.setString(_jellyfinUserNameKey, value);
+  }
+
+  Future<void> setJellyfinSession({
+    required String userId,
+    required String userName,
+  }) async {
+    await _prefs.setString(_jellyfinUserIdKey, userId);
+    await _prefs.setString(_jellyfinUserNameKey, userName);
+  }
+
+  /// Drops the cached profile but keeps the device id and server URL so the
+  /// user can pair again without retyping the address.
+  Future<void> clearJellyfinSession() async {
+    await _prefs.remove(_jellyfinServerNameKey);
+    await _prefs.remove(_jellyfinUserIdKey);
+    await _prefs.remove(_jellyfinUserNameKey);
+  }
+
+  /// A stable per-install device id for Quick Connect. Jellyfin shows it in
+  /// its device list, so it must not change between launches.
+  Future<String> ensureJellyfinDeviceId() async {
+    final existing = _prefs.getString(_jellyfinDeviceIdKey)?.trim();
+    if (existing != null && existing.isNotEmpty) return existing;
+    final random = Random.secure();
+    final id = List.generate(
+      16,
+      (_) => random.nextInt(256).toRadixString(16).padLeft(2, '0'),
+    ).join();
+    await _prefs.setString(_jellyfinDeviceIdKey, id);
+    return id;
+  }
+
   Future<void> setHomeRoleHintShown(bool shown) {
     return _prefs.setBool(_homeRoleHintShownKey, shown);
   }

+ 284 - 0
lib/data/services/jellyfin_service.dart

@@ -0,0 +1,284 @@
+import 'dart:async';
+import 'dart:convert';
+import 'dart:io';
+
+import '../../core/app_version.dart';
+import '../models/jellyfin_models.dart';
+
+/// The small part of the Jellyfin API CarDash needs for Quick Connect.
+abstract class JellyfinGateway {
+  Future<JellyfinServerInfo> serverInfo(Uri baseUrl);
+
+  Future<JellyfinQuickConnect> startQuickConnect(
+    Uri baseUrl, {
+    required String deviceId,
+  });
+
+  Future<bool> isQuickConnectApproved(Uri baseUrl, String secret);
+
+  Future<JellyfinSession> authenticateQuickConnect(
+    Uri baseUrl,
+    String secret, {
+    required String deviceId,
+  });
+
+  Future<JellyfinUser> currentUser(Uri baseUrl, String token);
+
+  void close() {}
+}
+
+/// Talks to Jellyfin over HTTPS using the documented Quick Connect endpoints.
+/// The access token is never logged; it only travels in the Authorization
+/// header and is stored by the caller in secure storage.
+class HttpJellyfinGateway implements JellyfinGateway {
+  HttpJellyfinGateway({
+    HttpClient Function()? clientFactory,
+    this.timeout = const Duration(seconds: 10),
+  }) : _clientFactory = clientFactory ?? HttpClient.new;
+
+  final HttpClient Function() _clientFactory;
+  final Duration timeout;
+  HttpClient? _client;
+
+  HttpClient get _http {
+    return _client ??= _clientFactory()
+      ..connectionTimeout = timeout
+      ..idleTimeout = const Duration(seconds: 15);
+  }
+
+  @override
+  Future<JellyfinServerInfo> serverInfo(Uri baseUrl) async {
+    final response = await _send('GET', baseUrl.resolve('System/Info/Public'));
+    if (response.status != HttpStatus.ok) {
+      throw _statusError(response, notFound: 'No Jellyfin server found there.');
+    }
+    final json = _decode(response.body);
+    final product = (json['ProductName'] as String?)?.toLowerCase();
+    if (product != null && !product.contains('jellyfin')) {
+      throw JellyfinException('That URL is not a Jellyfin server.');
+    }
+    final name = (json['ServerName'] as String?)?.trim();
+    return JellyfinServerInfo(
+      name: name == null || name.isEmpty ? 'Jellyfin' : name,
+      version: (json['Version'] as String?) ?? '',
+    );
+  }
+
+  @override
+  Future<JellyfinQuickConnect> startQuickConnect(
+    Uri baseUrl, {
+    required String deviceId,
+  }) async {
+    final response = await _send(
+      'POST',
+      baseUrl.resolve('QuickConnect/Initiate'),
+      deviceId: deviceId,
+    );
+    if (response.status != HttpStatus.ok) {
+      if (response.status == HttpStatus.unauthorized ||
+          response.status == HttpStatus.forbidden ||
+          response.status == HttpStatus.notFound) {
+        throw JellyfinException(
+          'Quick Connect is disabled on this server. Enable it in Jellyfin '
+          'settings and try again.',
+        );
+      }
+      throw _statusError(response);
+    }
+    final json = _decode(response.body);
+    final code = (json['Code'] as String?)?.trim();
+    final secret = (json['Secret'] as String?)?.trim();
+    if (code == null || code.isEmpty || secret == null || secret.isEmpty) {
+      throw JellyfinException(
+        'The server did not return a Quick Connect code.',
+      );
+    }
+    return JellyfinQuickConnect(code: code, secret: secret);
+  }
+
+  @override
+  Future<bool> isQuickConnectApproved(Uri baseUrl, String secret) async {
+    final uri = baseUrl
+        .resolve('QuickConnect/Connect')
+        .replace(queryParameters: {'secret': secret});
+    final response = await _send('GET', uri);
+    if (response.status != HttpStatus.ok) {
+      if (response.status == HttpStatus.notFound ||
+          response.status == HttpStatus.unauthorized) {
+        throw JellyfinException('The Quick Connect code expired.');
+      }
+      throw _statusError(response);
+    }
+    return _decode(response.body)['Authenticated'] == true;
+  }
+
+  @override
+  Future<JellyfinSession> authenticateQuickConnect(
+    Uri baseUrl,
+    String secret, {
+    required String deviceId,
+  }) async {
+    final response = await _send(
+      'POST',
+      baseUrl.resolve('Users/AuthenticateWithQuickConnect'),
+      deviceId: deviceId,
+      body: jsonEncode({'Secret': secret}),
+      contentType: ContentType.json,
+    );
+    if (response.status == HttpStatus.unauthorized ||
+        response.status == HttpStatus.forbidden) {
+      throw JellyfinAuthException('The Quick Connect approval was rejected.');
+    }
+    if (response.status != HttpStatus.ok) {
+      throw _statusError(response);
+    }
+    final json = _decode(response.body);
+    final token = (json['AccessToken'] as String?)?.trim();
+    final user = json['User'];
+    if (token == null || token.isEmpty || user is! Map) {
+      throw JellyfinException('The server did not return an access token.');
+    }
+    final id = (user['Id'] as String?)?.trim();
+    final name = (user['Name'] as String?)?.trim();
+    if (id == null || id.isEmpty || name == null || name.isEmpty) {
+      throw JellyfinException('The server did not return the user profile.');
+    }
+    return JellyfinSession(accessToken: token, userId: id, userName: name);
+  }
+
+  @override
+  Future<JellyfinUser> currentUser(Uri baseUrl, String token) async {
+    final response = await _send(
+      'GET',
+      baseUrl.resolve('Users/Me'),
+      token: token,
+    );
+    if (response.status == HttpStatus.unauthorized ||
+        response.status == HttpStatus.forbidden) {
+      throw JellyfinAuthException('The saved Jellyfin pairing is not valid.');
+    }
+    if (response.status != HttpStatus.ok) {
+      throw _statusError(response);
+    }
+    final json = _decode(response.body);
+    final id = (json['Id'] as String?)?.trim();
+    final name = (json['Name'] as String?)?.trim();
+    if (id == null || id.isEmpty || name == null || name.isEmpty) {
+      throw JellyfinException('The server returned an unexpected profile.');
+    }
+    return JellyfinUser(id: id, name: name);
+  }
+
+  @override
+  void close() {
+    _client?.close(force: true);
+    _client = null;
+  }
+
+  Future<_JellyfinResponse> _send(
+    String method,
+    Uri uri, {
+    String? deviceId,
+    String? token,
+    String? body,
+    ContentType? contentType,
+  }) async {
+    try {
+      final request = await _http.openUrl(method, uri).timeout(timeout);
+      request.headers.set(
+        HttpHeaders.authorizationHeader,
+        _authorization(deviceId ?? 'cardash', token: token),
+      );
+      request.headers.set(HttpHeaders.acceptHeader, 'application/json');
+      if (body != null) {
+        request.headers.contentType = contentType ?? ContentType.text;
+        request.write(body);
+      }
+      final response = await request.close().timeout(timeout);
+      final text = await response
+          .transform(utf8.decoder)
+          .join()
+          .timeout(timeout);
+      return _JellyfinResponse(status: response.statusCode, body: text);
+    } on JellyfinException {
+      rethrow;
+    } on SocketException {
+      throw JellyfinUnreachable('Could not reach the server.');
+    } on HandshakeException {
+      throw JellyfinUnreachable('Could not establish a secure connection.');
+    } on TimeoutException {
+      throw JellyfinUnreachable('The server did not respond.');
+    } on HttpException {
+      throw JellyfinUnreachable('Could not reach the server.');
+    } on FormatException {
+      throw JellyfinException('The server returned an unexpected response.');
+    }
+  }
+
+  String _authorization(String deviceId, {String? token}) {
+    final parts = [
+      'Client="CarDash"',
+      'Device="CarDash"',
+      'DeviceId="$deviceId"',
+      'Version="$appVersion"',
+      if (token != null) 'Token="$token"',
+    ];
+    return 'MediaBrowser ${parts.join(', ')}';
+  }
+
+  Map<String, dynamic> _decode(String body) {
+    final decoded = jsonDecode(body);
+    if (decoded is! Map<String, dynamic>) {
+      throw JellyfinException('The server returned an unexpected response.');
+    }
+    return decoded;
+  }
+
+  JellyfinException _statusError(
+    _JellyfinResponse response, {
+    String? notFound,
+  }) {
+    if (response.status == HttpStatus.unauthorized ||
+        response.status == HttpStatus.forbidden) {
+      return JellyfinAuthException('The server rejected the credentials.');
+    }
+    if (response.status == HttpStatus.notFound && notFound != null) {
+      return JellyfinException(notFound);
+    }
+    return JellyfinException('The server returned ${response.status}.');
+  }
+}
+
+class _JellyfinResponse {
+  const _JellyfinResponse({required this.status, required this.body});
+
+  final int status;
+  final String body;
+}
+
+/// Accepts `host`, `host:8096`, `https://host/jellyfin` and similar. Missing
+/// schemes default to HTTPS; the result always ends in `/` so relative API
+/// paths resolve under any configured path prefix.
+Uri? normalizeJellyfinUrl(String input) {
+  var value = input.trim();
+  if (value.isEmpty) return null;
+  if (!value.contains('://')) value = 'https://$value';
+  final uri = Uri.tryParse(value);
+  if (uri == null || uri.host.isEmpty) return null;
+  if (uri.scheme != 'http' && uri.scheme != 'https') return null;
+  final path = uri.path.endsWith('/') ? uri.path : '${uri.path}/';
+  return Uri(
+    scheme: uri.scheme,
+    host: uri.host,
+    port: uri.hasPort ? uri.port : null,
+    path: path,
+  );
+}
+
+/// The stored form of [normalizeJellyfinUrl], without the trailing slash.
+String? displayJellyfinUrl(String input) {
+  final uri = normalizeJellyfinUrl(input);
+  if (uri == null) return null;
+  final value = uri.toString();
+  return value.endsWith('/') ? value.substring(0, value.length - 1) : value;
+}

+ 541 - 0
lib/features/settings/jellyfin_controller.dart

@@ -0,0 +1,541 @@
+import 'dart:async';
+
+import 'package:flutter/foundation.dart';
+
+import '../../data/models/jellyfin_models.dart';
+import '../../data/repositories/settings_repository.dart';
+import '../../data/services/jellyfin_service.dart';
+import '../../data/services/secure_store.dart';
+
+enum JellyfinStatus {
+  disabled,
+  needsSetup,
+  connecting,
+  pairing,
+  connected,
+  offline,
+  error,
+}
+
+/// Owns the optional Jellyfin session. Quick Connect is the only sign-in path:
+/// the user approves a code in Jellyfin and CarDash keeps the returned token in
+/// secure storage. A rejected token disables the integration and clears it so
+/// the user has to pair again; an unreachable server is treated as a temporary
+/// outage and retried with backoff.
+class JellyfinController extends ChangeNotifier {
+  JellyfinController(
+    this._settings,
+    this._gateway,
+    this._secureStore, {
+    this.pollInterval = const Duration(seconds: 3),
+    this.pairingLifetime = const Duration(minutes: 10),
+    this.healthInterval = const Duration(minutes: 1),
+    List<Duration>? retryDelays,
+  }) : retryDelays =
+           retryDelays ??
+           const [
+             Duration(seconds: 5),
+             Duration(seconds: 10),
+             Duration(seconds: 20),
+             Duration(seconds: 30),
+             Duration(seconds: 60),
+           ];
+
+  static const tokenKey = 'jellyfin.accessToken';
+
+  final SettingsRepository _settings;
+  final JellyfinGateway _gateway;
+  final SecureStore _secureStore;
+  final Duration pollInterval;
+  final Duration pairingLifetime;
+  final Duration healthInterval;
+  final List<Duration> retryDelays;
+
+  bool _enabled = false;
+  bool _needsRepair = false;
+  bool _busy = false;
+  bool _polling = false;
+  bool _started = false;
+  bool _disposed = false;
+  JellyfinStatus _status = JellyfinStatus.disabled;
+  String _serverUrl = '';
+  String? _serverName;
+  String? _userName;
+  String? _error;
+  String? _code;
+  String? _secret;
+  String? _token;
+  String _deviceId = '';
+  int _generation = 0;
+  int _retryAttempt = 0;
+  Timer? _pollTimer;
+  Timer? _pairingTimer;
+  Timer? _healthTimer;
+  Timer? _retryTimer;
+
+  bool get enabled => _enabled;
+
+  bool get connected => _status == JellyfinStatus.connected;
+
+  bool get busy => _busy;
+
+  bool get pairing => _status == JellyfinStatus.pairing;
+
+  bool get needsRepair => _needsRepair;
+
+  bool get hasServerUrl => _serverUrl.isNotEmpty;
+
+  JellyfinStatus get status => _status;
+
+  String get serverUrl => _serverUrl;
+
+  String? get serverName => _serverName;
+
+  String? get userName => _userName;
+
+  String? get error => _error;
+
+  String? get quickConnectCode => _code;
+
+  String get statusLabel {
+    if (!_enabled) {
+      return _needsRepair ? 'Pairing needed' : 'Off';
+    }
+    switch (_status) {
+      case JellyfinStatus.disabled:
+        return 'Off';
+      case JellyfinStatus.needsSetup:
+        return 'Not connected';
+      case JellyfinStatus.connecting:
+        return 'Connecting…';
+      case JellyfinStatus.pairing:
+        return 'Waiting for approval';
+      case JellyfinStatus.connected:
+        return _serverName == null ? 'Connected' : 'Connected to $_serverName';
+      case JellyfinStatus.offline:
+        return 'No internet — retrying';
+      case JellyfinStatus.error:
+        return 'Not connected';
+    }
+  }
+
+  /// Loads persisted state. Does not touch the network; call [start] once the
+  /// app is running so a cold start is never delayed by Jellyfin.
+  Future<void> initialize() async {
+    _enabled = _settings.jellyfinEnabled;
+    _serverUrl = _settings.jellyfinServerUrl;
+    _serverName = _settings.jellyfinServerName;
+    _userName = _settings.jellyfinUserName;
+    _deviceId = await _settings.ensureJellyfinDeviceId();
+    _token = await _secureStore.read(tokenKey);
+    if (!_enabled) {
+      _status = JellyfinStatus.disabled;
+      return;
+    }
+    if (_serverUrl.isEmpty || _token == null) {
+      _status = JellyfinStatus.needsSetup;
+      return;
+    }
+    _status = JellyfinStatus.connecting;
+  }
+
+  /// Starts the first health check. Safe to call more than once.
+  void start() {
+    if (_started || _disposed) return;
+    _started = true;
+    if (_enabled && _token != null && _serverUrl.isNotEmpty) {
+      unawaited(_validate());
+    }
+  }
+
+  Future<void> setEnabled(bool value) async {
+    if (_disposed || value == _enabled) return;
+    if (!value) {
+      _generation++;
+      _cancelTimers();
+      _enabled = false;
+      _needsRepair = false;
+      _busy = false;
+      _code = null;
+      _secret = null;
+      _error = null;
+      _status = JellyfinStatus.disabled;
+      await _settings.setJellyfinEnabled(false);
+      _notify();
+      return;
+    }
+
+    _enabled = true;
+    _needsRepair = false;
+    _error = null;
+    await _settings.setJellyfinEnabled(true);
+    if (_serverUrl.isEmpty) {
+      _status = JellyfinStatus.needsSetup;
+      _notify();
+      return;
+    }
+    if (_token != null) {
+      await _validate();
+    } else {
+      _status = JellyfinStatus.needsSetup;
+      _notify();
+    }
+  }
+
+  /// Saves the URL and connects. With a saved token the session is reused;
+  /// otherwise a Quick Connect code is requested.
+  Future<void> connect(String rawUrl) async {
+    final normalized = displayJellyfinUrl(rawUrl);
+    if (normalized == null) {
+      _error = 'Enter a valid http or https server URL.';
+      _status = _enabled ? JellyfinStatus.needsSetup : JellyfinStatus.disabled;
+      _notify();
+      return;
+    }
+    final changed = _serverUrl.isNotEmpty && _serverUrl != normalized;
+    _serverUrl = normalized;
+    _error = null;
+    await _settings.setJellyfinServerUrl(normalized);
+    if (!_enabled) {
+      _enabled = true;
+      await _settings.setJellyfinEnabled(true);
+    }
+    if (changed) {
+      await _clearSavedSession();
+      _serverName = null;
+      _userName = null;
+    }
+    if (_token != null) {
+      await _validate();
+    } else {
+      await _beginQuickConnect();
+    }
+  }
+
+  /// Re-enables Jellyfin and starts pairing again after the token was rejected.
+  Future<void> pairAgain() async {
+    _needsRepair = false;
+    _error = null;
+    if (!_enabled) {
+      _enabled = true;
+      await _settings.setJellyfinEnabled(true);
+    }
+    if (_serverUrl.isEmpty) {
+      _status = JellyfinStatus.needsSetup;
+      _notify();
+      return;
+    }
+    await _beginQuickConnect();
+  }
+
+  /// Forgets the server entirely. The URL and device id stay for convenience.
+  Future<void> removePairing() async {
+    _generation++;
+    _cancelTimers();
+    await _clearSavedSession();
+    _enabled = false;
+    _busy = false;
+    _needsRepair = false;
+    _error = null;
+    _code = null;
+    _secret = null;
+    _serverName = null;
+    _userName = null;
+    _status = JellyfinStatus.disabled;
+    await _settings.setJellyfinEnabled(false);
+    _notify();
+  }
+
+  /// Stops the current Quick Connect attempt without disabling the section.
+  void cancelPairing() {
+    if (_status != JellyfinStatus.pairing) return;
+    _generation++;
+    _cancelTimers();
+    _code = null;
+    _secret = null;
+    _error = null;
+    _busy = false;
+    _status = JellyfinStatus.needsSetup;
+    _notify();
+  }
+
+  /// Manual retry used by the card and when the app returns to the foreground.
+  Future<void> checkNow() async {
+    if (_disposed || !_enabled) return;
+    if (_status == JellyfinStatus.pairing) {
+      await _poll(_generation);
+      return;
+    }
+    if (_token != null) {
+      await _validate();
+    } else if (_serverUrl.isNotEmpty) {
+      await _beginQuickConnect();
+    }
+  }
+
+  void onAppResumed() {
+    if (_disposed || !_enabled) return;
+    if (_status == JellyfinStatus.connected ||
+        _status == JellyfinStatus.connecting) {
+      return;
+    }
+    unawaited(checkNow());
+  }
+
+  Future<void> _beginQuickConnect() async {
+    final base = normalizeJellyfinUrl(_serverUrl);
+    if (base == null) {
+      _error = 'Enter a valid http or https server URL.';
+      _status = JellyfinStatus.needsSetup;
+      _notify();
+      return;
+    }
+
+    final generation = ++_generation;
+    _cancelTimers();
+    _busy = true;
+    _code = null;
+    _secret = null;
+    _error = null;
+    _status = JellyfinStatus.connecting;
+    _notify();
+
+    try {
+      final info = await _gateway.serverInfo(base);
+      if (_stale(generation)) return;
+      _serverName = info.name;
+      await _settings.setJellyfinServerName(info.name);
+
+      final ticket = await _gateway.startQuickConnect(
+        base,
+        deviceId: _deviceId,
+      );
+      if (_stale(generation)) return;
+      _code = ticket.code;
+      _secret = ticket.secret;
+      _busy = false;
+      _status = JellyfinStatus.pairing;
+      _notify();
+      _startPairingTimers(generation);
+    } on JellyfinUnreachable catch (exception) {
+      if (_stale(generation)) return;
+      _goOffline(exception.message, _beginQuickConnect);
+    } on JellyfinException catch (exception) {
+      if (_stale(generation)) return;
+      _failPairing(exception.message);
+    }
+  }
+
+  void _startPairingTimers(int generation) {
+    _pollTimer = Timer.periodic(
+      pollInterval,
+      (_) => unawaited(_poll(generation)),
+    );
+    _pairingTimer = Timer(pairingLifetime, () {
+      if (_stale(generation)) return;
+      _failPairing('The Quick Connect code expired. Try again.');
+    });
+  }
+
+  Future<void> _poll(int generation) async {
+    if (_disposed || _polling || _stale(generation)) return;
+    final base = normalizeJellyfinUrl(_serverUrl);
+    final secret = _secret;
+    if (base == null || secret == null) return;
+    _polling = true;
+    try {
+      final approved = await _gateway.isQuickConnectApproved(base, secret);
+      if (_stale(generation)) return;
+      if (!approved) {
+        if (_error != null) {
+          _error = null;
+          _notify();
+        }
+        return;
+      }
+
+      _pollTimer?.cancel();
+      _pairingTimer?.cancel();
+      _busy = true;
+      _status = JellyfinStatus.connecting;
+      _notify();
+
+      final session = await _gateway.authenticateQuickConnect(
+        base,
+        secret,
+        deviceId: _deviceId,
+      );
+      if (_stale(generation)) return;
+      _token = session.accessToken;
+      _userName = session.userName;
+      await _secureStore.write(tokenKey, session.accessToken);
+      await _settings.setJellyfinSession(
+        userId: session.userId,
+        userName: session.userName,
+      );
+      _code = null;
+      _secret = null;
+      _busy = false;
+      _error = null;
+      _status = JellyfinStatus.connected;
+      _retryAttempt = 0;
+      _notify();
+      _startHealthTimer();
+    } on JellyfinUnreachable {
+      // Keep the code alive; the next poll retries once the network is back.
+      if (_stale(generation)) return;
+      _error = 'No connection — waiting to retry.';
+      _notify();
+    } on JellyfinAuthException catch (exception) {
+      if (_stale(generation)) return;
+      _failPairing(exception.message);
+    } on JellyfinException catch (exception) {
+      if (_stale(generation)) return;
+      _failPairing(exception.message);
+    } finally {
+      _polling = false;
+    }
+  }
+
+  Future<void> _validate({bool silent = false}) async {
+    final base = normalizeJellyfinUrl(_serverUrl);
+    final token = _token;
+    if (base == null) {
+      _status = JellyfinStatus.needsSetup;
+      _notify();
+      return;
+    }
+    if (token == null) {
+      _status = JellyfinStatus.needsSetup;
+      _notify();
+      return;
+    }
+
+    final generation = ++_generation;
+    _cancelTimers();
+    if (!silent) {
+      _busy = true;
+      _status = JellyfinStatus.connecting;
+    }
+    _error = null;
+    if (!silent) _notify();
+
+    try {
+      final info = await _gateway.serverInfo(base);
+      if (_stale(generation)) return;
+      final user = await _gateway.currentUser(base, token);
+      if (_stale(generation)) return;
+      final changed =
+          _serverName != info.name ||
+          _userName != user.name ||
+          _status != JellyfinStatus.connected;
+      _serverName = info.name;
+      _userName = user.name;
+      _busy = false;
+      _status = JellyfinStatus.connected;
+      _error = null;
+      _retryAttempt = 0;
+      unawaited(_settings.setJellyfinServerName(info.name));
+      unawaited(_settings.setJellyfinUserName(user.name));
+      if (changed || !silent) _notify();
+      _startHealthTimer();
+    } on JellyfinUnreachable catch (exception) {
+      if (_stale(generation)) return;
+      _goOffline(exception.message, _validate);
+    } on JellyfinAuthException {
+      if (_stale(generation)) return;
+      await _invalidateSession('The saved pairing is no longer valid.');
+    } on JellyfinException catch (exception) {
+      if (_stale(generation)) return;
+      await _invalidateSession(exception.message);
+    }
+  }
+
+  void _goOffline(String message, Future<void> Function() resume) {
+    _cancelTimers();
+    _busy = false;
+    _status = JellyfinStatus.offline;
+    _error = message;
+    _notify();
+
+    final index = _retryAttempt < retryDelays.length
+        ? _retryAttempt
+        : retryDelays.length - 1;
+    _retryAttempt++;
+    _retryTimer = Timer(retryDelays[index], () {
+      _retryTimer = null;
+      if (_disposed || !_enabled) return;
+      unawaited(resume());
+    });
+  }
+
+  void _failPairing(String message) {
+    _cancelTimers();
+    _code = null;
+    _secret = null;
+    _busy = false;
+    _status = JellyfinStatus.error;
+    _error = message;
+    _notify();
+  }
+
+  /// The token was rejected: turn Jellyfin off, clear the token and profile,
+  /// and leave the card asking for a fresh Quick Connect.
+  Future<void> _invalidateSession(String message) async {
+    _generation++;
+    _cancelTimers();
+    await _clearSavedSession();
+    _enabled = false;
+    _needsRepair = true;
+    _busy = false;
+    _code = null;
+    _secret = null;
+    _serverName = null;
+    _userName = null;
+    _status = JellyfinStatus.error;
+    _error = message;
+    await _settings.setJellyfinEnabled(false);
+    _notify();
+  }
+
+  Future<void> _clearSavedSession() async {
+    _token = null;
+    await _secureStore.delete(tokenKey);
+    await _settings.clearJellyfinSession();
+  }
+
+  void _startHealthTimer() {
+    _healthTimer?.cancel();
+    _healthTimer = Timer.periodic(healthInterval, (_) {
+      if (_disposed || !_enabled || _token == null) return;
+      unawaited(_validate(silent: true));
+    });
+  }
+
+  void _cancelTimers() {
+    _pollTimer?.cancel();
+    _pollTimer = null;
+    _pairingTimer?.cancel();
+    _pairingTimer = null;
+    _healthTimer?.cancel();
+    _healthTimer = null;
+    _retryTimer?.cancel();
+    _retryTimer = null;
+  }
+
+  bool _stale(int generation) {
+    return _disposed || generation != _generation || !_enabled;
+  }
+
+  void _notify() {
+    if (!_disposed) notifyListeners();
+  }
+
+  @override
+  void dispose() {
+    _disposed = true;
+    _cancelTimers();
+    _gateway.close();
+    super.dispose();
+  }
+}

+ 354 - 0
lib/features/settings/jellyfin_settings_card.dart

@@ -0,0 +1,354 @@
+import 'dart:async';
+
+import 'package:flutter/material.dart';
+import 'package:provider/provider.dart';
+
+import '../../core/theme/app_colors.dart';
+import '../../core/widgets/app_icon.dart';
+import 'jellyfin_controller.dart';
+import 'settings_widgets.dart';
+
+/// Settings card for the optional Jellyfin music server. Quick Connect is the
+/// only sign-in path; the token itself never leaves secure storage.
+class JellyfinSettingsCard extends StatefulWidget {
+  const JellyfinSettingsCard({super.key});
+
+  @override
+  State<JellyfinSettingsCard> createState() => _JellyfinSettingsCardState();
+}
+
+class _JellyfinSettingsCardState extends State<JellyfinSettingsCard>
+    with WidgetsBindingObserver {
+  late final TextEditingController _url;
+  final FocusNode _urlFocus = FocusNode();
+
+  @override
+  void initState() {
+    super.initState();
+    _url = TextEditingController(
+      text: context.read<JellyfinController>().serverUrl,
+    );
+    WidgetsBinding.instance.addObserver(this);
+  }
+
+  @override
+  void dispose() {
+    WidgetsBinding.instance.removeObserver(this);
+    _url.dispose();
+    _urlFocus.dispose();
+    super.dispose();
+  }
+
+  @override
+  void didChangeAppLifecycleState(AppLifecycleState state) {
+    if (state != AppLifecycleState.resumed || !mounted) return;
+    context.read<JellyfinController>().onAppResumed();
+  }
+
+  @override
+  Widget build(BuildContext context) {
+    final colors = context.colors;
+    final theme = Theme.of(context);
+    final jellyfin = context.watch<JellyfinController>();
+
+    if (!_urlFocus.hasFocus && _url.text != jellyfin.serverUrl) {
+      _url.value = TextEditingValue(
+        text: jellyfin.serverUrl,
+        selection: TextSelection.collapsed(offset: jellyfin.serverUrl.length),
+      );
+    }
+
+    final statusColor = _statusColor(jellyfin, colors);
+    final connected = jellyfin.connected;
+    final showUrl = jellyfin.enabled && !connected && !jellyfin.pairing;
+
+    return Column(
+      crossAxisAlignment: CrossAxisAlignment.start,
+      children: [
+        Row(
+          children: [
+            Container(
+              width: 42,
+              height: 42,
+              decoration: BoxDecoration(
+                color: colors.accentSoft,
+                borderRadius: BorderRadius.circular(14),
+              ),
+              alignment: Alignment.center,
+              child: AppIcon(
+                'assets/icons/music.svg',
+                size: 20,
+                color: colors.accent,
+              ),
+            ),
+            const SizedBox(width: 14),
+            Expanded(
+              child: Column(
+                crossAxisAlignment: CrossAxisAlignment.start,
+                children: [
+                  Text('Jellyfin', style: theme.textTheme.titleMedium),
+                  const SizedBox(height: 2),
+                  Row(
+                    children: [
+                      Container(
+                        width: 7,
+                        height: 7,
+                        decoration: BoxDecoration(
+                          color: statusColor,
+                          shape: BoxShape.circle,
+                        ),
+                      ),
+                      const SizedBox(width: 7),
+                      Expanded(
+                        child: Text(
+                          jellyfin.statusLabel,
+                          maxLines: 1,
+                          overflow: TextOverflow.ellipsis,
+                          style: theme.textTheme.bodySmall?.copyWith(
+                            color: colors.inkMuted,
+                          ),
+                        ),
+                      ),
+                    ],
+                  ),
+                ],
+              ),
+            ),
+            const SizedBox(width: 8),
+            SettingsSwitch(
+              value: jellyfin.enabled,
+              semanticsLabel: jellyfin.enabled
+                  ? 'Turn off Jellyfin'
+                  : 'Turn on Jellyfin',
+              onChanged: (value) {
+                unawaited(jellyfin.setEnabled(value));
+              },
+            ),
+          ],
+        ),
+        if (jellyfin.enabled) ...[
+          const SizedBox(height: 12),
+          Divider(color: colors.border, height: 24),
+          if (jellyfin.pairing)
+            _QuickConnectCode(controller: jellyfin)
+          else ...[
+            if (showUrl) ...[
+              Row(
+                crossAxisAlignment: CrossAxisAlignment.start,
+                children: [
+                  Expanded(
+                    child: TextField(
+                      controller: _url,
+                      focusNode: _urlFocus,
+                      keyboardType: TextInputType.url,
+                      textInputAction: TextInputAction.go,
+                      autocorrect: false,
+                      enabled: !jellyfin.busy,
+                      style: theme.textTheme.bodyMedium,
+                      onSubmitted: (value) {
+                        _urlFocus.unfocus();
+                        unawaited(jellyfin.connect(value));
+                      },
+                      decoration: const InputDecoration(
+                        labelText: 'Server URL',
+                        hintText: 'https://jellyfin.example.com',
+                        isDense: true,
+                      ),
+                    ),
+                  ),
+                  const SizedBox(width: 10),
+                  Padding(
+                    padding: const EdgeInsets.only(top: 4),
+                    child: TextButton(
+                      onPressed: jellyfin.busy
+                          ? null
+                          : () {
+                              _urlFocus.unfocus();
+                              unawaited(jellyfin.connect(_url.text));
+                            },
+                      style: TextButton.styleFrom(
+                        foregroundColor: colors.accent,
+                      ),
+                      child: Text(jellyfin.busy ? 'Connecting…' : 'Connect'),
+                    ),
+                  ),
+                ],
+              ),
+              const SizedBox(height: 8),
+            ],
+            if (connected) ...[
+              Row(
+                children: [
+                  Expanded(
+                    child: Text(
+                      jellyfin.userName == null
+                          ? 'Signed in.'
+                          : 'Signed in as ${jellyfin.userName}.',
+                      style: theme.textTheme.bodySmall?.copyWith(
+                        color: colors.inkMuted,
+                      ),
+                    ),
+                  ),
+                  TextButton(
+                    onPressed: () {
+                      unawaited(jellyfin.removePairing());
+                    },
+                    style: TextButton.styleFrom(foregroundColor: colors.danger),
+                    child: const Text('Remove pairing'),
+                  ),
+                ],
+              ),
+            ] else if (jellyfin.status == JellyfinStatus.offline) ...[
+              Row(
+                children: [
+                  Expanded(
+                    child: Text(
+                      'Jellyfin is paused until the connection returns. '
+                      'It will retry automatically.',
+                      style: theme.textTheme.bodySmall?.copyWith(
+                        color: colors.inkMuted,
+                      ),
+                    ),
+                  ),
+                  TextButton(
+                    onPressed: () {
+                      unawaited(jellyfin.checkNow());
+                    },
+                    style: TextButton.styleFrom(foregroundColor: colors.accent),
+                    child: const Text('Retry now'),
+                  ),
+                ],
+              ),
+            ] else if (jellyfin.busy) ...[
+              Text(
+                'Checking the server…',
+                style: theme.textTheme.bodySmall?.copyWith(
+                  color: colors.inkMuted,
+                ),
+              ),
+            ] else if (!jellyfin.needsRepair) ...[
+              Text(
+                jellyfin.hasServerUrl
+                    ? 'CarDash reuses the saved pairing. Connect to check it, '
+                          'or enter a different server URL.'
+                    : 'Enter the address of your Jellyfin server to pair it '
+                          'with CarDash.',
+                style: theme.textTheme.bodySmall?.copyWith(
+                  color: colors.inkMuted,
+                ),
+              ),
+            ],
+          ],
+          if (jellyfin.error != null) ...[
+            const SizedBox(height: 8),
+            Text(
+              jellyfin.error!,
+              style: theme.textTheme.bodySmall?.copyWith(color: colors.danger),
+            ),
+          ],
+        ] else if (jellyfin.needsRepair) ...[
+          const SizedBox(height: 12),
+          Text(
+            '${jellyfin.error ?? 'The saved Jellyfin pairing is no longer valid.'} '
+            'Pair again to keep using Jellyfin.',
+            style: theme.textTheme.bodySmall?.copyWith(color: colors.danger),
+          ),
+          const SizedBox(height: 4),
+          TextButton(
+            onPressed: () {
+              unawaited(jellyfin.pairAgain());
+            },
+            style: TextButton.styleFrom(
+              foregroundColor: colors.accent,
+              padding: EdgeInsets.zero,
+              alignment: Alignment.centerLeft,
+            ),
+            child: const Text('Pair again'),
+          ),
+        ],
+        const SizedBox(height: 8),
+        Text(
+          'Only the music library is read. The access token is stored on this '
+          'device and can be removed at any time.',
+          style: theme.textTheme.bodySmall?.copyWith(color: colors.inkMuted),
+        ),
+      ],
+    );
+  }
+
+  Color _statusColor(JellyfinController jellyfin, AppColors colors) {
+    if (jellyfin.connected) return colors.accent;
+    if (jellyfin.error != null ||
+        jellyfin.status == JellyfinStatus.error ||
+        jellyfin.needsRepair) {
+      return colors.danger;
+    }
+    if (jellyfin.status == JellyfinStatus.offline) return colors.warning;
+    return colors.inkFaint;
+  }
+}
+
+class _QuickConnectCode extends StatelessWidget {
+  const _QuickConnectCode({required this.controller});
+
+  final JellyfinController controller;
+
+  @override
+  Widget build(BuildContext context) {
+    final colors = context.colors;
+    final theme = Theme.of(context);
+    final code = controller.quickConnectCode ?? '';
+
+    return Column(
+      crossAxisAlignment: CrossAxisAlignment.start,
+      children: [
+        Container(
+          width: double.infinity,
+          padding: const EdgeInsets.symmetric(horizontal: 22, vertical: 18),
+          decoration: BoxDecoration(
+            color: colors.accentSoft,
+            borderRadius: BorderRadius.circular(18),
+          ),
+          child: Column(
+            crossAxisAlignment: CrossAxisAlignment.start,
+            children: [
+              Text(
+                'Quick Connect code',
+                style: theme.textTheme.labelMedium?.copyWith(
+                  color: colors.inkMuted,
+                ),
+              ),
+              const SizedBox(height: 4),
+              SelectableText(
+                code,
+                style: theme.textTheme.headlineMedium?.copyWith(
+                  color: colors.ink,
+                  fontWeight: FontWeight.w700,
+                  letterSpacing: 6,
+                ),
+              ),
+              const SizedBox(height: 6),
+              Text(
+                'Open Jellyfin, choose Quick Connect and enter this code. '
+                'CarDash connects as soon as you approve it.',
+                style: theme.textTheme.bodySmall?.copyWith(
+                  color: colors.inkMuted,
+                ),
+              ),
+            ],
+          ),
+        ),
+        const SizedBox(height: 2),
+        TextButton(
+          onPressed: controller.cancelPairing,
+          style: TextButton.styleFrom(
+            foregroundColor: colors.inkMuted,
+            padding: EdgeInsets.zero,
+            alignment: Alignment.centerLeft,
+          ),
+          child: const Text('Cancel'),
+        ),
+      ],
+    );
+  }
+}

+ 12 - 0
lib/features/settings/settings_screen.dart

@@ -17,6 +17,7 @@ import '../home/system_controller.dart';
 import 'update_controller.dart';
 import '../lock/lock_controller.dart';
 import '../lock/pin_setup_screen.dart';
+import 'jellyfin_settings_card.dart';
 import 'obd_settings_card.dart';
 import 'settings_widgets.dart';
 import 'speed_settings_card.dart';
@@ -237,6 +238,17 @@ class SettingsScreen extends StatelessWidget {
                       child: _NowPlayingAccessRow(system: system),
                     ),
                     const SizedBox(height: 30),
+                    _SectionTitle(
+                      title: 'Jellyfin',
+                      colors: colors,
+                      theme: theme,
+                    ),
+                    const SizedBox(height: 12),
+                    SurfaceCard(
+                      padding: const EdgeInsets.fromLTRB(26, 22, 26, 20),
+                      child: const JellyfinSettingsCard(),
+                    ),
+                    const SizedBox(height: 30),
                     _SectionTitle(
                       title: 'OBD-II',
                       colors: colors,

+ 10 - 0
lib/main.dart

@@ -7,6 +7,7 @@ import 'app.dart';
 import 'core/display_controller.dart';
 import 'core/theme/theme_controller.dart';
 import 'data/repositories/settings_repository.dart';
+import 'data/services/jellyfin_service.dart';
 import 'data/services/native_bridge.dart';
 import 'data/services/pin_service.dart';
 import 'data/services/secure_store.dart';
@@ -17,6 +18,7 @@ import 'features/home/obd_controller.dart';
 import 'features/home/system_controller.dart';
 import 'features/home/telemetry_settings_controller.dart';
 import 'features/home/weather_controller.dart';
+import 'features/settings/jellyfin_controller.dart';
 import 'features/settings/update_controller.dart';
 import 'features/lock/lock_controller.dart';
 
@@ -61,6 +63,13 @@ Future<void> main() async {
   )..initialize();
   final lockController = LockController(PinService(FlutterSecureStore()));
   await lockController.initialize();
+  final jellyfinController = JellyfinController(
+    settings,
+    HttpJellyfinGateway(),
+    FlutterSecureStore(),
+  );
+  await jellyfinController.initialize();
+  jellyfinController.start();
 
   runApp(
     MultiProvider(
@@ -73,6 +82,7 @@ Future<void> main() async {
         ChangeNotifierProvider.value(value: gpsSpeedController),
         ChangeNotifierProvider.value(value: weatherController),
         ChangeNotifierProvider.value(value: telemetrySettingsController),
+        ChangeNotifierProvider.value(value: jellyfinController),
         ChangeNotifierProvider.value(value: updateController),
         ChangeNotifierProvider.value(value: lockController),
       ],

+ 265 - 0
test/jellyfin_controller_test.dart

@@ -0,0 +1,265 @@
+import 'package:cardash_launcher/data/models/jellyfin_models.dart';
+import 'package:cardash_launcher/data/repositories/settings_repository.dart';
+import 'package:cardash_launcher/data/services/jellyfin_service.dart';
+import 'package:cardash_launcher/data/services/secure_store.dart';
+import 'package:cardash_launcher/features/settings/jellyfin_controller.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:shared_preferences/shared_preferences.dart';
+
+class _MemoryStore implements SecureStore {
+  final values = <String, String>{};
+
+  @override
+  Future<String?> read(String key) async => values[key];
+
+  @override
+  Future<void> write(String key, String value) async {
+    values[key] = value;
+  }
+
+  @override
+  Future<void> delete(String key) async {
+    values.remove(key);
+  }
+}
+
+class _FakeGateway implements JellyfinGateway {
+  bool unreachable = false;
+  bool approved = false;
+  bool rejectToken = false;
+  int initiateCalls = 0;
+  int userCalls = 0;
+  String? lastDeviceId;
+
+  @override
+  Future<JellyfinServerInfo> serverInfo(Uri baseUrl) async {
+    if (unreachable) {
+      throw JellyfinUnreachable('Could not reach the server.');
+    }
+    return const JellyfinServerInfo(name: 'DarkTower', version: '12.2.0');
+  }
+
+  @override
+  Future<JellyfinQuickConnect> startQuickConnect(
+    Uri baseUrl, {
+    required String deviceId,
+  }) async {
+    if (unreachable) {
+      throw JellyfinUnreachable('Could not reach the server.');
+    }
+    initiateCalls++;
+    lastDeviceId = deviceId;
+    return const JellyfinQuickConnect(code: '491006', secret: 'secret');
+  }
+
+  @override
+  Future<bool> isQuickConnectApproved(Uri baseUrl, String secret) async {
+    if (unreachable) {
+      throw JellyfinUnreachable('Could not reach the server.');
+    }
+    return approved;
+  }
+
+  @override
+  Future<JellyfinSession> authenticateQuickConnect(
+    Uri baseUrl,
+    String secret, {
+    required String deviceId,
+  }) async {
+    return const JellyfinSession(
+      accessToken: 'token-1',
+      userId: 'user-1',
+      userName: 'wasp',
+    );
+  }
+
+  @override
+  Future<JellyfinUser> currentUser(Uri baseUrl, String token) async {
+    userCalls++;
+    if (unreachable) {
+      throw JellyfinUnreachable('Could not reach the server.');
+    }
+    if (rejectToken) {
+      throw JellyfinAuthException('The saved Jellyfin pairing is not valid.');
+    }
+    return const JellyfinUser(id: 'user-1', name: 'wasp');
+  }
+
+  @override
+  void close() {}
+}
+
+Future<SettingsRepository> _settings([
+  Map<String, Object> values = const {},
+]) async {
+  SharedPreferences.setMockInitialValues(values);
+  final prefs = await SharedPreferences.getInstance();
+  return SettingsRepository(prefs);
+}
+
+void main() {
+  TestWidgetsFlutterBinding.ensureInitialized();
+
+  test('enabling without a URL waits for setup', () async {
+    final settings = await _settings();
+    final gateway = _FakeGateway();
+    final controller = JellyfinController(
+      settings,
+      gateway,
+      _MemoryStore(),
+      pollInterval: const Duration(milliseconds: 10),
+      retryDelays: const [Duration(milliseconds: 10)],
+    );
+    addTearDown(controller.dispose);
+
+    await controller.initialize();
+    expect(controller.status, JellyfinStatus.disabled);
+
+    await controller.setEnabled(true);
+    expect(controller.status, JellyfinStatus.needsSetup);
+    expect(controller.enabled, isTrue);
+    expect(settings.jellyfinEnabled, isTrue);
+  });
+
+  test(
+    'Quick Connect code is shown and the approved token is stored',
+    () async {
+      final settings = await _settings();
+      final gateway = _FakeGateway();
+      final store = _MemoryStore();
+      final controller = JellyfinController(
+        settings,
+        gateway,
+        store,
+        pollInterval: const Duration(milliseconds: 10),
+        pairingLifetime: const Duration(seconds: 2),
+        retryDelays: const [Duration(milliseconds: 10)],
+      );
+      addTearDown(controller.dispose);
+
+      await controller.initialize();
+      await controller.setEnabled(true);
+      await controller.connect('jellyfin.example.com');
+
+      expect(controller.status, JellyfinStatus.pairing);
+      expect(controller.quickConnectCode, '491006');
+      expect(settings.jellyfinServerUrl, 'https://jellyfin.example.com');
+      expect(gateway.lastDeviceId, isNotEmpty);
+
+      gateway.approved = true;
+      await Future<void>.delayed(const Duration(milliseconds: 80));
+
+      expect(controller.status, JellyfinStatus.connected);
+      expect(controller.quickConnectCode, isNull);
+      expect(store.values[JellyfinController.tokenKey], 'token-1');
+      expect(settings.jellyfinUserName, 'wasp');
+    },
+  );
+
+  test('a saved token is reused on startup without Quick Connect', () async {
+    final settings = await _settings({
+      'jellyfin.enabled': true,
+      'jellyfin.serverUrl': 'https://jellyfin.example.com',
+    });
+    final store = _MemoryStore()
+      ..values[JellyfinController.tokenKey] = 'token-1';
+    final gateway = _FakeGateway();
+    final controller = JellyfinController(
+      settings,
+      gateway,
+      store,
+      pollInterval: const Duration(milliseconds: 10),
+      retryDelays: const [Duration(milliseconds: 10)],
+    );
+    addTearDown(controller.dispose);
+
+    await controller.initialize();
+    expect(controller.status, JellyfinStatus.connecting);
+
+    controller.start();
+    await Future<void>.delayed(const Duration(milliseconds: 40));
+
+    expect(controller.status, JellyfinStatus.connected);
+    expect(controller.serverName, 'DarkTower');
+    expect(controller.userName, 'wasp');
+    expect(gateway.initiateCalls, 0);
+    expect(gateway.userCalls, 1);
+  });
+
+  test(
+    'a rejected token disables Jellyfin, clears the token, and asks to pair',
+    () async {
+      final settings = await _settings({
+        'jellyfin.enabled': true,
+        'jellyfin.serverUrl': 'https://jellyfin.example.com',
+      });
+      final store = _MemoryStore()
+        ..values[JellyfinController.tokenKey] = 'stale-token';
+      final gateway = _FakeGateway()..rejectToken = true;
+      final controller = JellyfinController(
+        settings,
+        gateway,
+        store,
+        retryDelays: const [Duration(milliseconds: 10)],
+      );
+      addTearDown(controller.dispose);
+
+      await controller.initialize();
+      controller.start();
+      await Future<void>.delayed(const Duration(milliseconds: 40));
+
+      expect(controller.enabled, isFalse);
+      expect(controller.needsRepair, isTrue);
+      expect(controller.status, JellyfinStatus.error);
+      expect(controller.error, isNotNull);
+      expect(store.values.containsKey(JellyfinController.tokenKey), isFalse);
+      expect(settings.jellyfinEnabled, isFalse);
+      expect(settings.jellyfinServerUrl, 'https://jellyfin.example.com');
+    },
+  );
+
+  test('an unreachable server is retried and reconnects', () async {
+    final settings = await _settings({
+      'jellyfin.enabled': true,
+      'jellyfin.serverUrl': 'https://jellyfin.example.com',
+    });
+    final store = _MemoryStore()
+      ..values[JellyfinController.tokenKey] = 'token-1';
+    final gateway = _FakeGateway()..unreachable = true;
+    final controller = JellyfinController(
+      settings,
+      gateway,
+      store,
+      retryDelays: const [Duration(milliseconds: 20)],
+    );
+    addTearDown(controller.dispose);
+
+    await controller.initialize();
+    controller.start();
+    await Future<void>.delayed(const Duration(milliseconds: 30));
+
+    expect(controller.status, JellyfinStatus.offline);
+    expect(controller.enabled, isTrue);
+    expect(store.values[JellyfinController.tokenKey], 'token-1');
+
+    gateway.unreachable = false;
+    await Future<void>.delayed(const Duration(milliseconds: 80));
+
+    expect(controller.status, JellyfinStatus.connected);
+    expect(controller.userName, 'wasp');
+  });
+
+  test('urls are normalized with a scheme and without a trailing slash', () {
+    expect(
+      normalizeJellyfinUrl('jellyfin.example.com')?.toString(),
+      'https://jellyfin.example.com/',
+    );
+    expect(
+      normalizeJellyfinUrl('http://host:8096/library')?.toString(),
+      'http://host:8096/library/',
+    );
+    expect(normalizeJellyfinUrl(''), isNull);
+    expect(normalizeJellyfinUrl('ftp://host'), isNull);
+    expect(displayJellyfinUrl('https://host/'), 'https://host');
+  });
+}

+ 133 - 0
test/jellyfin_settings_card_test.dart

@@ -0,0 +1,133 @@
+import 'package:cardash_launcher/core/theme/app_theme.dart';
+import 'package:cardash_launcher/data/models/jellyfin_models.dart';
+import 'package:cardash_launcher/data/repositories/settings_repository.dart';
+import 'package:cardash_launcher/data/services/jellyfin_service.dart';
+import 'package:cardash_launcher/data/services/secure_store.dart';
+import 'package:cardash_launcher/features/settings/jellyfin_controller.dart';
+import 'package:cardash_launcher/features/settings/jellyfin_settings_card.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:provider/provider.dart';
+import 'package:shared_preferences/shared_preferences.dart';
+
+class _MemoryStore implements SecureStore {
+  final values = <String, String>{};
+
+  @override
+  Future<String?> read(String key) async => values[key];
+
+  @override
+  Future<void> write(String key, String value) async {
+    values[key] = value;
+  }
+
+  @override
+  Future<void> delete(String key) async {
+    values.remove(key);
+  }
+}
+
+class _FakeGateway implements JellyfinGateway {
+  @override
+  Future<JellyfinServerInfo> serverInfo(Uri baseUrl) async {
+    return const JellyfinServerInfo(name: 'DarkTower', version: '12.2.0');
+  }
+
+  @override
+  Future<JellyfinQuickConnect> startQuickConnect(
+    Uri baseUrl, {
+    required String deviceId,
+  }) async {
+    return const JellyfinQuickConnect(code: '491006', secret: 'secret');
+  }
+
+  @override
+  Future<bool> isQuickConnectApproved(Uri baseUrl, String secret) async {
+    return false;
+  }
+
+  @override
+  Future<JellyfinSession> authenticateQuickConnect(
+    Uri baseUrl,
+    String secret, {
+    required String deviceId,
+  }) async {
+    return const JellyfinSession(
+      accessToken: 'token-1',
+      userId: 'user-1',
+      userName: 'wasp',
+    );
+  }
+
+  @override
+  Future<JellyfinUser> currentUser(Uri baseUrl, String token) async {
+    return const JellyfinUser(id: 'user-1', name: 'wasp');
+  }
+
+  @override
+  void close() {}
+}
+
+void main() {
+  testWidgets(
+    'Jellyfin card shows the URL field, then the Quick Connect code',
+    (tester) async {
+      await tester.binding.setSurfaceSize(const Size(900, 700));
+      addTearDown(() => tester.binding.setSurfaceSize(null));
+
+      SharedPreferences.setMockInitialValues({});
+      final prefs = await SharedPreferences.getInstance();
+      final controller = JellyfinController(
+        SettingsRepository(prefs),
+        _FakeGateway(),
+        _MemoryStore(),
+        pollInterval: const Duration(milliseconds: 10),
+        pairingLifetime: const Duration(seconds: 30),
+      );
+      addTearDown(controller.dispose);
+      await controller.initialize();
+
+      await tester.pumpWidget(
+        MaterialApp(
+          theme: AppTheme.light(),
+          home: ChangeNotifierProvider<JellyfinController>.value(
+            value: controller,
+            child: const Scaffold(
+              body: SizedBox(
+                width: 720,
+                child: Padding(
+                  padding: EdgeInsets.all(24),
+                  child: JellyfinSettingsCard(),
+                ),
+              ),
+            ),
+          ),
+        ),
+      );
+      await tester.pumpAndSettle();
+
+      expect(find.text('Jellyfin'), findsOneWidget);
+      expect(find.text('Off'), findsOneWidget);
+      expect(find.text('Server URL'), findsNothing);
+
+      await tester.tap(find.byType(Switch));
+      await tester.pumpAndSettle();
+
+      expect(find.text('Server URL'), findsOneWidget);
+      expect(find.text('Connect'), findsOneWidget);
+
+      await tester.enterText(find.byType(TextField), 'jellyfin.example.com');
+      await tester.tap(find.text('Connect'));
+      await tester.pump();
+      await tester.pump(const Duration(milliseconds: 20));
+
+      expect(find.text('Quick Connect code'), findsOneWidget);
+      expect(find.text('491006'), findsOneWidget);
+      expect(find.text('Waiting for approval'), findsOneWidget);
+      expect(tester.takeException(), isNull);
+
+      controller.cancelPairing();
+      await tester.pump();
+    },
+  );
+}

+ 111 - 57
test/settings_screen_test.dart

@@ -6,12 +6,14 @@ import 'package:cardash_launcher/data/models/app_entry.dart';
 import 'package:cardash_launcher/data/models/bluetooth_device_info.dart';
 import 'package:cardash_launcher/data/models/bluetooth_power_state.dart';
 import 'package:cardash_launcher/data/models/gps_speed.dart';
+import 'package:cardash_launcher/data/models/jellyfin_models.dart';
 import 'package:cardash_launcher/data/models/location_reading.dart';
 import 'package:cardash_launcher/data/models/media_playback.dart';
 import 'package:cardash_launcher/data/models/obd_state.dart';
 import 'package:cardash_launcher/data/models/system_status.dart';
 import 'package:cardash_launcher/data/models/update_info.dart';
 import 'package:cardash_launcher/data/repositories/settings_repository.dart';
+import 'package:cardash_launcher/data/services/jellyfin_service.dart';
 import 'package:cardash_launcher/data/services/native_bridge.dart';
 import 'package:cardash_launcher/data/services/pin_service.dart';
 import 'package:cardash_launcher/data/services/secure_store.dart';
@@ -22,6 +24,7 @@ import 'package:cardash_launcher/features/home/system_controller.dart';
 import 'package:cardash_launcher/features/home/telemetry_settings_controller.dart';
 import 'package:cardash_launcher/features/home/weather_controller.dart';
 import 'package:cardash_launcher/features/lock/lock_controller.dart';
+import 'package:cardash_launcher/features/settings/jellyfin_controller.dart';
 import 'package:cardash_launcher/features/settings/settings_screen.dart';
 import 'package:cardash_launcher/features/settings/update_controller.dart';
 import 'package:flutter/material.dart';
@@ -128,65 +131,116 @@ class _SecureStore implements SecureStore {
   Future<void> delete(String key) async {}
 }
 
+class _JellyfinGateway implements JellyfinGateway {
+  @override
+  Future<JellyfinServerInfo> serverInfo(Uri baseUrl) async {
+    return const JellyfinServerInfo(name: 'DarkTower', version: '12.2.0');
+  }
+
+  @override
+  Future<JellyfinQuickConnect> startQuickConnect(
+    Uri baseUrl, {
+    required String deviceId,
+  }) async {
+    return const JellyfinQuickConnect(code: '491006', secret: 'secret');
+  }
+
+  @override
+  Future<bool> isQuickConnectApproved(Uri baseUrl, String secret) async {
+    return false;
+  }
+
+  @override
+  Future<JellyfinSession> authenticateQuickConnect(
+    Uri baseUrl,
+    String secret, {
+    required String deviceId,
+  }) async {
+    return const JellyfinSession(
+      accessToken: 'token-1',
+      userId: 'user-1',
+      userName: 'wasp',
+    );
+  }
+
+  @override
+  Future<JellyfinUser> currentUser(Uri baseUrl, String token) async {
+    return const JellyfinUser(id: 'user-1', name: 'wasp');
+  }
+
+  @override
+  void close() {}
+}
+
 void main() {
   TestWidgetsFlutterBinding.ensureInitialized();
 
-  testWidgets('settings shows the OBD-II, Speed and Temperature cards', (
-    tester,
-  ) async {
-    await tester.binding.setSurfaceSize(const Size(1280, 800));
-    addTearDown(() => tester.binding.setSurfaceSize(null));
-
-    SharedPreferences.setMockInitialValues({});
-    final prefs = await SharedPreferences.getInstance();
-    final settings = SettingsRepository(prefs);
-    await settings.setThemePreference(ThemePreference.light);
-    final platform = _LauncherPlatform();
-    final telemetry = _TelemetryPlatform();
-    final theme = ThemeController(settings);
-    addTearDown(theme.dispose);
-    final display = DisplayController(platform, settings);
-    await display.initialize();
-    final launcher = LauncherController(platform, settings);
-    final system = SystemController(platform);
-    final obd = ObdController(telemetry, settings)..initialize();
-    final weather = WeatherController(telemetry);
-    final telemetrySettings = TelemetrySettingsController(settings, weather)
-      ..initialize();
-    final update = UpdateController(platform);
-    final lock = LockController(PinService(_SecureStore(), iterations: 10));
-    await lock.initialize();
-
-    await tester.pumpWidget(
-      MultiProvider(
-        providers: [
-          ChangeNotifierProvider.value(value: theme),
-          ChangeNotifierProvider.value(value: display),
-          ChangeNotifierProvider.value(value: launcher),
-          ChangeNotifierProvider.value(value: system),
-          ChangeNotifierProvider.value(value: obd),
-          ChangeNotifierProvider.value(value: weather),
-          ChangeNotifierProvider.value(value: telemetrySettings),
-          ChangeNotifierProvider.value(value: update),
-          ChangeNotifierProvider.value(value: lock),
-        ],
-        child: MaterialApp(
-          theme: AppTheme.light(),
-          home: const SettingsScreen(),
+  testWidgets(
+    'settings shows the Jellyfin, OBD-II, Speed and Temperature cards',
+    (tester) async {
+      await tester.binding.setSurfaceSize(const Size(1280, 800));
+      addTearDown(() => tester.binding.setSurfaceSize(null));
+
+      SharedPreferences.setMockInitialValues({});
+      final prefs = await SharedPreferences.getInstance();
+      final settings = SettingsRepository(prefs);
+      await settings.setThemePreference(ThemePreference.light);
+      final platform = _LauncherPlatform();
+      final telemetry = _TelemetryPlatform();
+      final theme = ThemeController(settings);
+      addTearDown(theme.dispose);
+      final display = DisplayController(platform, settings);
+      await display.initialize();
+      final launcher = LauncherController(platform, settings);
+      final system = SystemController(platform);
+      final obd = ObdController(telemetry, settings)..initialize();
+      final weather = WeatherController(telemetry);
+      final telemetrySettings = TelemetrySettingsController(settings, weather)
+        ..initialize();
+      final update = UpdateController(platform);
+      final lock = LockController(PinService(_SecureStore(), iterations: 10));
+      await lock.initialize();
+      final jellyfin = JellyfinController(
+        settings,
+        _JellyfinGateway(),
+        _SecureStore(),
+      );
+      await jellyfin.initialize();
+      addTearDown(jellyfin.dispose);
+
+      await tester.pumpWidget(
+        MultiProvider(
+          providers: [
+            ChangeNotifierProvider.value(value: theme),
+            ChangeNotifierProvider.value(value: display),
+            ChangeNotifierProvider.value(value: launcher),
+            ChangeNotifierProvider.value(value: system),
+            ChangeNotifierProvider.value(value: obd),
+            ChangeNotifierProvider.value(value: weather),
+            ChangeNotifierProvider.value(value: telemetrySettings),
+            ChangeNotifierProvider.value(value: jellyfin),
+            ChangeNotifierProvider.value(value: update),
+            ChangeNotifierProvider.value(value: lock),
+          ],
+          child: MaterialApp(
+            theme: AppTheme.light(),
+            home: const SettingsScreen(),
+          ),
         ),
-      ),
-    );
-    await tester.pumpAndSettle();
-
-    expect(find.text('OBD-II'), findsOneWidget);
-    expect(find.text('Speed'), findsOneWidget);
-    expect(find.text('Temperature'), findsOneWidget);
-    expect(find.text('Show speed'), findsOneWidget);
-    expect(find.text('Show temperature'), findsOneWidget);
-    expect(tester.takeException(), isNull);
-
-    obd.dispose();
-    weather.dispose();
-    telemetrySettings.dispose();
-  });
+      );
+      await tester.pumpAndSettle();
+
+      expect(find.text('OBD-II'), findsOneWidget);
+      expect(find.text('Jellyfin'), findsWidgets);
+      expect(find.text('Speed'), findsOneWidget);
+      expect(find.text('Temperature'), findsOneWidget);
+      expect(find.text('Show speed'), findsOneWidget);
+      expect(find.text('Show temperature'), findsOneWidget);
+      expect(tester.takeException(), isNull);
+
+      obd.dispose();
+      weather.dispose();
+      telemetrySettings.dispose();
+    },
+  );
 }