Explorar o código

feat: redesign Jellyfin album controls

Replaces the album ring/volume card with a single seekable progress slider and elapsed/total time, a centered previous/play-pause/next row, and a compact minus/mute/plus volume card beside the now-playing block. The Home card is unchanged. Bumps to 1.2.4+24 and includes the release APK, checksum and notes.
Krasimir Kazakov hai 5 días
pai
achega
78c0917b26

+ 2 - 2
AGENTS.md

@@ -105,7 +105,7 @@ notifyListeners();
 - 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.
 - 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. 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 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 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 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. Progressive/transcoded streams often report no Media3 duration; the bridge falls back to the queued MediaItem duration and `JellyfinPlaybackController.mediaPlayback` falls back to the current queue item so progress and seeking have a denominator.
 - 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 ships in 1.2.2.
 - 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 ships in 1.2.2.
 - 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 ship in 1.2.2. 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 ship in 1.2.2. 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.
 - 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.
@@ -116,7 +116,7 @@ notifyListeners();
 - Connection attempts are bounded (three failures, 12 s connect watchdog). After they are exhausted the card shows **Retry connection**; do not restore an infinite reconnect loop.
 - Connection attempts are bounded (three failures, 12 s connect watchdog). After they are exhausted the card shows **Retry connection**; do not restore an infinite reconnect loop.
 - When Bluetooth is off, the OBD-II card shows **Open Bluetooth settings**. On resume the controller checks adapter power state and reuses the cached bonded list; it only lists devices when the list is unknown, after **Find**, or when the card opens.
 - When Bluetooth is off, the OBD-II card shows **Open Bluetooth settings**. On resume the controller checks adapter power state and reuses the cached bonded list; it only lists devices when the list is unknown, after **Find**, or when the card opens.
 - Pairing happens in Android settings, never in CarDash.
 - Pairing happens in Android settings, never in CarDash.
-- The circular ring is song progress; its centre is play/pause and the Mute pill is separate. Jellyfin playback title/progress comes from `JellyfinPlaybackController`, so it works without notification access; other apps still need the user-granted notification access, otherwise the card falls back to Playing / Nothing playing. The album page reuses `VolumeCard` with previous/next and a `large` ring; the Home card keeps the normal size.
+- On Home, the circular ring is song progress; its centre is play/pause and the Mute pill is separate. Jellyfin playback title/progress comes from `JellyfinPlaybackController`, so it works without notification access; other apps still need the user-granted notification access, otherwise the card falls back to Playing / Nothing playing. The album page no longer uses `VolumeCard`: it has one seekable progress slider with elapsed/total time, a centered previous / play-pause / next row, and a compact volume card (−, Mute/Unmute, +) beside the now-playing block. The Home card keeps the ring. The album slider seeks on release through `JellyfinPlaybackController.seek`; a local drag value keeps the thumb stable until the next poll.
 - Reinstalling the app can clear its HOME role; re-run `tool/install.sh` or use Settings > Home app.
 - Reinstalling the app can clear its HOME role; re-run `tool/install.sh` or use Settings > Home app.
 - CarDash can also run as a normal app without the HOME role. The launcher activity intentionally has no `excludeFromRecents`, so it appears in recents and can be switched back to. `resumeWhilePausing` and `alwaysRetainTaskState` keep the Home transition and task state sane. Android may still kill any background app: there is deliberately no keep-alive foreground service, `persistent` flag or battery-exemption request. On vendor firmware, tell the user to set CarDash to no battery restrictions / allow autostart. When CarDash holds the HOME role, Android may still filter the home task out of recents; use Home to return. Do not re-add `excludeFromRecents` without asking, and never add a launcher keep-alive FGS.
 - CarDash can also run as a normal app without the HOME role. The launcher activity intentionally has no `excludeFromRecents`, so it appears in recents and can be switched back to. `resumeWhilePausing` and `alwaysRetainTaskState` keep the Home transition and task state sane. Android may still kill any background app: there is deliberately no keep-alive foreground service, `persistent` flag or battery-exemption request. On vendor firmware, tell the user to set CarDash to no battery restrictions / allow autostart. When CarDash holds the HOME role, Android may still filter the home task out of recents; use Home to return. Do not re-add `excludeFromRecents` without asking, and never add a launcher keep-alive FGS.
 
 

