Просмотр исходного кода

fix: align album controls and enlarge buttons

Bottoms the Jellyfin album progress/transport card against the album cover in both wide and narrow layouts, enlarges previous/next to 62 px, play/pause to 80 px and the compact volume controls to 40 px. Adds alignment assertions at 1280x800 and 1024x600. Bumps to 1.2.5+25 with release APK and notes.
Krasimir Kazakov 5 дней назад
Родитель
Сommit
55546f8000

+ 1 - 1
AGENTS.md

@@ -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.
-- 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.
+- 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 progress/transport card is bottom-aligned with the album cover in both the wide and narrow layouts; the transport buttons are 80/62 px and the compact volume steps are 40 px. 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.
 

+ 16 - 13
README.md

@@ -101,8 +101,9 @@ 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, a seekable song-progress slider, previous /
-  play-pause / next transport, and a compact volume card.
+  full album page with track list, a seekable song-progress slider, large
+  previous / play-pause / next transport, and a compact volume card. The
+  navigation card is bottom-aligned with the album art.
   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
@@ -330,10 +331,11 @@ the app resumes.
   and five otherwise; an empty list shows **No favorites**. Artists is shown but
   disabled for a later release.
 - **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.
+  seekable song-progress slider, elapsed / total time, and large previous /
+  play-pause / next buttons below it. The card is bottom-aligned with the album
+  cover. 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
@@ -454,13 +456,14 @@ 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.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.
+is available on this machine; socket behavior still needs a car test. The 1.2.5
+universal APK is the current published release. It bottom-aligns the Jellyfin
+progress/transport card with the album cover, enlarges the transport and compact
+volume buttons, and includes the 1.2.4 album redesign, 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
 

+ 6 - 0
docs/IMPLEMENTATION_PLAN.md

@@ -331,6 +331,12 @@ Android (Kotlin)
 
 ## 8. Change log
 
+- 2026-11-01: CarDash 1.2.5+25 aligns the Jellyfin album progress/transport card
+  with the bottom of the album cover in both wide and narrow layouts. The
+  previous/play-pause/next buttons grow to 62/80 px and the compact volume steps
+  to 40 px. The album page still has one seekable slider with elapsed / total
+  time and the compact −/Mute/+ card beside the now-playing block. 150 tests
+  pass; release `releases/cardash-android-1.2.5.apk`.
 - 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

+ 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.4';
+const String appVersion = '1.2.5';

+ 119 - 103
lib/features/jellyfin/jellyfin_album_screen.dart

@@ -127,6 +127,7 @@ class _JellyfinAlbumScreenState extends State<JellyfinAlbumScreen> {
           child: AspectRatio(
             aspectRatio: 1,
             child: Container(
+              key: const ValueKey('jellyfin-album-cover'),
               clipBehavior: Clip.antiAlias,
               decoration: BoxDecoration(
                 borderRadius: BorderRadius.circular(24),
@@ -146,7 +147,7 @@ class _JellyfinAlbumScreenState extends State<JellyfinAlbumScreen> {
       );
     }
 
-    Widget controls() {
+    Widget controls({double? minHeight}) {
       final error = playback.error;
       final durationMs = media.durationMs > 0
           ? media.durationMs
@@ -160,7 +161,7 @@ class _JellyfinAlbumScreenState extends State<JellyfinAlbumScreen> {
       final canSeek = active && durationMs > 0;
       final canToggle = !playback.loading;
 
-      return Column(
+      final content = Column(
         crossAxisAlignment: CrossAxisAlignment.stretch,
         mainAxisSize: MainAxisSize.min,
         children: [
@@ -226,106 +227,120 @@ class _JellyfinAlbumScreenState extends State<JellyfinAlbumScreen> {
               ],
             ),
           ],
-          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,
+        ],
+      );
+      final card = Container(
+        key: const ValueKey('jellyfin-album-progress-card'),
+        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: [
-                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,
-                        ),
+                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: 8,
                       ),
-                    ),
-                    const SizedBox(width: 10),
-                    Text(
-                      '${_formatDuration(position)} / ${_formatDuration(total)}',
-                      style: theme.textTheme.bodySmall?.copyWith(
-                        color: colors.inkMuted,
-                        fontFeatures: const [FontFeature.tabularFigures()],
+                      overlayShape: const RoundSliderOverlayShape(
+                        overlayRadius: 18,
                       ),
                     ),
-                  ],
-                ),
-                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));
-                        }
-                      },
+                    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: 18),
-                    _AlbumTransportButton(
-                      asset: 'assets/icons/skip-forward.svg',
-                      semanticLabel: 'Next track',
-                      enabled: active && playback.canGoNext,
-                      onTap: () => unawaited(playback.next()),
-                    ),
-                  ],
+                  ),
+                ),
+                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: 20),
+                _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: 20),
+                _AlbumTransportButton(
+                  asset: 'assets/icons/skip-forward.svg',
+                  semanticLabel: 'Next track',
+                  enabled: active && playback.canGoNext,
+                  onTap: () => unawaited(playback.next()),
+                ),
+              ],
+            ),
+          ],
+        ),
+      );
+      if (minHeight == null) {
+        return Column(
+          crossAxisAlignment: CrossAxisAlignment.stretch,
+          mainAxisSize: MainAxisSize.min,
+          children: [content, const SizedBox(height: 14), card],
+        );
+      }
+      return SizedBox(
+        height: minHeight,
+        child: Column(
+          crossAxisAlignment: CrossAxisAlignment.stretch,
+          children: [content, const Spacer(), card],
+        ),
       );
     }
 
