Kaynağa Gözat

feat: add Jellyfin favorites and active-album shortcut

Adds a Favorites tab between Albums and Artists backed by Jellyfin favorites, with a scrollable 5/6-column grid and a centered No favorites state. The album page gains a heart that sets/unsets the server favorite and the Favorites tab refreshes on return. Opening the Jellyfin Home tile while an album is active now opens that album with the dashboard underneath, so Back returns to Albums. Staged for the next version; no version bump or APK.
Krasimir Kazakov 5 gün önce
ebeveyn
işleme
42977b2fed

+ 3 - 3
AGENTS.md

@@ -101,11 +101,11 @@ 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. Enabling speed with GPS as the source (no adapter selected or OBD speed off) asks for location permission; the normal resume path never prompts. GPS starts only with fine location and GPS, and clamps 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 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 is optional: Settings → Jellyfin owns pairing, the Home switch lives in General → Home buttons, and the Home tile opens the dashboard/player. If a Jellyfin album is active, the tile opens that album's page with the dashboard underneath; Back then lands on Albums. 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. Favorites are `GET /Items?userId=...&filters=IsFavorite&includeItemTypes=MusicAlbum&recursive=true&enableUserData=true`; toggle with `POST`/`DELETE /UserFavoriteItems/{itemId}?userId=...` (not `/Users/{userId}/FavoriteItems`). The album's `UserData.IsFavorite` is parsed and re-checked on the album page; the Favorites tab refreshes on selection and after an album page closes.
 - 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.
+- 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 **Favorites** tab sits between Albums and Artists (Artists stays disabled), uses the same 5/6 columns in a scrollable multi-row grid, and shows `No favorites` centered when empty. The album-page heart toggles the server favorite; the Favorites tab reloads after the album page closes. These favorites/Home-shortcut changes are staged for the next build, not in 1.2.1. 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.
 - 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.

+ 25 - 13
README.md

@@ -106,9 +106,12 @@ 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. A **Streaming quality** setting (next build)
-  selects Direct or 320/256/192/128 kbps MP3 transcoding; see Settings →
-  Jellyfin.
+  token and retries with backoff. The dashboard also has a **Favorites** tab
+  backed by Jellyfin favorites (a scrollable 5/6-column grid), with a heart on
+  the album page to add or remove the album. If an album is playing, tapping the
+  Jellyfin Home tile opens that album directly; Back returns to Albums. 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
@@ -224,7 +227,9 @@ first run shows a one-time tip pointing to this setting.
 | Action | Result |
 |---|---|
 | Tap Navigation / Music / Radio | Open the assigned app full-screen |
-| Tap Jellyfin | Open the Jellyfin dashboard (Albums) |
+| Tap Jellyfin | Open the playing album if one is active; otherwise the dashboard (Albums) |
+| Tap Jellyfin → Favorites | Browse favorited albums from Jellyfin |
+| Album page heart | Add or remove the album from Jellyfin favorites |
 | Long-press Navigation / Music / Radio | Pick a different installed app |
 | Long-press Jellyfin | Nothing; Jellyfin is a built-in source |
 | Tap an unassigned shortcut | Open the app picker |
@@ -301,11 +306,16 @@ the app resumes.
 - **Home button**: after pairing, turn on **Jellyfin** in Settings → General →
   Home buttons. The tile sits after Music and before Radio and opens the
   dashboard.
-- **Dashboard**: Recently played (derived from played tracks across your Jellyfin
-  devices), Recently added, and Random. Artists is shown but disabled for a
-  later release. Six albums per row when they fit, otherwise five.
+- **Dashboard**: **Favorites**, **Recently played** (derived from played tracks
+  across your Jellyfin devices), Recently added, and Random. The Favorites tab
+  shows the server-side favorites in a scrollable grid, six columns when they fit
+  and five otherwise; an empty list shows **No favorites**. Artists is shown but
+  disabled for a later release.
 - **Album page**: large cover, track list, previous / play-pause / next, current
-  track and the same volume/mute controls as Home.
+  track and the same volume/mute controls as Home. The heart adds or removes the
+  album through the Jellyfin API; the Favorites tab refreshes when you return.
+- **Home tile**: if a Jellyfin album is active, the Home tile opens that album's
+  page with the dashboard underneath, so Back returns to the Albums tab.
 - **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.
