Explorar el Código

feat: add Jellyfin streaming quality setting

Persist Direct or 320/256/192/128 kbps for Jellyfin. Bitrate choices request an MP3 transcode through the universal audio endpoint; Direct keeps the source. Adds the Settings dropdown, queue URL wiring, persistence and tests. Staged for the next version; no version bump or APK.
Krasimir Kazakov hace 5 días
padre
commit
37336448fe

+ 2 - 1
AGENTS.md

@@ -36,7 +36,7 @@ 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`, Jellyfin library/connection/playback models, OBD/weather models.
 - `lib/data/services/` — `NativeBridge`, `TelemetryBridge`, `WeatherService`, `UpdateService`, `PinService`, Jellyfin Quick Connect + library gateway, `JellyfinGenreCatalog`, `JellyfinLibraryService`, `JellyfinPlaybackPlatform`/`NativeJellyfinPlayback`, secure-store adapter.
-- `lib/data/repositories/` — `SettingsRepository`, the SSOT for theme, night window, slot assignments, visibility flags, pinned apps, onboarding time, speed/temperature choices, the Jellyfin server URL/enable flag and the selected genre families.
+- `lib/data/repositories/` — `SettingsRepository`, the SSOT for theme, night window, slot assignments, visibility flags, pinned apps, onboarding time, speed/temperature choices, the Jellyfin server URL/enable flag, the selected genre families and the streaming quality.
 - `lib/features/home/` — home screen, `LauncherController`, `SystemController`, `ObdController`, `WeatherController`, `GpsSpeedController`, `TelemetrySettingsController` (speed/temperature visibility and sources), equalizer, night overlay, Jellyfin Home tile and source-aware `VolumeCard`.
 - `lib/features/jellyfin/` — dashboard controller/screen, album screen, app-level `JellyfinPlaybackController`, and the Jellyfin-specific widgets.
 - `lib/features/apps/` — All Apps screen and app-picker sheet.
@@ -104,6 +104,7 @@ notifyListeners();
 - Jellyfin is optional: Settings → Jellyfin owns pairing, the Home switch lives in General → Home buttons, and the Home tile opens the dashboard/player. 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. `JellyfinController.configured` gates the Home switch/tile (saved URL + token, survives outages); `connected` is for network actions. The Home slot default is off, counts toward the 16-button cap, is cleared on disconnect, and has no app assignment or long-press. Do not add a username/password form.
 - Jellyfin 12.2 API contract: `/Items?userId=...` (there is no `/Users/{userId}/Items` path); `/Genres` requires `parentId` of a `music` view from `/UserViews`; `genres` is pipe-delimited and OR-ed; `/Items/Latest` returns a bare array; auth is the `Authorization: MediaBrowser Client=..., Device=..., DeviceId=..., Version=..., Token=...` header (`X-Emby-Token` and `api_key` were rejected on 12.2). Album `UserData` is not updated by track playback, so "recently played" is derived from played Audio tracks sorted by `DatePlayed`, deduped by `AlbumId`, and capped at 6. Stream URLs use `/Audio/{id}/universal`; the native data source adds the auth header and the token never goes in a URL or log.
 - Jellyfin playback is Media3 1.11.1 in `JellyfinPlaybackService` (foreground `mediaPlayback`). `JellyfinPlaybackController` sends the queue, polls `getState` every ~700 ms, and keeps the Home card alive; the service owns the `MediaSession`, audio focus and `JellyfinPlaybackReporter`. Focus loss pauses and keeps position, a transient loss resumes on gain, a permanent loss abandons focus and does not resume. The silent `jellyfin_playback` notification needs `POST_NOTIFICATIONS` on Android 13+; the Settings card's **Allow** row requests it, and playback still works when denied. The service keeps playing while CarDash is backgrounded.
+- Jellyfin streaming quality is persisted as `jellyfin.audioQuality` (`JellyfinAudioQuality`). Direct (default) omits `MaxStreamingBitrate` and advertises the source codecs; 320/256/192/128 kbps set `Container=mp3`, `AudioCodec=mp3`, `TranscodingContainer=mp3` and `MaxStreamingBitrate`, asking Jellyfin to transcode. `JellyfinLibraryService` reads `audioQualityProvider` when a queue is built, so a change applies to the next album. The dropdown is staged for the next build; do not claim it is in 1.2.1.
 - Dashboard layout: the controller asks for 6 albums per row; the screen shows 6 when each of six cards is at least 160 px wide, otherwise 5. Album names/artists share the album art's left edge, the album page cover is top-aligned with the track list/controls, and selecting a Random album re-rolls the Random row after playback starts. The Home equalizer is exclusive: Jellyfin playing → Jellyfin tile only; another app playing while Jellyfin is paused → Music tile only. The Home `VolumeCard` prefers the Jellyfin state while it is playing, or while it has a queue and no other app is playing; otherwise it uses the system session/media keys.
 - 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.

+ 13 - 6
README.md

@@ -106,7 +106,9 @@ live vehicle data.
   Jellyfin. Selecting a Random album re-rolls the row. The home equalizer is
   exclusive: Jellyfin while it plays, Music only for another app. A rejected
   token is cleared and asks for pairing again; an unreachable server keeps the
-  token and retries with backoff.
+  token and retries with backoff. A **Streaming quality** setting (next build)
+  selects Direct or 320/256/192/128 kbps MP3 transcoding; see Settings →
+  Jellyfin.
 - **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
@@ -307,6 +309,10 @@ the app resumes.
 - **Playback**: in-app Media3 with background playback, a media notification on
   Android 13+ when allowed, audio-focus pause/resume, and playback reporting to
   Jellyfin. Stops at the end of the album; no autoplay or repeat.
+- **Streaming quality (next build)**: Direct (default) or 320/256/192/128 kbps.
+  The bitrate choices ask Jellyfin to transcode to MP3, useful if a
+  high-resolution source plays too slowly or quietly on a head unit. The change
+  applies to the next album you start.
 - **Allow notifications** asks for Android's notification permission; denying it
   only hides the playback notification, not playback itself.
 
@@ -392,7 +398,7 @@ flutter test
 ./tool/check.sh
 ```
 