+ 24 - 19
README.md

@@ -101,7 +101,8 @@ live vehicle data.
   button in Settings → General → Home buttons. The tile opens a full-screen
   button in Settings → General → Home buttons. The tile opens a full-screen
   dashboard with **Recently played**, **Recently added** and **Random** album
   dashboard with **Recently played**, **Recently added** and **Random** album
   rows (six cards when they fit, five otherwise), grouped genre selection and a
   rows (six cards when they fit, five otherwise), grouped genre selection and a
-  full album page with track list, previous/next, play/pause and volume.
+  full album page with track list, a seekable song-progress slider, previous /
+  play-pause / next transport, and a compact volume card.
   Playback runs in CarDash through Media3, keeps playing in the background, shows
   Playback runs in CarDash through Media3, keeps playing in the background, shows
   a media notification when allowed, pauses on audio-focus loss and reports to
   a media notification when allowed, pauses on audio-focus loss and reports to
   Jellyfin. Selecting a Random album re-rolls the row. The home equalizer is
   Jellyfin. Selecting a Random album re-rolls the row. The home equalizer is
@@ -111,8 +112,8 @@ live vehicle data.
   backed by Jellyfin favorites (a scrollable 5/6-column grid), with a heart on
   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
   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
   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.
+  **Streaming quality** setting selects Direct or 320/256/192/128 kbps MP3
+  transcoding; see Settings → Jellyfin.
 - **Update check** — on startup, CarDash queries the public Gogs release list
 - **Update check** — on startup, CarDash queries the public Gogs release list
   without blocking the dashboard. Settings → Updates shows the current version,
   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
   a Check button, and an Update action that downloads the APK and opens Android’s
@@ -328,15 +329,17 @@ the app resumes.
   shows the server-side favorites in a scrollable grid, six columns when they fit
   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
   and five otherwise; an empty list shows **No favorites**. Artists is shown but
   disabled for a later release.
   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. The heart adds or removes the
-  album through the Jellyfin API; the Favorites tab refreshes when you return.
+- **Album page**: large cover and track list, plus a control card with one
+  seekable song-progress slider, elapsed / total time, and previous /
+  play-pause / next below it. A compact volume card (−, Mute / Unmute, +) sits
+  to the right of the now-playing block. 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
 - **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.
   page with the dashboard underneath, so Back returns to the Albums tab.
 - **Playback**: in-app Media3 with background playback, a media notification on
 - **Playback**: in-app Media3 with background playback, a media notification on
   Android 13+ when allowed, audio-focus pause/resume, and playback reporting to
   Android 13+ when allowed, audio-focus pause/resume, and playback reporting to
   Jellyfin. Stops at the end of the album; no autoplay or repeat.
   Jellyfin. Stops at the end of the album; no autoplay or repeat.
-- **Streaming quality (next build)**: Direct (default) or 320/256/192/128 kbps.
+- **Streaming quality**: Direct (default) or 320/256/192/128 kbps.
   The bitrate choices ask Jellyfin to transcode to MP3, useful if a
   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
   high-resolution source plays too slowly or quietly on a head unit. The change
   applies to the next album you start.
   applies to the next album you start.
@@ -425,7 +428,7 @@ flutter test
 ./tool/check.sh
 ./tool/check.sh
 ```
 ```
 
 
-The 149 tests cover PIN hashing/persistence, settings persistence, theme/accent,
+The 150 tests cover PIN hashing/persistence, settings persistence, theme/accent,
 dark accent tinting, battery visibility, first-run state, the ten-minute
 dark accent tinting, battery visibility, first-run state, the ten-minute
 onboarding hint, slot visibility, pinned apps, custom labels/icons, the
 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
 16-button cap at 1280×800, 1280×720 and 1024×600, the dense shortcut grid, the
@@ -436,11 +439,12 @@ speed fallback and its permission prompt, speed/temperature source persistence,
 weather fallback, the update version/availability logic and the Settings update
 weather fallback, the update version/availability logic and the Settings update
 dot. Jellyfin coverage includes genre grouping, recent-played dedupe, the
 dot. Jellyfin coverage includes genre grouping, recent-played dedupe, the
 12.2 request contract, the dashboard controller and widget states, the genre
 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, the
