Prechádzať zdrojové kódy

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 5 dní pred
rodič
commit
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.
 - 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 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.
 - 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.
@@ -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.
 - 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.
-- 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.
 - 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
   dashboard with **Recently played**, **Recently added** and **Random** album
   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
   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
@@ -111,8 +112,8 @@ live vehicle data.
   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.
+  **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
   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
@@ -328,15 +329,17 @@ the app resumes.
   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. 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
   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.
-- **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
   high-resolution source plays too slowly or quietly on a head unit. The change
   applies to the next album you start.
@@ -425,7 +428,7 @@ flutter test
 ./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
 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
@@ -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
 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, 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
 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.
@@ -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
 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.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
 

+ 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
 
+- 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
   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.

+ 1 - 1
lib/core/app_version.dart

@@ -1,2 +1,2 @@
 /// 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/services/jellyfin_library_service.dart';
 import '../home/system_controller.dart';
-import '../home/widgets/volume_card.dart';
 import 'jellyfin_playback_controller.dart';
 import 'widgets/jellyfin_back_button.dart';
 
@@ -28,6 +27,7 @@ class _JellyfinAlbumScreenState extends State<JellyfinAlbumScreen> {
   late Future<List<JellyfinTrack>> _tracksFuture;
   late bool _isFavorite;
   bool _favoriteBusy = false;
+  double? _seekDragMs;
 
   @override
   void initState() {
@@ -148,31 +148,61 @@ class _JellyfinAlbumScreenState extends State<JellyfinAlbumScreen> {
 
     Widget controls() {
       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(
         crossAxisAlignment: CrossAxisAlignment.stretch,
         mainAxisSize: MainAxisSize.min,
         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) ...[
             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 {
   const _TrackRow({
     required this.track,

+ 1 - 1
pubspec.yaml

@@ -1,7 +1,7 @@
 name: cardash_launcher
 description: "CarDash — a calm, configurable Android car dashboard launcher."
 publish_to: "none"
-version: 1.2.3+23
+version: 1.2.4+24
 
 environment:
   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
 
+## 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
 
 - **File:** `cardash-android-1.2.3.apk`

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/data/services/jellyfin_library_service.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_playback_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 t3'), 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('Pause music'), 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(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.pumpAndSettle();
     expect(gateway.setFavoriteCalls, 1);