-The 139 tests cover PIN hashing/persistence, settings persistence, theme/accent,
+The 142 tests cover PIN hashing/persistence, settings persistence, theme/accent,
 dark accent tinting, battery visibility, first-run state, the ten-minute
 onboarding hint, slot visibility, pinned apps, custom labels/icons, the
 16-button cap at 1280×800, 1280×720 and 1024×600, the dense shortcut grid, the
@@ -404,8 +410,9 @@ weather fallback, the update version/availability logic and the Settings update
 dot. Jellyfin coverage includes genre grouping, recent-played dedupe, the
 12.2 request contract, the dashboard controller and widget states, the genre
 picker and five-family cap, album page/track list/transport, playback queue and
-state mapping, the settings gating, the Home tile width clipping and the
-equalizer's stale-session guard. Native
+state mapping, the settings gating, the Home tile width clipping, the
+equalizer's stale-session guard, the streaming-quality persistence and the
+direct/MP3 transcode URL parameters. Native
 package enumeration, the HOME role, volume, app launching and PIN flows were
 verified on API 35 and API 29 emulators. Location permission, reverse geocoding
 and the Open-Meteo fallback were verified live on the API 35 emulator.
@@ -416,8 +423,8 @@ background playback, notification, media keys, exclusive equalizers, audio-focus
 handover, Random re-shuffle) and against a local 12.2 container. The OBD hardware
 path compiles and is covered by the parser/economy design, but no ELM327 adapter
 is available on this machine; socket behavior still needs a car test. The 1.2.1
-universal APK is built from this tree and published. The emulator has verified
-the in-app update path.
+universal APK is the current published release; the streaming-quality setting is
+staged for the next build. The emulator has verified the in-app update path.
 
 ## Troubleshooting the emulator
 

+ 11 - 1
docs/IMPLEMENTATION_PLAN.md

@@ -310,6 +310,10 @@ Android (Kotlin)
 16. **Jellyfin genre grouping is heuristic**: built-in families cover the known
     tags (including Cyrillic); unknown tags become standalone. The picker is
     capped at five families.