-direct/MP3 transcode URL parameters, Jellyfin favorite toggling, the Favorites
-tab/grid/empty state and the playing-album Home shortcut. Native
+picker and five-family cap, album page/track list/transport, the seek slider and
+compact album volume card, playback queue and state mapping, the settings
+gating, the Home tile width clipping, the equalizer's stale-session guard, the
+streaming-quality persistence, the direct/MP3 transcode URL parameters, Jellyfin
+favorite toggling, the Favorites tab/grid/empty state, the playing-album Home
+shortcut and the progress-duration fallback. Native
 package enumeration, the HOME role, volume, app launching and PIN flows were
 package enumeration, the HOME role, volume, app launching and PIN flows were
 verified on API 35 and API 29 emulators. Location permission, reverse geocoding
 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.
 and the Open-Meteo fallback were verified live on the API 35 emulator.
@@ -450,12 +454,13 @@ real 12.2 server (Quick Connect, grouped genres, 6/5-card dashboard, album page,
 background playback, notification, media keys, exclusive equalizers, audio-focus
 background playback, notification, media keys, exclusive equalizers, audio-focus
 handover, Random re-shuffle) and against a local 12.2 container. The OBD hardware
 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
 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.2
-universal APK is the current published release. It adds the FYT/Syu MCU volume
-bridge and the no-snap-back volume handling, and includes the streaming-quality
-setting, Favorites and the playing-album Home shortcut. The emulator has
-verified the in-app update path; the MCU volume path still needs the real-car
-test.
+is available on this machine; socket behavior still needs a car test. The 1.2.4
+universal APK is the current published release. It redesigns the Jellyfin album
+controls and includes the 1.2.3 recents/normal-app work, the FYT/Syu MCU volume
+bridge, the no-snap-back volume handling, the streaming-quality setting,
+Favorites, the playing-album Home shortcut and the Jellyfin progress-duration
+fallback. The emulator has verified the in-app update path; the MCU volume path
+still needs the real-car test.
 
 
 ## Troubleshooting the emulator
 ## Troubleshooting the emulator
 
 

+ 16 - 0
assets/icons/pause.svg

@@ -0,0 +1,16 @@
+<!-- @license lucide-static v1.64.0 - ISC -->
+<svg
+  class="lucide lucide-pause"
+  xmlns="http://www.w3.org/2000/svg"
+  width="24"
+  height="24"
+  viewBox="0 0 24 24"
+  fill="none"
+  stroke="currentColor"
+  stroke-width="2"
+  stroke-linecap="round"
+  stroke-linejoin="round"
+>
+  <rect x="14" y="3" width="4" height="18" rx="1" />
+  <rect x="6" y="3" width="4" height="18" rx="1" />
+</svg>

+ 8 - 0
docs/IMPLEMENTATION_PLAN.md

@@ -331,6 +331,14 @@ Android (Kotlin)
 
 
 ## 8. Change log
 ## 8. Change log
 
 
+- 2026-11-01: CarDash 1.2.4+24 redesigns the Jellyfin album controls. The old
+  ring card is replaced by one seekable song-progress slider with elapsed /
+  total time, and a centered previous / play-pause / next row. A compact volume
+  card (−, Mute / Unmute, +) sits to the right of the now-playing block. The
+  album slider seeks through `JellyfinPlaybackController.seek` and keeps a local
+  drag value until the next poll. Includes the 1.2.3 recents/normal-app work and
+  the Jellyfin progress-duration fallback. 150 tests pass; release
+  `releases/cardash-android-1.2.4.apk`.
 - 2026-11-01: CarDash 1.2.3+23 makes the launcher usable as a normal app as
 - 2026-11-01: CarDash 1.2.3+23 makes the launcher usable as a normal app as
   well as a HOME app. The activity no longer sets `excludeFromRecents`, so it
   well as a HOME app. The activity no longer sets `excludeFromRecents`, so it
   appears in the recents/overview list and can be switched back to.
   appears in the recents/overview list and can be switched back to.

+ 1 - 1
lib/core/app_version.dart

@@ -1,2 +1,2 @@
 /// Keep this in sync with the `version` in pubspec.yaml.
 /// Keep this in sync with the `version` in pubspec.yaml.