@@ -419,7 +434,8 @@ class _JellyfinAlbumScreenState extends State<JellyfinAlbumScreen> {
           const SizedBox(width: 22),
           SizedBox(
             width: controlsWidth,
-            child: SingleChildScrollView(child: controls()),
+            height: coverWidth,
+            child: controls(minHeight: coverWidth),
           ),
         ],
       );
@@ -436,7 +452,7 @@ class _JellyfinAlbumScreenState extends State<JellyfinAlbumScreen> {
               children: [
                 SizedBox(width: coverWidth, child: cover()),
                 const SizedBox(width: 18),
-                Expanded(child: controls()),
+                Expanded(child: controls(minHeight: coverWidth)),
               ],
             ),
             const SizedBox(height: 16),
@@ -638,7 +654,7 @@ class _AlbumVolumeCard extends StatelessWidget {
             semanticLabel: system.isMuted ? 'Unmute' : 'Mute',
             pressedScale: 0.94,
             child: Container(
-              height: 36,
+              height: 40,
               padding: const EdgeInsets.symmetric(horizontal: 10),
               decoration: BoxDecoration(
                 color: system.isMuted
@@ -658,7 +674,7 @@ class _AlbumVolumeCard extends StatelessWidget {
                     system.isMuted
                         ? 'assets/icons/volume-x.svg'
                         : 'assets/icons/volume-2.svg',
-                    size: 16,
+                    size: 18,
                     color: system.isMuted ? colors.accent : colors.inkMuted,
                   ),
                   const SizedBox(width: 6),
@@ -703,8 +719,8 @@ class _AlbumVolumeStep extends StatelessWidget {
       semanticLabel: semanticLabel,
       pressedScale: 0.92,
       child: Container(
-        width: 36,
-        height: 36,
+        width: 40,
+        height: 40,
         decoration: BoxDecoration(
           color: colors.surfaceStrong,
           shape: BoxShape.circle,
@@ -713,7 +729,7 @@ class _AlbumVolumeStep extends StatelessWidget {
         alignment: Alignment.center,
         child: Icon(
           icon,
-          size: 18,
+          size: 20,
           color: onTap == null ? colors.inkFaint : colors.inkMuted,
         ),
       ),
@@ -739,7 +755,7 @@ class _AlbumTransportButton extends StatelessWidget {
   @override
   Widget build(BuildContext context) {
     final colors = context.colors;
-    final size = primary ? 68.0 : 54.0;
+    final size = primary ? 80.0 : 62.0;
     return Opacity(
       opacity: enabled ? 1 : 0.4,
       child: Pressable(
@@ -757,7 +773,7 @@ class _AlbumTransportButton extends StatelessWidget {
           alignment: Alignment.center,
           child: AppIcon(
             asset,
-            size: primary ? 28 : 22,
+            size: primary ? 32 : 26,
             color: primary ? colors.accentInk : colors.ink,
           ),
         ),

+ 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.4+24
+version: 1.2.5+25
 
 environment:
   sdk: ^3.13.7

+ 37 - 0
releases/1.2.5.md

@@ -0,0 +1,37 @@
+CarDash 1.2.5 polishes the Jellyfin album controls.
+
+- **Package:** `com.wasp.cardash_launcher`
+- **Version:** 1.2.5 (versionCode 25)
+- **ABIs:** arm64-v8a, armeabi-v7a, x86_64 (universal APK)
+- **Signing:** project debug key matching all prior releases
+- **SHA-256:** `47ba0960bf9bf16122f482f65941f27f166283d8aeeb0eb6ae718216aaf0dad5`
+
+**What changed**
+- The progress/transport card is now bottom-aligned with the album cover in
+  both the wide three-column layout and the narrower two-column layout.
+- Previous and next grow from 54 to 62 px; play/pause grows from 68 to 80 px.
+  The compact volume −/+ and Mute/Unmute pill grow from 36 to 40 px, so all
+  controls are easier to hit in a moving car.
+- Includes all 1.2.4 work: the seekable song-progress slider with elapsed/total
+  time, the centered transport row and the compact volume card. The Home card is
+  unchanged.
+- Includes the 1.2.3 normal-app/recents and Jellyfin progress-duration work, and
+  all 1.2.2 FYT/Syu volume, Favorites and streaming-quality work.
+
+**Verified**
+- `flutter analyze`, formatting and the full 150-test suite.
+- A widget test asserts the progress card's bottom edge matches the album
+  cover's bottom edge at 1280×800 and 1024×600, and covers the slider seek and
+  transport.
+- Universal release APK built with JDK 21.
+
+**[Download cardash-android-1.2.5.apk](https://git.krasi.net/kkazakov/car-interface-android/raw/main/releases/cardash-android-1.2.5.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.

+ 12 - 0
releases/README.md

@@ -1,5 +1,17 @@
 # CarDash releases
 
+## 1.2.5 — album card alignment and larger controls
+
+- **File:** `cardash-android-1.2.5.apk`
+- **Version:** 1.2.5 (versionCode 25)
+- **ABIs:** arm64-v8a, armeabi-v7a, x86_64
+- **SHA-256:** `47ba0960bf9bf16122f482f65941f27f166283d8aeeb0eb6ae718216aaf0dad5`
+- **Signing:** project debug key matching all releases.
+- **Changes:** bottoms the album progress/transport card against the album cover;
+  enlarges previous/next to 62 px, play/pause to 80 px and the compact volume
+  controls to 40 px. Includes all 1.2.4 album controls and prior Jellyfin work.
+- **Verified:** analyze, formatting, 150 tests and universal release build.
+
 ## 1.2.4 — Jellyfin album controls redesign
 
 - **File:** `cardash-android-1.2.4.apk`

BIN
releases/cardash-android-1.2.5.apk


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

@@ -0,0 +1 @@
+47ba0960bf9bf16122f482f65941f27f166283d8aeeb0eb6ae718216aaf0dad5  cardash-android-1.2.5.apk

+ 21 - 0
test/jellyfin_album_screen_test.dart

@@ -73,6 +73,13 @@ void main() {
     expect(find.bySemanticsLabel('Volume down'), findsOneWidget);
     expect(find.bySemanticsLabel('Volume up'), findsOneWidget);
     expect(find.bySemanticsLabel('Remove from favorites'), findsOneWidget);
+    final coverRect = tester.getRect(
+      find.byKey(const ValueKey('jellyfin-album-cover')),
+    );
+    final cardRect = tester.getRect(
+      find.byKey(const ValueKey('jellyfin-album-progress-card')),
+    );
+    expect(cardRect.bottom, moreOrLessEquals(coverRect.bottom, epsilon: 0.5));
     expect(tester.takeException(), isNull);
 
     await tester.drag(find.byType(Slider), const Offset(120, 0));
@@ -104,6 +111,20 @@ void main() {
     expect(platform.commands, contains('previous'));
     expect(playback.currentItem?.trackId, 't2');
 
+    await tester.binding.setSurfaceSize(const Size(1024, 600));
+    await tester.pumpAndSettle();
+    final narrowCover = tester.getRect(
+      find.byKey(const ValueKey('jellyfin-album-cover')),
+    );
+    final narrowCard = tester.getRect(
+      find.byKey(const ValueKey('jellyfin-album-progress-card')),
+    );
+    expect(
+      narrowCard.bottom,
+      moreOrLessEquals(narrowCover.bottom, epsilon: 0.5),
+    );
+    expect(tester.takeException(), isNull);
+
     await tester.pumpWidget(const SizedBox.shrink());
     playback.dispose();
     jellyfin.dispose();