+17. **High-resolution Jellyfin audio on a car head unit**: direct play can sound
+    slow or quiet on some DSP/audio HAL combinations. The staged streaming
+    quality setting offers 320/256/192/128 kbps MP3 transcodes; verify which one
+    fixes the user's unit. The choice applies to the next album queue.
 
 ## 7. Out of scope (for now)
 
@@ -321,11 +325,17 @@ Android (Kotlin)
 - Play Store packaging/signing, remote git, CI.
 - Jellyfin Artists tab, artist/album browsing and search.
 - Jellyfin offline caching/downloads; queue persistence across process death.
-- Data-aware streaming quality (Wi-Fi vs metered, bitrate caps).
+- Data-aware automatic streaming quality (Wi-Fi vs metered). A manual
+  Direct/320/256/192/128 kbps choice is staged in the next build.
 - User-editable genre mapping, gapless playback and richer metadata.
 
 ## 8. Change log
 
+- 2026-10-31 (unreleased): adds a Jellyfin **Streaming quality** dropdown:
+  Direct (default) or 320/256/192/128 kbps MP3 transcoding. The choice is
+  persisted in `SettingsRepository`, read when the next album queue is built,
+  and shown in the Jellyfin settings card. Staged for the next version; no APK
+  or version bump has been made. 142 tests pass.
 - 2026-10-31: CarDash 1.2.1+21 fixes the brief Music-tile equalizer flash after
   pausing Jellyfin from the Home card. The Music equalizer now ignores
   CarDash's own stale system MediaSession while a Jellyfin pause propagates, and

+ 9 - 0
docs/JELLYFIN_1.2.0_PLAN.md

@@ -100,6 +100,15 @@ Post-plan refinements implemented on 2026-10-31:
 - Selecting an album from the Random row re-rolls the Random row once playback
   starts.
 
+Next-version work staged (unreleased):
+
+- **Streaming quality** dropdown in Settings → Jellyfin: Direct (default) or
+  320/256/192/128 kbps. Bitrate choices request an MP3 transcode from Jellyfin
+  and apply when the next album queue is built. This is intended to diagnose
+  high-resolution sources that some car head units play too slowly or quietly.
+- The setting is persisted in `SettingsRepository`; no version bump or APK has
+  been produced for it yet.
+
 ## 2. Scope and non-goals
 
 In scope:

+ 27 - 0
lib/data/models/jellyfin_library.dart

@@ -1,5 +1,32 @@
 import 'package:flutter/foundation.dart';
 