-const String appVersion = '1.2.3';
+const String appVersion = '1.2.4';

+ 309 - 43
lib/features/jellyfin/jellyfin_album_screen.dart

@@ -10,7 +10,6 @@ import '../../data/models/jellyfin_library.dart';
 import '../../data/models/media_playback.dart';
 import '../../data/models/media_playback.dart';
 import '../../data/services/jellyfin_library_service.dart';
 import '../../data/services/jellyfin_library_service.dart';
 import '../home/system_controller.dart';
 import '../home/system_controller.dart';
-import '../home/widgets/volume_card.dart';
 import 'jellyfin_playback_controller.dart';
 import 'jellyfin_playback_controller.dart';
 import 'widgets/jellyfin_back_button.dart';
 import 'widgets/jellyfin_back_button.dart';
 
 
@@ -28,6 +27,7 @@ class _JellyfinAlbumScreenState extends State<JellyfinAlbumScreen> {
   late Future<List<JellyfinTrack>> _tracksFuture;
   late Future<List<JellyfinTrack>> _tracksFuture;
   late bool _isFavorite;
   late bool _isFavorite;
   bool _favoriteBusy = false;
   bool _favoriteBusy = false;
+  double? _seekDragMs;
 
 
   @override
   @override
   void initState() {
   void initState() {
@@ -148,31 +148,61 @@ class _JellyfinAlbumScreenState extends State<JellyfinAlbumScreen> {
 
 
     Widget controls() {
     Widget controls() {
       final error = playback.error;
       final error = playback.error;
+      final durationMs = media.durationMs > 0
+          ? media.durationMs
+          : (playback.currentItem?.durationMs ?? 0);
+      final total = Duration(milliseconds: durationMs);
+      final rawPosition = (_seekDragMs ?? media.positionMs.toDouble()).clamp(
+        0.0,
+        durationMs > 0 ? durationMs.toDouble() : 1.0,
+      );
+      final position = Duration(milliseconds: rawPosition.round());
+      final canSeek = active && durationMs > 0;
+      final canToggle = !playback.loading;
+
       return Column(
       return Column(
         crossAxisAlignment: CrossAxisAlignment.stretch,
         crossAxisAlignment: CrossAxisAlignment.stretch,
         mainAxisSize: MainAxisSize.min,
         mainAxisSize: MainAxisSize.min,
         children: [
         children: [
-          Text(
-            active
-                ? (playing ? 'Now playing' : 'Paused')
-                : playback.loading
-                ? 'Starting playback…'
-                : 'Ready',
-            style: theme.textTheme.labelLarge?.copyWith(color: colors.inkMuted),
-          ),
-          const SizedBox(height: 4),
-          Text(
-            currentTitle,
-            maxLines: 2,
-            overflow: TextOverflow.ellipsis,
-            style: theme.textTheme.headlineSmall,
-          ),
-          const SizedBox(height: 2),
-          Text(
-            currentArtist.isEmpty ? 'Unknown artist' : currentArtist,
-            maxLines: 1,
-            overflow: TextOverflow.ellipsis,
-            style: theme.textTheme.bodyMedium?.copyWith(color: colors.inkMuted),
+          Row(
+            crossAxisAlignment: CrossAxisAlignment.start,
+            children: [
+              Expanded(
+                child: Column(
+                  crossAxisAlignment: CrossAxisAlignment.start,
+                  children: [
+                    Text(
+                      active
+                          ? (playing ? 'Now playing' : 'Paused')
+                          : playback.loading
+                          ? 'Starting playback…'
+                          : 'Ready',
+                      style: theme.textTheme.labelLarge?.copyWith(
+                        color: colors.inkMuted,
+                      ),
+                    ),
+                    const SizedBox(height: 4),
+                    Text(
+                      currentTitle,
+                      maxLines: 2,
+                      overflow: TextOverflow.ellipsis,
+                      style: theme.textTheme.headlineSmall,
+                    ),
+                    const SizedBox(height: 2),
+                    Text(
+                      currentArtist.isEmpty ? 'Unknown artist' : currentArtist,
+                      maxLines: 1,
+                      overflow: TextOverflow.ellipsis,
+                      style: theme.textTheme.bodyMedium?.copyWith(
+                        color: colors.inkMuted,
+                      ),
+                    ),
+                  ],
+                ),
+              ),
+              const SizedBox(width: 12),
+              _AlbumVolumeCard(system: system),
+            ],
           ),
           ),
           if (error != null) ...[
           if (error != null) ...[
             const SizedBox(height: 8),
             const SizedBox(height: 8),
@@ -196,27 +226,104 @@ class _JellyfinAlbumScreenState extends State<JellyfinAlbumScreen> {
               ],
               ],
             ),
             ),
           ],
           ],
-          const SizedBox(height: 12),
-          VolumeCard(
-            volume: system.volume,
-            onSetVolume: system.setVolume,
-            onToggleMute: system.toggleMute,
-            isMusicPlaying: playing,
-            onPlayPause: () {
-              if (playback.loading) return;
-              if (playback.isCurrentAlbum(widget.album)) {
-                unawaited(playback.toggle());
-              } else {
-                unawaited(playback.playAlbum(widget.album));
-              }
-            },
-            media: media,
-            large: true,
-            onPrevious: active ? () => unawaited(playback.previous()) : null,
-            onNext: active ? () => unawaited(playback.next()) : null,
-            canPrevious: active && playback.canGoPrevious,
-            canNext: active && playback.canGoNext,
-            unconfirmed: system.volumeUnconfirmed,
+          const SizedBox(height: 14),
+          Container(
+            padding: const EdgeInsets.fromLTRB(18, 12, 18, 14),
+            decoration: BoxDecoration(
+              color: colors.surface,
+              borderRadius: BorderRadius.circular(20),
+              border: Border.all(color: colors.border),
+            ),
+            child: Column(
+              mainAxisSize: MainAxisSize.min,
+              children: [
+                Row(
+                  children: [
+                    Expanded(
+                      child: SliderTheme(
+                        data: SliderTheme.of(context).copyWith(
+                          trackHeight: 4,
+                          activeTrackColor: colors.accent,
+                          inactiveTrackColor: colors.border,
+                          thumbColor: colors.accent,
+                          overlayColor: colors.accent.withValues(alpha: 0.12),
+                          thumbShape: const RoundSliderThumbShape(
+                            enabledThumbRadius: 7,
+                          ),
+                          overlayShape: const RoundSliderOverlayShape(
+                            overlayRadius: 16,
+                          ),
+                        ),
+                        child: Slider(
+                          min: 0,
+                          max: durationMs > 0 ? durationMs.toDouble() : 1,
+                          value: durationMs > 0 ? rawPosition : 0,
+                          onChanged: canSeek
+                              ? (value) {
+                                  setState(() => _seekDragMs = value);
+                                }
+                              : null,
+                          onChangeEnd: canSeek
+                              ? (value) {
+                                  setState(() => _seekDragMs = null);
+                                  unawaited(
+                                    playback.seek(
+                                      Duration(milliseconds: value.round()),
+                                    ),
+                                  );
+                                }
+                              : null,
+                        ),
+                      ),
+                    ),
+                    const SizedBox(width: 10),
+                    Text(
+                      '${_formatDuration(position)} / ${_formatDuration(total)}',
+                      style: theme.textTheme.bodySmall?.copyWith(
+                        color: colors.inkMuted,
+                        fontFeatures: const [FontFeature.tabularFigures()],
+                      ),
+                    ),
+                  ],
+                ),
+                const SizedBox(height: 10),
+                Row(
+                  mainAxisAlignment: MainAxisAlignment.center,
+                  children: [
+                    _AlbumTransportButton(
+                      asset: 'assets/icons/skip-back.svg',
+                      semanticLabel: 'Previous track',
+                      enabled: active && playback.canGoPrevious,
+                      onTap: () => unawaited(playback.previous()),
+                    ),
+                    const SizedBox(width: 18),
+                    _AlbumTransportButton(
+                      asset: playing
+                          ? 'assets/icons/pause.svg'
+                          : 'assets/icons/play.svg',
+                      semanticLabel: playing ? 'Pause music' : 'Play music',
+                      primary: true,
+                      enabled: canToggle,
+                      onTap: () {
+                        if (playback.loading) return;
+                        if (active) {
+                          unawaited(playback.toggle());
+                        } else {
+                          unawaited(playback.playAlbum(widget.album));
+                        }
+                      },
+                    ),
+                    const SizedBox(width: 18),
+                    _AlbumTransportButton(
+                      asset: 'assets/icons/skip-forward.svg',
+                      semanticLabel: 'Next track',
+                      enabled: active && playback.canGoNext,
+                      onTap: () => unawaited(playback.next()),
+                    ),
+                  ],
+                ),
+              ],
+            ),
           ),
           ),
         ],
         ],
       );
       );
@@ -500,6 +607,165 @@ class _FavoriteButton extends StatelessWidget {
   }
   }
 }
 }
 
 