@@ -398,7 +408,7 @@ flutter test
 ./tool/check.sh
 ```
 
-The 142 tests cover PIN hashing/persistence, settings persistence, theme/accent,
+The 149 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
@@ -411,8 +421,9 @@ 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, the
-equalizer's stale-session guard, the streaming-quality persistence and the
-direct/MP3 transcode URL parameters. Native
+equalizer's stale-session guard, the streaming-quality persistence, the
+direct/MP3 transcode URL parameters, Jellyfin favorite toggling, the Favorites
+tab/grid/empty state and the playing-album Home shortcut. 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.
@@ -423,8 +434,9 @@ 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 the current published release; the streaming-quality setting is
-staged for the next build. The emulator has verified the in-app update path.
+universal APK is the current published release; the streaming-quality setting,
+Favorites and the playing-album Home shortcut are staged for the next build. The
+emulator has verified the in-app update path.
 
 ## Troubleshooting the emulator
 

+ 15 - 0
assets/icons/heart-filled.svg

@@ -0,0 +1,15 @@
+<!-- @license lucide-static v1.64.0 - ISC -->
+<svg
+  class="lucide lucide-heart"
+  xmlns="http://www.w3.org/2000/svg"
+  width="24"
+  height="24"
+  viewBox="0 0 24 24"
+  fill="currentColor"
+  stroke="currentColor"
+  stroke-width="2"
+  stroke-linecap="round"
+  stroke-linejoin="round"
+>
+  <path d="M2 9.5a5.5 5.5 0 0 1 9.591-3.676.56.56 0 0 0 .818 0A5.49 5.49 0 0 1 22 9.5c0 2.29-1.5 4-3 5.5l-5.492 5.313a2 2 0 0 1-3 .019L5 15c-1.5-1.5-3-3.2-3-5.5" />
+</svg>

+ 10 - 5
docs/IMPLEMENTATION_PLAN.md

@@ -331,11 +331,16 @@ Android (Kotlin)
 
 ## 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 (unreleased): adds a Jellyfin **Favorites** tab between Albums and
+  Artists, backed by the Jellyfin favorites API; a scrollable 5/6-column grid and
+  a centered `No favorites` empty state. The album page gains a heart that sets
+  or unsets the server favorite, and the Favorites tab refreshes when you return.
+  The Home Jellyfin tile opens the currently active album with the dashboard
+  underneath, so Back returns to Albums instead of Home. Also adds a **Streaming
+  quality** dropdown: Direct (default) or 320/256/192/128 kbps MP3 transcoding.
+  The quality 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. 149 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

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

@@ -108,6 +108,13 @@ Next-version work staged (unreleased):
   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.
+- **Favorites** tab between Albums and Artists, backed by Jellyfin's
+  `/Items?filters=IsFavorite` list. It uses the same 5/6-column rule in a
+  scrollable multi-row grid and shows a centered `No favorites` when empty. The
+  album page has a heart that POSTs/DELETEs `/UserFavoriteItems/{itemId}` and
+  refreshes the Favorites tab on return.
+- The Home Jellyfin tile opens the active album page when one is playing, with
+  the dashboard underneath; Back returns to the Albums tab.
 
 ## 2. Scope and non-goals
 

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

@@ -111,6 +111,7 @@ class JellyfinAlbum {
     this.childCount,
     this.dateCreated,
     this.lastPlayed,
+    this.isFavorite = false,
   });
 
   final String id;
@@ -121,6 +122,7 @@ class JellyfinAlbum {
   final int? childCount;
   final DateTime? dateCreated;
   final DateTime? lastPlayed;
+  final bool isFavorite;
 
   bool get hasArtwork => (imageTag?.isNotEmpty ?? false);
 
@@ -136,6 +138,7 @@ class JellyfinAlbum {
       childCount: _int(json['ChildCount']),
       dateCreated: _date(json['DateCreated']),
       lastPlayed: _lastPlayed(json['UserData']),
+      isFavorite: _isFavorite(json['UserData']),
     );
   }
 
@@ -294,6 +297,10 @@ DateTime? _lastPlayed(Object? userData) {
   return _date(userData['LastPlayedDate']);
 }
 
+bool _isFavorite(Object? userData) {
+  return userData is Map && userData['IsFavorite'] == true;
+}
+
 String? _imageTag(Object? imageTags) {
   if (imageTags is! Map) return null;
   return _stringOrNull(imageTags['Primary']);

+ 12 - 0
lib/data/services/jellyfin_library_service.dart

@@ -129,6 +129,18 @@ class JellyfinLibraryService {
     return gateway.randomAlbums(_require(), genres: rawGenres, limit: limit);
   }
 
+  Future<List<JellyfinAlbum>> favoriteAlbums() {
+    return gateway.favoriteAlbums(_require());
+  }
+
+  Future<bool> isFavorite(String albumId) {
+    return gateway.isAlbumFavorite(_require(), albumId);
+  }
+
+  Future<void> setFavorite(String albumId, bool favorite) {
+    return gateway.setAlbumFavorite(_require(), albumId, favorite: favorite);
+  }
+
   Future<List<JellyfinTrack>> albumTracks(String albumId) {
     return gateway.albumTracks(_require(), albumId);
   }

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

@@ -48,6 +48,16 @@ abstract class JellyfinLibraryGateway {
     int limit = 5,
   });
 
+  Future<List<JellyfinAlbum>> favoriteAlbums(JellyfinConnection connection);
+
+  Future<bool> isAlbumFavorite(JellyfinConnection connection, String albumId);
+
+  Future<void> setAlbumFavorite(
+    JellyfinConnection connection,
+    String albumId, {
+    required bool favorite,
+  });
+
   Future<List<JellyfinTrack>> albumTracks(
     JellyfinConnection connection,
     String albumId,
@@ -297,6 +307,81 @@ class HttpJellyfinGateway implements JellyfinGateway, JellyfinLibraryGateway {
         .toList(growable: false);
   }
 
+  @override
+  Future<List<JellyfinAlbum>> favoriteAlbums(
+    JellyfinConnection connection,
+  ) async {
+    final uri = connection.baseUrl
+        .resolve('Items')
+        .replace(
+          queryParameters: {
+            'userId': connection.userId,
+            'recursive': 'true',
+            'includeItemTypes': 'MusicAlbum',
+            'filters': 'IsFavorite',
+            'sortBy': 'SortName',
+            'sortOrder': 'Ascending',
+            'fields': 'Genres,DateCreated,ChildCount,AlbumArtist,Artists,PrimaryImageAspectRatio',
+            'enableUserData': 'true',
+            'enableImageTypes': 'Primary',
+            'imageTypeLimit': '1',
+          },
+        );
+    final json = await _getJson(connection, uri);
+    return _items(json)
+        .map(JellyfinAlbum.fromJson)
+        .where((album) => album.id.isNotEmpty)
+        .toList(growable: false);
+  }
+
+  @override
+  Future<bool> isAlbumFavorite(
+    JellyfinConnection connection,
+    String albumId,
+  ) async {
+    if (albumId.isEmpty) return false;
+    final uri = connection.baseUrl
+        .resolve('Items')
+        .replace(
+          queryParameters: {
+            'userId': connection.userId,
+            'ids': albumId,
+            'includeItemTypes': 'MusicAlbum',
+            'enableUserData': 'true',
+            'limit': '1',
+          },
+        );
+    final json = await _getJson(connection, uri);
+    final items = _items(json);
+    if (items.isEmpty) return false;
+    final userData = items.first['UserData'];
+    return userData is Map && userData['IsFavorite'] == true;
+  }
+
+  @override
+  Future<void> setAlbumFavorite(
+    JellyfinConnection connection,
+    String albumId, {
+    required bool favorite,
+  }) async {
+    if (albumId.isEmpty) {
+      throw JellyfinException('Unknown Jellyfin album.');
+    }
+    final uri = connection.baseUrl
+        .resolve('UserFavoriteItems/$albumId')
+        .replace(queryParameters: {'userId': connection.userId});
+    final response = await _send(
+      favorite ? 'POST' : 'DELETE',
+      uri,
+      deviceId: connection.deviceId,
+      token: connection.accessToken,
+    );
+    if (response.status != HttpStatus.ok &&
+        response.status != HttpStatus.noContent) {
+      throw _statusError(response);
+    }
+  }
+
   @override
   Future<List<JellyfinTrack>> albumTracks(
     JellyfinConnection connection,

+ 5 - 1
lib/features/home/home_screen.dart

@@ -401,8 +401,12 @@ class _HomeScreenState extends State<HomeScreen> {
   }
 
   void _openJellyfin(BuildContext context) {
+    final playback = context.read<JellyfinPlaybackController>();
+    final album = playback.isActive ? playback.currentAlbum : null;
     Navigator.of(context).push(
-      MaterialPageRoute<void>(builder: (_) => const JellyfinDashboardScreen()),
+      MaterialPageRoute<void>(
+        builder: (_) => JellyfinDashboardScreen(openAlbum: album),
+      ),
     );
   }
 

+ 107 - 2
lib/features/jellyfin/jellyfin_album_screen.dart

@@ -5,6 +5,7 @@ import 'package:provider/provider.dart';
 
 import '../../core/theme/app_colors.dart';
 import '../../core/widgets/app_icon.dart';
+import '../../core/widgets/pressable.dart';
 import '../../data/models/jellyfin_library.dart';
 import '../../data/models/media_playback.dart';
 import '../../data/services/jellyfin_library_service.dart';
@@ -25,12 +26,16 @@ class JellyfinAlbumScreen extends StatefulWidget {
 class _JellyfinAlbumScreenState extends State<JellyfinAlbumScreen> {
   late final JellyfinLibraryService _library;
   late Future<List<JellyfinTrack>> _tracksFuture;
+  late bool _isFavorite;
+  bool _favoriteBusy = false;
 
   @override
   void initState() {
     super.initState();
     _library = context.read<JellyfinLibraryService>();
     _tracksFuture = _library.albumTracks(widget.album.id);
+    _isFavorite = widget.album.isFavorite;
+    unawaited(_loadFavorite());
   }
 
   void _reloadTracks() {
@@ -39,6 +44,36 @@ class _JellyfinAlbumScreenState extends State<JellyfinAlbumScreen> {
     });
   }
 
+  Future<void> _loadFavorite() async {
+    try {
+      final favorite = await _library.isFavorite(widget.album.id);
+      if (!mounted) return;
+      setState(() => _isFavorite = favorite);
+    } catch (_) {
+      // Keep the album's incoming state if the lookup fails.
+    }
+  }
+
+  Future<void> _toggleFavorite() async {
+    if (_favoriteBusy) return;
+    final next = !_isFavorite;
+    setState(() => _favoriteBusy = true);
+    try {
+      await _library.setFavorite(widget.album.id, next);
+      if (!mounted) return;
+      setState(() {
+        _isFavorite = next;
+        _favoriteBusy = false;
+      });
+    } catch (_) {
+      if (!mounted) return;
+      setState(() => _favoriteBusy = false);
+      ScaffoldMessenger.of(context).showSnackBar(
+        const SnackBar(content: Text('Could not update favorites.')),
+      );
+    }
+  }
+
   @override
   Widget build(BuildContext context) {
     final colors = context.colors;
@@ -313,7 +348,12 @@ class _JellyfinAlbumScreenState extends State<JellyfinAlbumScreen> {
             child: Column(
               crossAxisAlignment: CrossAxisAlignment.stretch,
               children: [
-                _AlbumHeader(album: widget.album),
+                _AlbumHeader(
+                  album: widget.album,
+                  isFavorite: _isFavorite,
+                  favoriteBusy: _favoriteBusy,
+                  onToggleFavorite: _toggleFavorite,
+                ),
                 const SizedBox(height: 14),
                 Expanded(
                   child: LayoutBuilder(
@@ -347,9 +387,17 @@ class _JellyfinAlbumScreenState extends State<JellyfinAlbumScreen> {
 }
 
 class _AlbumHeader extends StatelessWidget {
-  const _AlbumHeader({required this.album});
+  const _AlbumHeader({
+    required this.album,
+    required this.isFavorite,
+    required this.favoriteBusy,
+    required this.onToggleFavorite,
+  });
 
   final JellyfinAlbum album;
+  final bool isFavorite;
+  final bool favoriteBusy;
+  final VoidCallback onToggleFavorite;
 
   @override
   Widget build(BuildContext context) {
@@ -389,11 +437,68 @@ class _AlbumHeader extends StatelessWidget {
             ],
           ),
         ),
+        const SizedBox(width: 12),
+        _FavoriteButton(
+          isFavorite: isFavorite,
+          busy: favoriteBusy,
+          onTap: onToggleFavorite,
+        ),
       ],
     );
   }
 }
 
+class _FavoriteButton extends StatelessWidget {
+  const _FavoriteButton({
+    required this.isFavorite,
+    required this.busy,
+    required this.onTap,
+  });
+
+  final bool isFavorite;
+  final bool busy;
+  final VoidCallback onTap;
+
+  @override
+  Widget build(BuildContext context) {
+    final colors = context.colors;
+    return Pressable(
+      onTap: busy ? null : onTap,
+      semanticLabel: isFavorite ? 'Remove from favorites' : 'Add to favorites',
+      child: Container(
+        width: 46,
+        height: 46,
+        decoration: BoxDecoration(
+          color: isFavorite ? colors.accentSoft : colors.surface,
+          shape: BoxShape.circle,
+          border: Border.all(
+            color: isFavorite
+                ? colors.accent.withValues(alpha: 0.3)
+                : colors.border,
+          ),
+        ),
+        alignment: Alignment.center,
+        child: busy
+            ? SizedBox(
+                width: 16,
+                height: 16,
+                child: CircularProgressIndicator(
+                  strokeWidth: 2,
+                  color: colors.accent,
+                ),
+              )
+            : AppIcon(
+                isFavorite
+                    ? 'assets/icons/heart-filled.svg'
+                    : 'assets/icons/heart.svg',
+                size: 21,
+                color: isFavorite ? colors.accent : colors.inkMuted,
+              ),
+      ),
+    );
+  }
+}
+
 class _TrackRow extends StatelessWidget {
   const _TrackRow({
     required this.track,

+ 44 - 0
lib/features/jellyfin/jellyfin_dashboard_controller.dart

@@ -20,12 +20,16 @@ class JellyfinDashboardController extends ChangeNotifier {
   List<JellyfinAlbum> _latestPlayed = const [];
   List<JellyfinAlbum> _latestAdded = const [];
   List<JellyfinAlbum> _random = const [];
+  List<JellyfinAlbum> _favorites = const [];
   bool _loading = false;
   bool _randomLoading = false;
+  bool _favoritesLoading = false;
   bool _hasLoaded = false;
+  bool _favoritesLoaded = false;
   bool _disposed = false;
   String? _error;
   String? _randomError;
+  String? _favoritesError;
 
   List<JellyfinAlbum> get latestPlayed => _latestPlayed;
 
@@ -33,16 +37,24 @@ class JellyfinDashboardController extends ChangeNotifier {
 
   List<JellyfinAlbum> get random => _random;
 
+  List<JellyfinAlbum> get favorites => _favorites;
+
   bool get loading => _loading;
 
   bool get randomLoading => _randomLoading;
 
+  bool get favoritesLoading => _favoritesLoading;
+
   bool get hasLoaded => _hasLoaded;
 
+  bool get favoritesLoaded => _favoritesLoaded;
+
   String? get error => _error;
 
   String? get randomError => _randomError;
 
+  String? get favoritesError => _favoritesError;
+
   bool get hasGenres => jellyfin.genreSelections.isNotEmpty;
 
   List<JellyfinGenreSelection> get selections => jellyfin.genreSelections;
@@ -93,6 +105,38 @@ class JellyfinDashboardController extends ChangeNotifier {
 
   Future<void> retry() => load();
 
+  Future<void> loadFavorites() async {
+    if (_disposed || _favoritesLoading) return;
+    if (!library.isConfigured) {
+      _favorites = const [];
+      _favoritesError = 'Pair Jellyfin first.';
+      _favoritesLoaded = true;
+      _notify();
+      return;
+    }
+    _favoritesLoading = true;
+    _favoritesError = null;
+    _notify();
+    try {
+      final favorites = await library.favoriteAlbums();
+      if (_disposed) return;
+      _favorites = favorites;
+      _favoritesLoaded = true;
+    } catch (error) {
+      if (_disposed) return;
+      _favoritesError = _messageFor(error);
+      _favorites = const [];
+      _favoritesLoaded = true;
+    } finally {
+      if (!_disposed) {
+        _favoritesLoading = false;
+        _notify();
+      }
+    }
+  }
+
+  Future<void> retryFavorites() => loadFavorites();
+
   Future<void> shuffle() async {
     if (_disposed || _randomLoading) return;
     if (jellyfin.genreSelections.isEmpty) {

+ 132 - 16
lib/features/jellyfin/jellyfin_dashboard_screen.dart

@@ -16,7 +16,12 @@ import 'jellyfin_playback_controller.dart';
 import 'widgets/jellyfin_back_button.dart';
 
 class JellyfinDashboardScreen extends StatelessWidget {
-  const JellyfinDashboardScreen({super.key});
+  const JellyfinDashboardScreen({super.key, this.openAlbum});
+
+  /// When set, the album page is pushed once the dashboard is mounted. Used
+  /// when Home is tapped while a Jellyfin album is already playing, so Back
+  /// from the album lands on Albums instead of Home.
+  final JellyfinAlbum? openAlbum;
 
   @override
   Widget build(BuildContext context) {
@@ -29,14 +34,53 @@ class JellyfinDashboardScreen extends StatelessWidget {
         unawaited(controller.load());
         return controller;
       },
-      child: const _JellyfinDashboardBody(),
+      child: _JellyfinDashboardRoute(openAlbum: openAlbum),
     );
   }
 }
 
-class _JellyfinDashboardBody extends StatelessWidget {
+class _JellyfinDashboardRoute extends StatefulWidget {
+  const _JellyfinDashboardRoute({this.openAlbum});
+
+  final JellyfinAlbum? openAlbum;
+
+  @override
+  State<_JellyfinDashboardRoute> createState() =>
+      _JellyfinDashboardRouteState();
+}
+
+class _JellyfinDashboardRouteState extends State<_JellyfinDashboardRoute> {
+  @override
+  void initState() {
+    super.initState();
+    final album = widget.openAlbum;
+    if (album == null) return;
+    WidgetsBinding.instance.addPostFrameCallback((_) {
+      if (!mounted) return;
+      Navigator.of(context).push(
+        MaterialPageRoute<void>(
+          builder: (_) => JellyfinAlbumScreen(album: album),
+        ),
+      );
+    });
+  }
+
+  @override
+  Widget build(BuildContext context) => const _JellyfinDashboardBody();
+}
+
+enum _JellyfinTab { albums, favorites }
+
+class _JellyfinDashboardBody extends StatefulWidget {
   const _JellyfinDashboardBody();
 
+  @override
+  State<_JellyfinDashboardBody> createState() => _JellyfinDashboardBodyState();
+}
+
+class _JellyfinDashboardBodyState extends State<_JellyfinDashboardBody> {
+  _JellyfinTab _tab = _JellyfinTab.albums;
+
   @override
   Widget build(BuildContext context) {
     final colors = context.colors;
@@ -53,7 +97,52 @@ class _JellyfinDashboardBody extends StatelessWidget {
     String? imageFor(JellyfinAlbum album) =>
         library.imageUrl(album.id, album.imageTag)?.toString();
 
+    Widget favoritesBody(int columns) {
+      final error = dashboard.favoritesError;
+      if (error != null) {
+        return _DashboardError(
+          message: error,
+          onRetry: () => unawaited(dashboard.retryFavorites()),
+        );
+      }
+      if (dashboard.favoritesLoading && !dashboard.favoritesLoaded) {
+        return const Center(child: CircularProgressIndicator());
+      }
+      if (dashboard.favorites.isEmpty) {
+        return Center(
+          child: Text(
+            'No favorites',
+            style: theme.textTheme.bodyLarge?.copyWith(color: colors.inkMuted),
+          ),
+        );
+      }
+      return GridView.builder(
+        padding: const EdgeInsets.only(bottom: 8),
+        gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
+          crossAxisCount: columns,
+          crossAxisSpacing: 12,
+          mainAxisSpacing: 14,
+          childAspectRatio: 0.78,
+        ),
+        itemCount: dashboard.favorites.length,
+        itemBuilder: (context, index) {
+          final album = dashboard.favorites[index];
+          return _AlbumCard(
+            album: album,
+            imageUrl: imageFor(album),
+            headers: headers,
+            isCurrent: currentId == album.id,
+            isPlaying: currentPlaying && currentId == album.id,
+            onTap: () => _openAlbum(context, dashboard, playback, album),
+          );
+        },
+      );
+    }
+
     Widget body(int columns) {
+      if (_tab == _JellyfinTab.favorites) {
+        return favoritesBody(columns);
+      }
       if (dashboard.loading && !dashboard.hasLoaded) {
         return const Center(child: CircularProgressIndicator());
       }
@@ -136,7 +225,15 @@ class _JellyfinDashboardBody extends StatelessWidget {
               children: [
                 _DashboardHeader(serverName: jellyfin.serverName),
                 const SizedBox(height: 10),
-                const _DashboardTabs(),
+                _DashboardTabs(
+                  selected: _tab,
+                  onSelect: (tab) {
+                    setState(() => _tab = tab);
+                    if (tab == _JellyfinTab.favorites) {
+                      unawaited(dashboard.loadFavorites());
+                    }
+                  },
+                ),
                 const SizedBox(height: 10),
                 Expanded(
                   child: LayoutBuilder(
@@ -179,10 +276,14 @@ class _JellyfinDashboardBody extends StatelessWidget {
     } else {
       unawaited(started);
     }
-    Navigator.of(context).push(
-      MaterialPageRoute<void>(
-        builder: (_) => JellyfinAlbumScreen(album: album),
-      ),
+    unawaited(
+      Navigator.of(context)
+          .push(
+            MaterialPageRoute<void>(
+              builder: (_) => JellyfinAlbumScreen(album: album),
+            ),
+          )
+          .then((_) => dashboard.loadFavorites()),
     );
   }
 }
@@ -226,22 +327,29 @@ class _DashboardHeader extends StatelessWidget {
 }
 
 class _DashboardTabs extends StatelessWidget {
-  const _DashboardTabs();
+  const _DashboardTabs({required this.selected, required this.onSelect});
+
+  final _JellyfinTab selected;
+  final ValueChanged<_JellyfinTab> onSelect;
 
   @override
   Widget build(BuildContext context) {
     final theme = Theme.of(context);
     final colors = context.colors;
-    return Row(
-      children: [
-        Column(
+
+    Widget tab(String label, _JellyfinTab value) {
+      final isSelected = selected == value;
+      return Pressable(
+        onTap: () => onSelect(value),
+        semanticLabel: label,
+        child: Column(
           mainAxisSize: MainAxisSize.min,
           children: [
             Text(
-              'Albums',
+              label,
               style: theme.textTheme.titleMedium?.copyWith(
-                color: colors.ink,
-                fontWeight: FontWeight.w700,
+                color: isSelected ? colors.ink : colors.inkMuted,
+                fontWeight: isSelected ? FontWeight.w700 : FontWeight.w500,
               ),
             ),
             const SizedBox(height: 4),
@@ -249,12 +357,20 @@ class _DashboardTabs extends StatelessWidget {
               width: 38,
               height: 2,
               decoration: BoxDecoration(
-                color: colors.accent,
+                color: isSelected ? colors.accent : Colors.transparent,
                 borderRadius: BorderRadius.circular(2),
               ),
             ),
           ],
         ),
+      );
+    }
+
+    return Row(
+      children: [
+        tab('Albums', _JellyfinTab.albums),
+        const SizedBox(width: 28),
+        tab('Favorites', _JellyfinTab.favorites),
         const SizedBox(width: 28),
         Text(
           'Artists',

+ 8 - 0
test/jellyfin_album_screen_test.dart

@@ -20,6 +20,7 @@ void main() {
 
     final jellyfin = await configuredJellyfinController();
     final gateway = FakeJellyfinLibraryGateway()
+      ..albumFavorite = true
       ..tracks = {
         'album-1': [
           track('t1', albumId: 'album-1', albumName: 'Album 1', index: 1),
@@ -69,8 +70,15 @@ void main() {
     expect(find.byType(VolumeCard), findsOneWidget);
     expect(find.bySemanticsLabel('Previous track'), findsOneWidget);
     expect(find.bySemanticsLabel('Next track'), findsOneWidget);
+    expect(find.bySemanticsLabel('Remove from favorites'), findsOneWidget);
     expect(tester.takeException(), isNull);
 
+    await tester.tap(find.bySemanticsLabel('Remove from favorites'));
+    await tester.pumpAndSettle();
+    expect(gateway.setFavoriteCalls, 1);
+    expect(gateway.lastFavoriteValue, isFalse);
+    expect(find.bySemanticsLabel('Add to favorites'), findsOneWidget);
+
     await tester.tap(find.bySemanticsLabel('Next track'));
     await tester.pumpAndSettle();
     expect(platform.commands, contains('next'));

+ 20 - 0
test/jellyfin_dashboard_controller_test.dart

@@ -172,4 +172,24 @@ void main() {
     expect(dashboard.hasLoaded, isTrue);
     expect(dashboard.error, contains('Pair Jellyfin'));
   });
+
+  test('loads favorites and surfaces a favorite error', () async {
+    final fixture = await _fixture();
+    addTearDown(fixture.dashboard.dispose);
+    addTearDown(fixture.jellyfin.dispose);
+    fixture.gateway.favorites = [album('fav-1'), album('fav-2')];
+
+    await fixture.dashboard.loadFavorites();
+
+    expect(fixture.dashboard.favorites.length, 2);
+    expect(fixture.dashboard.favorites.first.id, 'fav-1');
+    expect(fixture.dashboard.favoritesLoading, isFalse);
+    expect(fixture.dashboard.favoritesLoaded, isTrue);
+
+    fixture.gateway.favoriteError = JellyfinUnreachable('offline');
+    await fixture.dashboard.retryFavorites();
+
+    expect(fixture.dashboard.favoritesError, contains('unreachable'));
+    expect(fixture.dashboard.favorites, isEmpty);
+  });
 }

+ 64 - 2
test/jellyfin_dashboard_screen_test.dart

@@ -2,6 +2,7 @@ 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/services/jellyfin_library_service.dart';
+import 'package:cardash_launcher/features/home/system_controller.dart';
 import 'package:cardash_launcher/features/jellyfin/jellyfin_dashboard_screen.dart';
 import 'package:cardash_launcher/features/jellyfin/jellyfin_playback_controller.dart';
 import 'package:cardash_launcher/features/settings/jellyfin_controller.dart';
@@ -72,11 +73,17 @@ Future<_Fixture> _fixture({List<String> genres = const ['metal']}) async {
   );
 }
 
-Future<void> _pump(WidgetTester tester, _Fixture fixture) async {
+Future<void> _pump(
+  WidgetTester tester,
+  _Fixture fixture, {
+  JellyfinAlbum? openAlbum,
+}) async {
   await tester.binding.setSurfaceSize(const Size(1280, 800));
   addTearDown(() => tester.binding.setSurfaceSize(null));
   addTearDown(fixture.playback.dispose);
   addTearDown(fixture.jellyfin.dispose);
+  final system = SystemController(FakeSystemPlatform());
+  addTearDown(system.dispose);
 
   await tester.pumpWidget(
     MultiProvider(
@@ -88,10 +95,11 @@ Future<void> _pump(WidgetTester tester, _Fixture fixture) async {
         ChangeNotifierProvider<JellyfinPlaybackController>.value(
           value: fixture.playback,
         ),
+        ChangeNotifierProvider<SystemController>.value(value: system),
       ],
       child: MaterialApp(
         theme: AppTheme.light(),
-        home: const JellyfinDashboardScreen(),
+        home: JellyfinDashboardScreen(openAlbum: openAlbum),
       ),
     ),
   );
@@ -142,4 +150,58 @@ void main() {
     expect(find.text('Retry'), findsOneWidget);
     expect(find.bySemanticsLabel('Shuffle random albums'), findsNothing);
   });
+
+  testWidgets('favorites tab shows a scrollable album grid', (tester) async {
+    final fixture = await _fixture();
+    fixture.gateway.favorites = [
+      album('fav-1'),
+      album('fav-2'),
+      album('fav-3'),
+    ];
+    await _pump(tester, fixture);
+
+    await tester.tap(find.text('Favorites'));
+    await tester.pumpAndSettle();
+
+    expect(find.text('Album fav-1'), findsOneWidget);
+    expect(find.text('Album fav-2'), findsOneWidget);
+    expect(find.text('Album fav-3'), findsOneWidget);
+    expect(find.text('Recently played'), findsNothing);
+    expect(fixture.gateway.favoriteCalls, 1);
+    expect(tester.takeException(), isNull);
+  });
+
+  testWidgets('favorites tab shows No favorites when empty', (tester) async {
+    final fixture = await _fixture();
+    await _pump(tester, fixture);
+
+    await tester.tap(find.text('Favorites'));
+    await tester.pumpAndSettle();
+
+    expect(find.text('No favorites'), findsOneWidget);
+    expect(find.text('Recently played'), findsNothing);
+    expect(tester.takeException(), isNull);
+  });
+
+  testWidgets('openAlbum opens the album page and Back returns to Albums', (
+    tester,
+  ) async {
+    final fixture = await _fixture();
+    fixture.gateway.tracks = {
+      'album-a': [track('a1', albumId: 'album-a', albumName: 'Album A')],
+    };
+
+    await _pump(tester, fixture, openAlbum: album('album-a'));
+
+    expect(find.text('Album album-a'), findsWidgets);
+    expect(find.text('Tracks'), findsOneWidget);
+
+    await tester.tap(find.bySemanticsLabel('Back').last);
+    await tester.pumpAndSettle();
+
+    expect(find.text('Recently played'), findsOneWidget);
+    expect(find.text('Albums'), findsOneWidget);
+    expect(find.text('Favorites'), findsOneWidget);
+    expect(tester.takeException(), isNull);
+  });
 }

+ 47 - 0
test/jellyfin_fakes.dart

@@ -171,12 +171,15 @@ class FakeJellyfinLibraryGateway implements JellyfinLibraryGateway {
   List<JellyfinTrack> played = const [];
   List<JellyfinAlbum> added = const [];
   List<JellyfinAlbum> random = const [];
+  List<JellyfinAlbum> favorites = const [];
   Map<String, List<JellyfinTrack>> tracks = const {};
   List<String> libraryIds = const ['library-1'];
   List<String> genres = const [];
   Uint8List? artwork;
   Object? error;
   Object? randomError;
+  Object? favoriteError;
+  bool albumFavorite = false;
 
   int playedCalls = 0;
   int addedCalls = 0;
@@ -184,8 +187,12 @@ class FakeJellyfinLibraryGateway implements JellyfinLibraryGateway {
   int genreCalls = 0;
   int trackCalls = 0;
   int artworkCalls = 0;
+  int favoriteCalls = 0;
+  int favoriteStateCalls = 0;
+  int setFavoriteCalls = 0;
   List<String>? lastRandomGenres;
   String? lastAlbumId;
+  bool? lastFavoriteValue;
 
   void resetCounters() {
     playedCalls = 0;
@@ -194,6 +201,9 @@ class FakeJellyfinLibraryGateway implements JellyfinLibraryGateway {
     genreCalls = 0;
     trackCalls = 0;
     artworkCalls = 0;
+    favoriteCalls = 0;
+    favoriteStateCalls = 0;
+    setFavoriteCalls = 0;
   }
 
   @override
@@ -228,6 +238,43 @@ class FakeJellyfinLibraryGateway implements JellyfinLibraryGateway {
     return random.take(limit).toList(growable: false);
   }
 
+  @override
+  Future<List<JellyfinAlbum>> favoriteAlbums(
+    JellyfinConnection connection,
+  ) async {
+    favoriteCalls++;
+    if (favoriteError != null) throw favoriteError!;
+    return favorites;
+  }
+
+  @override
+  Future<bool> isAlbumFavorite(
+    JellyfinConnection connection,
+    String albumId,
+  ) async {
+    favoriteStateCalls++;
+    if (favoriteError != null) throw favoriteError!;
+    return albumFavorite || favorites.any((album) => album.id == albumId);
+  }
+
+  @override
+  Future<void> setAlbumFavorite(
+    JellyfinConnection connection,
+    String albumId, {
+    required bool favorite,
+  }) async {
+    setFavoriteCalls++;
+    lastAlbumId = albumId;
+    lastFavoriteValue = favorite;
+    if (favoriteError != null) throw favoriteError!;
+    albumFavorite = favorite;
+    if (!favorite) {
+      favorites = favorites
+          .where((album) => album.id != albumId)
+          .toList(growable: false);
+    }
+  }
+
   @override
   Future<List<JellyfinTrack>> albumTracks(
     JellyfinConnection connection,

+ 19 - 0
test/jellyfin_library_service_test.dart

@@ -178,6 +178,25 @@ void main() {
     expect(query['AudioCodec'], contains('flac'));
   });
 
+  test('favorites are read and toggled through the gateway', () async {
+    final gateway = FakeJellyfinLibraryGateway()
+      ..favorites = [album('fav-1')]
+      ..albumFavorite = true;
+    final service = JellyfinLibraryService(
+      connectionProvider: () => testJellyfinConnection,
+      gateway: gateway,
+    );
+
+    final favorites = await service.favoriteAlbums();
+    expect(favorites.single.id, 'fav-1');
+    expect(await service.isFavorite('fav-1'), isTrue);
+
+    await service.setFavorite('fav-1', false);
+    expect(gateway.setFavoriteCalls, 1);
+    expect(gateway.lastAlbumId, 'fav-1');
+    expect(gateway.lastFavoriteValue, isFalse);
+  });
+
   test('buildQueue produces a native queue with artwork and auth', () async {
     final gateway = FakeJellyfinLibraryGateway()
       ..tracks = {