+/// Streaming quality for Jellyfin audio.
+///
+/// [direct] sends the original file. Bitrate choices ask Jellyfin to transcode
+/// to MP3 at that cap, which is useful when a car head unit mishandles a
+/// high-resolution source.
+enum JellyfinAudioQuality {
+  direct('Direct', null),
+  kbps320('320 kbps', 320000),
+  kbps256('256 kbps', 256000),
+  kbps192('192 kbps', 192000),
+  kbps128('128 kbps', 128000);
+
+  const JellyfinAudioQuality(this.label, this.maxStreamingBitrate);
+
+  final String label;
+  final int? maxStreamingBitrate;
+
+  bool get isDirect => maxStreamingBitrate == null;
+
+  static JellyfinAudioQuality fromStorage(String? value) {
+    return values.firstWhere(
+      (quality) => quality.name == value,
+      orElse: () => direct,
+    );
+  }
+}
+
 /// A saved Jellyfin selection in the genre picker.
 @immutable
 class JellyfinGenreSelection {

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

@@ -40,6 +40,7 @@ class SettingsRepository {
   static const _jellyfinUserIdKey = 'jellyfin.userId';
   static const _jellyfinUserNameKey = 'jellyfin.userName';
   static const _jellyfinGenreFamiliesKey = 'jellyfin.genreFamilies';
+  static const _jellyfinAudioQualityKey = 'jellyfin.audioQuality';
 
   ThemePreference get themePreference {
     return ThemePreference.fromStorage(_prefs.getString(_themeKey));
@@ -126,6 +127,13 @@ class SettingsRepository {
     return value == null || value.isEmpty ? null : value;
   }
 
+  /// Direct play by default. Bitrate choices request a Jellyfin audio transcode.
+  JellyfinAudioQuality get jellyfinAudioQuality {
+    return JellyfinAudioQuality.fromStorage(
+      _prefs.getString(_jellyfinAudioQualityKey),
+    );
+  }
+
   String? get obdAdapterAddress {
     final value = _prefs.getString(_obdAddressKey)?.trim();
     return value == null || value.isEmpty ? null : value;
@@ -194,6 +202,10 @@ class SettingsRepository {
     return _prefs.setString(_jellyfinUserNameKey, value);
   }
 
+  Future<void> setJellyfinAudioQuality(JellyfinAudioQuality quality) {
+    return _prefs.setString(_jellyfinAudioQualityKey, quality.name);
+  }
+
   Future<void> setJellyfinSession({
     required String userId,
     required String userName,

+ 22 - 4
lib/data/services/jellyfin_library_service.dart

@@ -24,12 +24,14 @@ class JellyfinLibraryService {
   JellyfinLibraryService({
     required this.connectionProvider,
     required this.gateway,
+    this.audioQualityProvider,
     this.catalog = const JellyfinGenreCatalog(),
     this.genreCacheTtl = const Duration(minutes: 5),
   });
 
   final JellyfinConnection? Function() connectionProvider;
   final JellyfinLibraryGateway gateway;
+  final JellyfinAudioQuality Function()? audioQualityProvider;
   final JellyfinGenreCatalog catalog;
   final Duration genreCacheTtl;
 
@@ -133,8 +135,19 @@ class JellyfinLibraryService {
 
   /// Builds the direct/transcode URL. The token is never added here; the
   /// native player sends the Authorization header.
-  Uri streamUrl(JellyfinTrack track, {int maxStreamingBitrate = 1500000}) {
+  ///
+  /// Direct quality keeps the source file. A bitrate quality sends MP3 as the
+  /// only container/codec and caps the stream, which asks Jellyfin to transcode
+  /// high-resolution audio that a car head unit may play back incorrectly.
+  Uri streamUrl(
+    JellyfinTrack track, {
+    JellyfinAudioQuality? quality,
+    int? maxStreamingBitrate,
+  }) {
     final connection = _require();
+    final selected =
+        quality ?? audioQualityProvider?.call() ?? JellyfinAudioQuality.direct;
+    final bitrate = maxStreamingBitrate ?? selected.maxStreamingBitrate;
     return connection.baseUrl
         .resolve('Audio/${track.id}/universal')
         .replace(
@@ -142,12 +155,17 @@ class JellyfinLibraryService {
             'UserId': connection.userId,
             'DeviceId': connection.deviceId,
             'MediaSourceId': track.mediaSourceId,
-            'MaxStreamingBitrate': '$maxStreamingBitrate',
-            'Container': 'mp3,m4a,aac,flac,ogg,opus,wav',
             'TranscodingContainer': 'mp3',
             'TranscodingProtocol': 'http',
-            'AudioCodec': 'mp3,aac,flac,opus,vorbis',
             'EnableRedirection': 'true',
+            if (bitrate == null) ...{
+              'Container': 'mp3,m4a,aac,flac,ogg,opus,wav',
+              'AudioCodec': 'mp3,aac,flac,opus,vorbis',
+            } else ...{
+              'MaxStreamingBitrate': '$bitrate',
+              'Container': 'mp3',
+              'AudioCodec': 'mp3',
+            },
           },
         );
   }

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

@@ -69,6 +69,7 @@ class JellyfinController extends ChangeNotifier {
   String _deviceId = '';
   int _generation = 0;
   int _retryAttempt = 0;
+  JellyfinAudioQuality _audioQuality = JellyfinAudioQuality.direct;
   Timer? _pollTimer;
   Timer? _pairingTimer;
   Timer? _healthTimer;
@@ -88,6 +89,8 @@ class JellyfinController extends ChangeNotifier {
 
   bool get hasSession => _token != null;
 
+  JellyfinAudioQuality get audioQuality => _audioQuality;
+
   List<JellyfinGenreSelection> get genreSelections =>
       _settings.jellyfinGenreSelections;
 
@@ -98,6 +101,13 @@ class JellyfinController extends ChangeNotifier {
     _notify();
   }
 
+  Future<void> setAudioQuality(JellyfinAudioQuality quality) async {
+    if (_audioQuality == quality) return;
+    _audioQuality = quality;
+    _notify();
+    await _settings.setJellyfinAudioQuality(quality);
+  }
+
   /// A saved server URL plus an access token. Unlike [connected] this stays
   /// true during a temporary outage, so the Home button and dashboard can show
   /// an offline state instead of disappearing.
@@ -159,6 +169,7 @@ class JellyfinController extends ChangeNotifier {
   /// app is running so a cold start is never delayed by Jellyfin.
   Future<void> initialize() async {
     _enabled = _settings.jellyfinEnabled;
+    _audioQuality = _settings.jellyfinAudioQuality;
     _serverUrl = _settings.jellyfinServerUrl;
     _serverName = _settings.jellyfinServerName;
     _userName = _settings.jellyfinUserName;

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

@@ -5,6 +5,7 @@ import 'package:provider/provider.dart';
 
 import '../../core/theme/app_colors.dart';
 import '../../core/widgets/app_icon.dart';
+import '../../data/models/jellyfin_library.dart';
 import '../../data/services/jellyfin_library_service.dart';
 import '../jellyfin/jellyfin_playback_controller.dart';
 import 'jellyfin_controller.dart';
@@ -260,6 +261,9 @@ class _JellyfinSettingsCardState extends State<JellyfinSettingsCard>
             controller: jellyfin,
             onChoose: () => _openGenres(jellyfin),
           ),
+          const SizedBox(height: 4),
+          Divider(color: colors.border, height: 24),
+          _AudioQualityRow(controller: jellyfin),
           if (jellyfin.configured && widget.playback != null) ...[
             const SizedBox(height: 4),
             Divider(color: colors.border, height: 24),
@@ -401,6 +405,63 @@ class _PlaybackNotificationRowState extends State<_PlaybackNotificationRow>
   }
 }
 
+class _AudioQualityRow extends StatelessWidget {
+  const _AudioQualityRow({required this.controller});
+
+  final JellyfinController controller;
+
+  @override
+  Widget build(BuildContext context) {
+    final colors = context.colors;
+    final theme = Theme.of(context);
+    final quality = controller.audioQuality;
+    return Row(
+      children: [
+        Expanded(
+          child: Column(
+            crossAxisAlignment: CrossAxisAlignment.start,
+            children: [
+              Text('Streaming quality', style: theme.textTheme.bodyLarge),
+              const SizedBox(height: 2),
+              Text(
+                quality.isDirect
+                    ? 'Direct plays the original file. Applies to the next album.'
+                    : 'Transcodes to MP3 at ${quality.label}. '
+                          'Applies to the next album.',
+                style: theme.textTheme.bodySmall?.copyWith(
+                  color: colors.inkMuted,
+                ),
+              ),
+            ],
+          ),
+        ),
+        const SizedBox(width: 8),
+        DropdownButton<JellyfinAudioQuality>(
+          value: quality,
+          isDense: true,
+          borderRadius: BorderRadius.circular(12),
+          dropdownColor: colors.surface,
+          underline: const SizedBox.shrink(),
+          icon: Icon(
+            Icons.expand_more_rounded,
+            size: 20,
+            color: colors.inkMuted,
+          ),
+          style: theme.textTheme.bodyMedium?.copyWith(color: colors.ink),
+          items: [
+            for (final option in JellyfinAudioQuality.values)
+              DropdownMenuItem(value: option, child: Text(option.label)),
+          ],
+          onChanged: (value) {
+            if (value == null) return;
+            unawaited(controller.setAudioQuality(value));
+          },
+        ),
+      ],
+    );
+  }
+}
+
 class _GenreRow extends StatelessWidget {
   const _GenreRow({required this.controller, required this.onChoose});
 

+ 1 - 0
lib/main.dart

@@ -76,6 +76,7 @@ Future<void> main() async {
   final jellyfinLibrary = JellyfinLibraryService(
     connectionProvider: () => jellyfinController.connection,
     gateway: jellyfinGateway,
+    audioQualityProvider: () => jellyfinController.audioQuality,
   );
   final jellyfinPlayback = JellyfinPlaybackController(
     NativeJellyfinPlayback(),

+ 23 - 0
test/jellyfin_controller_test.dart

@@ -1,3 +1,4 @@
+import 'package:cardash_launcher/data/models/jellyfin_library.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';
@@ -249,6 +250,28 @@ void main() {
     expect(controller.userName, 'wasp');
   });
 
+  test('audio quality defaults to direct and persists', () async {
+    final settings = await _settings();
+    final controller = JellyfinController(
+      settings,
+      _FakeGateway(),
+      _MemoryStore(),
+    );
+    addTearDown(controller.dispose);
+    await controller.initialize();
+
+    expect(controller.audioQuality, JellyfinAudioQuality.direct);
+
+    await controller.setAudioQuality(JellyfinAudioQuality.kbps320);
+    expect(controller.audioQuality, JellyfinAudioQuality.kbps320);
+    expect(settings.jellyfinAudioQuality, JellyfinAudioQuality.kbps320);
+
+    final second = JellyfinController(settings, _FakeGateway(), _MemoryStore());
+    addTearDown(second.dispose);
+    await second.initialize();
+    expect(second.audioQuality, JellyfinAudioQuality.kbps320);
+  });
+
   test('urls are normalized with a scheme and without a trailing slash', () {
     expect(
       normalizeJellyfinUrl('jellyfin.example.com')?.toString(),

+ 35 - 0
test/jellyfin_library_service_test.dart

@@ -143,6 +143,41 @@ void main() {
     expect(text, isNot(contains('api_key')));
   });
 
+  test('audio quality requests an MP3 transcode at the selected cap', () {
+    final service = JellyfinLibraryService(
+      connectionProvider: () => testJellyfinConnection,
+      gateway: FakeJellyfinLibraryGateway(),
+      audioQualityProvider: () => JellyfinAudioQuality.kbps128,
+    );
+
+    final query = service
+        .streamUrl(track('a1', mediaSourceId: 'source-a1'))
+        .queryParameters;
+
+    expect(query['MaxStreamingBitrate'], '128000');
+    expect(query['Container'], 'mp3');
+    expect(query['AudioCodec'], 'mp3');
+    expect(query['TranscodingContainer'], 'mp3');
+  });
+
+  test('direct quality leaves the source codecs and has no bitrate cap', () {
+    final service = JellyfinLibraryService(
+      connectionProvider: () => testJellyfinConnection,
+      gateway: FakeJellyfinLibraryGateway(),
+    );
+
+    final query = service
+        .streamUrl(
+          track('a1', mediaSourceId: 'source-a1'),
+          quality: JellyfinAudioQuality.direct,
+        )
+        .queryParameters;
+
+    expect(query.containsKey('MaxStreamingBitrate'), isFalse);
+    expect(query['Container'], contains('flac'));
+    expect(query['AudioCodec'], contains('flac'));
+  });
+
   test('buildQueue produces a native queue with artwork and auth', () async {
     final gateway = FakeJellyfinLibraryGateway()
       ..tracks = {

+ 10 - 0
test/jellyfin_settings_card_test.dart

@@ -1,4 +1,5 @@
 import 'package:cardash_launcher/core/theme/app_theme.dart';
+import 'package:cardash_launcher/data/models/jellyfin_library.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';
@@ -115,6 +116,15 @@ void main() {
 
       expect(find.text('Server URL'), findsOneWidget);
       expect(find.text('Connect'), findsOneWidget);
+      expect(find.text('Streaming quality'), findsOneWidget);
+      expect(find.text('Direct'), findsOneWidget);
+
+      await tester.tap(find.text('Direct'));
+      await tester.pumpAndSettle();
+      await tester.tap(find.text('320 kbps').last);
+      await tester.pumpAndSettle();
+      expect(controller.audioQuality, JellyfinAudioQuality.kbps320);
+      expect(find.text('320 kbps'), findsOneWidget);
 
       await tester.enterText(find.byType(TextField), 'jellyfin.example.com');
       await tester.tap(find.text('Connect'));