+class _AlbumVolumeCard extends StatelessWidget {
+  const _AlbumVolumeCard({required this.system});
+
+  final SystemController system;
+
+  @override
+  Widget build(BuildContext context) {
+    final colors = context.colors;
+    final theme = Theme.of(context);
+    final enabled = !system.isVolumeFixed;
+    return Container(
+      padding: const EdgeInsets.all(8),
+      decoration: BoxDecoration(
+        color: colors.surface,
+        borderRadius: BorderRadius.circular(18),
+        border: Border.all(color: colors.border),
+      ),
+      child: Row(
+        mainAxisSize: MainAxisSize.min,
+        children: [
+          _AlbumVolumeStep(
+            icon: Icons.remove_rounded,
+            semanticLabel: 'Volume down',
+            onTap: enabled ? () => unawaited(system.adjustVolume(-1)) : null,
+          ),
+          const SizedBox(width: 6),
+          Pressable(
+            onTap: enabled ? () => unawaited(system.toggleMute()) : null,
+            semanticLabel: system.isMuted ? 'Unmute' : 'Mute',
+            pressedScale: 0.94,
+            child: Container(
+              height: 36,
+              padding: const EdgeInsets.symmetric(horizontal: 10),
+              decoration: BoxDecoration(
+                color: system.isMuted
+                    ? colors.accentSoft
+                    : colors.surfaceStrong,
+                borderRadius: BorderRadius.circular(999),
+                border: Border.all(
+                  color: system.isMuted
+                      ? colors.accent.withValues(alpha: 0.25)
+                      : colors.border,
+                ),
+              ),
+              child: Row(
+                mainAxisSize: MainAxisSize.min,
+                children: [
+                  AppIcon(
+                    system.isMuted
+                        ? 'assets/icons/volume-x.svg'
+                        : 'assets/icons/volume-2.svg',
+                    size: 16,
+                    color: system.isMuted ? colors.accent : colors.inkMuted,
+                  ),
+                  const SizedBox(width: 6),
+                  Text(
+                    system.isMuted ? 'Unmute' : 'Mute',
+                    style: theme.textTheme.labelMedium?.copyWith(
+                      color: system.isMuted ? colors.accent : colors.inkMuted,
+                    ),
+                  ),
+                ],
+              ),
+            ),
+          ),
+          const SizedBox(width: 6),
+          _AlbumVolumeStep(
+            icon: Icons.add_rounded,
+            semanticLabel: 'Volume up',
+            onTap: enabled ? () => unawaited(system.adjustVolume(1)) : null,
+          ),
+        ],
+      ),
+    );
+  }
+}
+
+class _AlbumVolumeStep extends StatelessWidget {
+  const _AlbumVolumeStep({
+    required this.icon,
+    required this.semanticLabel,
+    required this.onTap,
+  });
+
+  final IconData icon;
+  final String semanticLabel;
+  final VoidCallback? onTap;
+
+  @override
+  Widget build(BuildContext context) {
+    final colors = context.colors;
+    return Pressable(
+      onTap: onTap,
+      semanticLabel: semanticLabel,
+      pressedScale: 0.92,
+      child: Container(
+        width: 36,
+        height: 36,
+        decoration: BoxDecoration(
+          color: colors.surfaceStrong,
+          shape: BoxShape.circle,
+          border: Border.all(color: colors.border),
+        ),
+        alignment: Alignment.center,
+        child: Icon(
+          icon,
+          size: 18,
+          color: onTap == null ? colors.inkFaint : colors.inkMuted,
+        ),
+      ),
+    );
+  }
+}
+
+class _AlbumTransportButton extends StatelessWidget {
+  const _AlbumTransportButton({
+    required this.asset,
+    required this.semanticLabel,
+    required this.onTap,
+    this.enabled = true,
+    this.primary = false,
+  });
+
+  final String asset;
+  final String semanticLabel;
+  final VoidCallback onTap;
+  final bool enabled;
+  final bool primary;
+
+  @override
+  Widget build(BuildContext context) {
+    final colors = context.colors;
+    final size = primary ? 68.0 : 54.0;
+    return Opacity(
+      opacity: enabled ? 1 : 0.4,
+      child: Pressable(
+        onTap: enabled ? onTap : null,
+        semanticLabel: semanticLabel,
+        pressedScale: 0.92,
+        child: Container(
+          width: size,
+          height: size,
+          decoration: BoxDecoration(
+            color: primary ? colors.accent : colors.surfaceStrong,
+            shape: BoxShape.circle,
+            border: primary ? null : Border.all(color: colors.border),
+          ),
+          alignment: Alignment.center,
+          child: AppIcon(
+            asset,
+            size: primary ? 28 : 22,
+            color: primary ? colors.accentInk : colors.ink,
+          ),
+        ),
+      ),
+    );
+  }
+}
+
 class _TrackRow extends StatelessWidget {
 class _TrackRow extends StatelessWidget {
   const _TrackRow({
   const _TrackRow({
     required this.track,
     required this.track,

+ 1 - 1
pubspec.yaml

@@ -1,7 +1,7 @@
 name: cardash_launcher
 name: cardash_launcher
 description: "CarDash — a calm, configurable Android car dashboard launcher."
 description: "CarDash — a calm, configurable Android car dashboard launcher."
 publish_to: "none"
 publish_to: "none"
-version: 1.2.3+23
+version: 1.2.4+24
 
 
 environment:
 environment:
   sdk: ^3.13.7
   sdk: ^3.13.7

+ 38 - 0
releases/1.2.4.md

@@ -0,0 +1,38 @@
+CarDash 1.2.4 redesigns the Jellyfin album controls.
+
+- **Package:** `com.wasp.cardash_launcher`
+- **Version:** 1.2.4 (versionCode 24)
+- **ABIs:** arm64-v8a, armeabi-v7a, x86_64 (universal APK)
+- **Signing:** project debug key matching all prior releases
+- **SHA-256:** `7a157490ae5c312edd87cac448d1fc7f2ceb01a57a9cce36f3bc4fe25fcfeafb`
+
+**What changed**
+- The Jellyfin album page now has one seekable song-progress slider with
+  elapsed / total time. Dragging the slider and releasing it seeks to that
+  position.
+- Previous, play/pause and next are below the slider, centered and larger for
+  easy tapping.
+- A compact volume card with −, Mute / Unmute and + sits to the right of the
+  now-playing block, above the progress/transport card. The old combined ring,
+  transport and volume card is no longer used on the album page; Home keeps it.
+- Includes the 1.2.3 normal-app/recents work and the Jellyfin
+  progress-duration fallback, plus all 1.2.2 FYT/Syu volume, Favorites and
+  streaming-quality work.
+
+**Verified**
+- `flutter analyze`, formatting and the full 150-test suite.
+- Universal release APK built with JDK 21.
+- The optional emulator install was skipped because the remote ADB server was
+  unreachable. The album slider seek, transport controls and compact volume card
+  are covered by the widget tests.
+
+**[Download cardash-android-1.2.4.apk](https://git.krasi.net/kkazakov/car-interface-android/raw/main/releases/cardash-android-1.2.4.apk)**
+
+Devices with the in-app updater can use Settings → Updates to download this APK
+and open Android's package installer. Android requires install confirmation and a
+one-time unknown-apps allowance; no silent install is attempted. A yellow dot
+appears on the Settings gear when this release is available.
+
+This build is debug-signed for sideloading/testing, not Play Store distribution.
+Gogs release attachments are limited to 32 MB, so the universal APK is linked
+directly from the repository.

+ 14 - 0
releases/README.md

@@ -1,5 +1,19 @@
 # CarDash releases
 # CarDash releases
 
 
+## 1.2.4 — Jellyfin album controls redesign
+
+- **File:** `cardash-android-1.2.4.apk`
+- **Version:** 1.2.4 (versionCode 24)
+- **ABIs:** arm64-v8a, armeabi-v7a, x86_64
+- **SHA-256:** `7a157490ae5c312edd87cac448d1fc7f2ceb01a57a9cce36f3bc4fe25fcfeafb`
+- **Signing:** project debug key matching all releases.
+- **Changes:** the album page now uses one seekable song-progress slider with
+  elapsed / total time, a centered previous / play-pause / next row, and a
+  compact volume card (−, Mute / Unmute, +) beside the now-playing block. The
+  old album ring/volume card is gone; Home keeps it. Includes the 1.2.3
+  recents/normal-app work and the Jellyfin progress-duration fallback.
+- **Verified:** analyze, formatting, 150 tests and universal release build.
+
 ## 1.2.3 — normal-app recents and Home-transition fix
 ## 1.2.3 — normal-app recents and Home-transition fix
 
 
 - **File:** `cardash-android-1.2.3.apk`
 - **File:** `cardash-android-1.2.3.apk`

BIN=BIN
releases/cardash-android-1.2.4.apk


+ 1 - 0
releases/cardash-android-1.2.4.apk.sha256

@@ -0,0 +1 @@
+7a157490ae5c312edd87cac448d1fc7f2ceb01a57a9cce36f3bc4fe25fcfeafb  cardash-android-1.2.4.apk

+ 11 - 2
test/jellyfin_album_screen_test.dart

@@ -1,7 +1,6 @@
 import 'package:cardash_launcher/core/theme/app_theme.dart';
 import 'package:cardash_launcher/core/theme/app_theme.dart';
 import 'package:cardash_launcher/data/services/jellyfin_library_service.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/home/system_controller.dart';
-import 'package:cardash_launcher/features/home/widgets/volume_card.dart';
 import 'package:cardash_launcher/features/jellyfin/jellyfin_album_screen.dart';
 import 'package:cardash_launcher/features/jellyfin/jellyfin_album_screen.dart';
 import 'package:cardash_launcher/features/jellyfin/jellyfin_playback_controller.dart';
 import 'package:cardash_launcher/features/jellyfin/jellyfin_playback_controller.dart';
 import 'package:cardash_launcher/features/settings/jellyfin_controller.dart';
 import 'package:cardash_launcher/features/settings/jellyfin_controller.dart';
@@ -67,12 +66,22 @@ void main() {
     expect(find.text('Track t2'), findsOneWidget);
     expect(find.text('Track t2'), findsOneWidget);
     expect(find.text('Track t3'), findsOneWidget);
     expect(find.text('Track t3'), findsOneWidget);
     expect(find.text('Now playing'), findsOneWidget);
     expect(find.text('Now playing'), findsOneWidget);
-    expect(find.byType(VolumeCard), findsOneWidget);
+    expect(find.byType(Slider), findsOneWidget);
     expect(find.bySemanticsLabel('Previous track'), findsOneWidget);
     expect(find.bySemanticsLabel('Previous track'), findsOneWidget);
+    expect(find.bySemanticsLabel('Pause music'), findsOneWidget);
     expect(find.bySemanticsLabel('Next track'), findsOneWidget);
     expect(find.bySemanticsLabel('Next track'), findsOneWidget);
+    expect(find.bySemanticsLabel('Volume down'), findsOneWidget);
+    expect(find.bySemanticsLabel('Volume up'), findsOneWidget);
     expect(find.bySemanticsLabel('Remove from favorites'), findsOneWidget);
     expect(find.bySemanticsLabel('Remove from favorites'), findsOneWidget);
     expect(tester.takeException(), isNull);
     expect(tester.takeException(), isNull);
 
 
+    await tester.drag(find.byType(Slider), const Offset(120, 0));
+    await tester.pumpAndSettle();
+    expect(
+      platform.commands.any((command) => command.startsWith('seek:')),
+      isTrue,
+    );
+
     await tester.tap(find.bySemanticsLabel('Remove from favorites'));
     await tester.tap(find.bySemanticsLabel('Remove from favorites'));
     await tester.pumpAndSettle();
     await tester.pumpAndSettle();
     expect(gateway.setFavoriteCalls, 1);
     expect(gateway.setFavoriteCalls, 1);