Преглед изворни кода

feat: streamline Jellyfin album controls and chrome

Krasimir Kazakov пре 10 часа
родитељ
комит
8d32c35f8f

+ 1 - 1
AGENTS.md

@@ -117,7 +117,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 page uses the stacked layout at every width: cover and control column on top, the track list below. The cover is 30% of the body width clamped to 200–360 px; the wide three-column layout was removed. The progress/transport card is bottom-aligned with the album cover; the transport buttons are 80/62 px and the compact volume steps are 40 px. The album header appends the Jellyfin `ProductionYear` in parentheses to the name when present, e.g. `Ascension (2025)`, and does not duplicate a year already in the name. 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`: its navigation card has one seekable progress slider with elapsed/total time and one row with the 69 px favorite heart in the left corner, centered previous / play-pause / next, and three separate 60 px volume buttons (−, Mute/Unmute, +) in the right corner; there is no surrounding volume card. The Home card keeps the ring. The album page uses the stacked layout at every width: cover and control column on top, the track list below. The cover is 21% of the body width clamped to 140–252 px; the wide three-column layout was removed. The navigation card is bottom-aligned with the album cover when the control column fits, otherwise it grows below the cover. The transport buttons are 80/62 px and track rows use 12 px vertical padding. Dashboard and album headers use an 83 px back button and a top-right strip with the time, current speed, current temperature and a Home button that pops to the home route. The album header appends the Jellyfin `ProductionYear` in parentheses to the name when present, e.g. `Ascension (2025)`, and does not duplicate a year already in the name. 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.
 

+ 15 - 10
README.md

@@ -103,8 +103,10 @@ live vehicle data.
   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 the cover and controls above a 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.
+  song-progress slider, large previous / play-pause / next transport, and three
+  separate volume buttons. The navigation card is bottom-aligned with the album
+  art when the control column fits. Both Jellyfin pages show a top-right strip
+  with the time, current speed, current temperature and a Home button.
   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
@@ -332,14 +334,17 @@ the app resumes.
   and five otherwise; an empty list shows **No favorites**. Artists is shown but
   disabled for a later release.
 - **Album page**: the stacked layout is used at every screen size — cover and
-  controls on top, track list below. The cover grows on larger screens. The
-  control card has one seekable song-progress slider, elapsed / total time, and
-  large previous / play-pause / next buttons; its bottom is 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. The album heading includes
-  the release year in parentheses, for example `Ascension (2025)`, when Jellyfin
-  provides it.
+  controls on top, track list below. The cover is 21% of the body width, clamped
+  to 140–252 px. The navigation card has one seekable song-progress slider,
+  elapsed / total time, and a row with the favorite heart in the left corner,
+  large previous / play-pause / next buttons in the centre and three separate
+  volume buttons (−, Mute / Unmute, +) in the right corner. Its bottom is aligned
+  with the album cover when the control column fits; otherwise it grows below
+  the cover. The heart adds or removes the album through the Jellyfin API; the
+  Favorites tab refreshes when you return. The album heading includes the release
+  year in parentheses, for example `Ascension (2025)`, when Jellyfin provides it.
+  The dashboard and album headers use a large back button and a top-right strip
+  with the time, current speed, current temperature and a Home button.
 - **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

+ 6 - 2
docs/IMPLEMENTATION_PLAN.md

@@ -149,8 +149,12 @@ Android (Kotlin)
   network actions.
 - The Jellyfin dashboard asks for 6 albums per row and shows 6 columns when each
   card is at least 160 px wide, otherwise 5. Album labels share the cover's left
-  edge; the album page cover is top-aligned; a Random selection re-rolls that
-  row after playback starts.
+  edge; the album page cover is top-aligned at 21% of the body width (140–252 px);
+  a Random selection re-rolls that row after playback starts. The album
+  navigation card holds the 69 px favorite heart on the left, the transport in
+  the centre and three 60 px volume buttons on the right. Both Jellyfin headers
+  use an 83 px back button and a top-right time / speed / temperature / Home
+  strip.
 - Jellyfin playback is Media3 1.11.1 in a foreground `mediaPlayback` service.
   The Dart controller owns the queue and polls state; the service owns audio
   focus, the notification and playback reporting. It pauses on permanent focus

BIN
docs/screenshots/jellyfin-album-1024.png


BIN
docs/screenshots/jellyfin-album.png


+ 3 - 3
lib/features/apps/all_apps_screen.dart

@@ -255,8 +255,8 @@ class _BackButton extends StatelessWidget {
       semanticLabel: 'Back',
       pressedScale: 0.92,
       child: Container(
-        width: 46,
-        height: 46,
+        width: 83,
+        height: 83,
         decoration: BoxDecoration(
           color: colors.surface,
           shape: BoxShape.circle,
@@ -265,7 +265,7 @@ class _BackButton extends StatelessWidget {
         alignment: Alignment.center,
         child: AppIcon(
           'assets/icons/arrow-left.svg',
-          size: 20,
+          size: 36,
           color: colors.ink,
         ),
       ),

+ 163 - 190
lib/features/jellyfin/jellyfin_album_screen.dart

@@ -12,6 +12,7 @@ import '../../data/services/jellyfin_library_service.dart';
 import '../home/system_controller.dart';
 import 'jellyfin_playback_controller.dart';
 import 'widgets/jellyfin_back_button.dart';
+import 'widgets/jellyfin_status_strip.dart';
 
 class JellyfinAlbumScreen extends StatefulWidget {
   const JellyfinAlbumScreen({super.key, required this.album});
@@ -165,44 +166,35 @@ class _JellyfinAlbumScreenState extends State<JellyfinAlbumScreen> {
         crossAxisAlignment: CrossAxisAlignment.stretch,
         mainAxisSize: MainAxisSize.min,
         children: [
-          Row(
+          Column(
             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,
-                      ),
-                    ),
-                  ],
+              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) ...[
@@ -290,57 +282,77 @@ class _JellyfinAlbumScreenState extends State<JellyfinAlbumScreen> {
               ],
             ),
             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()),
-                ),
-              ],
+            SizedBox(
+              height: 80,
+              child: Stack(
+                alignment: Alignment.center,
+                children: [
+                  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()),
+                      ),
+                    ],
+                  ),
+                  Align(
+                    alignment: Alignment.centerLeft,
+                    child: _FavoriteButton(
+                      isFavorite: _isFavorite,
+                      busy: _favoriteBusy,
+                      onTap: _toggleFavorite,
+                    ),
+                  ),
+                  Align(
+                    alignment: Alignment.centerRight,
+                    child: _AlbumVolumeControls(system: system),
+                  ),
+                ],
+              ),
             ),
           ],
         ),
       );
-      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],
-        ),
+      final column = Column(
+        crossAxisAlignment: CrossAxisAlignment.stretch,
+        mainAxisSize: MainAxisSize.min,
+        mainAxisAlignment: MainAxisAlignment.spaceBetween,
+        children: [
+          content,
+          Padding(padding: const EdgeInsets.only(top: 14), child: card),
+        ],
+      );
+      if (minHeight == null) return column;
+      return ConstrainedBox(
+        constraints: BoxConstraints(minHeight: minHeight),
+        child: column,
       );
     }
 
@@ -423,27 +435,26 @@ class _JellyfinAlbumScreenState extends State<JellyfinAlbumScreen> {
     }
 
     Widget body(BoxConstraints constraints) {
-      final coverWidth = (constraints.maxWidth * 0.30).clamp(200.0, 360.0);
-      return SingleChildScrollView(
-        child: Column(
-          crossAxisAlignment: CrossAxisAlignment.stretch,
-          children: [
-            Row(
-              crossAxisAlignment: CrossAxisAlignment.start,
-              children: [
-                SizedBox(width: coverWidth, child: cover()),
-                const SizedBox(width: 18),
-                Expanded(child: controls(minHeight: coverWidth)),
-              ],
-            ),
-            const SizedBox(height: 16),
-            SizedBox(
+      final coverWidth = (constraints.maxWidth * 0.21).clamp(140.0, 252.0);
+      return Column(
+        crossAxisAlignment: CrossAxisAlignment.stretch,
+        children: [
+          Row(
+            crossAxisAlignment: CrossAxisAlignment.start,
+            children: [
+              SizedBox(width: coverWidth, child: cover()),
+              const SizedBox(width: 18),
+              Expanded(child: controls(minHeight: coverWidth)),
+            ],
+          ),
+          const SizedBox(height: 16),
+          Expanded(
+            child: SizedBox.expand(
               key: const ValueKey('jellyfin-album-track-list'),
-              height: 260,
               child: trackList(),
             ),
-          ],
-        ),
+          ),
+        ],
       );
     }
 
@@ -457,12 +468,7 @@ class _JellyfinAlbumScreenState extends State<JellyfinAlbumScreen> {
             child: Column(
               crossAxisAlignment: CrossAxisAlignment.stretch,
               children: [
-                _AlbumHeader(
-                  album: widget.album,
-                  isFavorite: _isFavorite,
-                  favoriteBusy: _favoriteBusy,
-                  onToggleFavorite: _toggleFavorite,
-                ),
+                _AlbumHeader(album: widget.album),
                 const SizedBox(height: 14),
                 Expanded(
                   child: LayoutBuilder(
@@ -492,17 +498,9 @@ class _JellyfinAlbumScreenState extends State<JellyfinAlbumScreen> {
 }
 
 class _AlbumHeader extends StatelessWidget {
-  const _AlbumHeader({
-    required this.album,
-    required this.isFavorite,
-    required this.favoriteBusy,
-    required this.onToggleFavorite,
-  });
+  const _AlbumHeader({required this.album});
 
   final JellyfinAlbum album;
-  final bool isFavorite;
-  final bool favoriteBusy;
-  final VoidCallback onToggleFavorite;
 
   @override
   Widget build(BuildContext context) {
@@ -547,11 +545,7 @@ class _AlbumHeader extends StatelessWidget {
           ),
         ),
         const SizedBox(width: 12),
-        _FavoriteButton(
-          isFavorite: isFavorite,
-          busy: favoriteBusy,
-          onTap: onToggleFavorite,
-        ),
+        const JellyfinStatusStrip(),
       ],
     );
   }
@@ -574,9 +568,10 @@ class _FavoriteButton extends StatelessWidget {
     return Pressable(
       onTap: busy ? null : onTap,
       semanticLabel: isFavorite ? 'Remove from favorites' : 'Add to favorites',
+      pressedScale: 0.94,
       child: Container(
-        width: 46,
-        height: 46,
+        width: 69,
+        height: 69,
         decoration: BoxDecoration(
           color: isFavorite ? colors.accentSoft : colors.surface,
           shape: BoxShape.circle,
@@ -589,8 +584,8 @@ class _FavoriteButton extends StatelessWidget {
         alignment: Alignment.center,
         child: busy
             ? SizedBox(
-                width: 16,
-                height: 16,
+                width: 24,
+                height: 24,
                 child: CircularProgressIndicator(
                   strokeWidth: 2,
                   color: colors.accent,
@@ -600,7 +595,7 @@ class _FavoriteButton extends StatelessWidget {
                 isFavorite
                     ? 'assets/icons/heart-filled.svg'
                     : 'assets/icons/heart.svg',
-                size: 21,
+                size: 32,
                 color: isFavorite ? colors.accent : colors.inkMuted,
               ),
       ),
@@ -608,79 +603,57 @@ class _FavoriteButton extends StatelessWidget {
   }
 }
 
-class _AlbumVolumeCard extends StatelessWidget {
-  const _AlbumVolumeCard({required this.system});
+class _AlbumVolumeControls extends StatelessWidget {
+  const _AlbumVolumeControls({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: 40,
-              padding: const EdgeInsets.symmetric(horizontal: 10),
-              decoration: BoxDecoration(
+    return Row(
+      mainAxisSize: MainAxisSize.min,
+      children: [
+        _AlbumVolumeStep(
+          icon: Icons.remove_rounded,
+          semanticLabel: 'Volume down',
+          onTap: enabled ? () => unawaited(system.adjustVolume(-1)) : null,
+        ),
+        const SizedBox(width: 8),
+        Pressable(
+          onTap: enabled ? () => unawaited(system.toggleMute()) : null,
+          semanticLabel: system.isMuted ? 'Unmute' : 'Mute',
+          pressedScale: 0.92,
+          child: Container(
+            width: 60,
+            height: 60,
+            decoration: BoxDecoration(
+              color: system.isMuted ? colors.accentSoft : colors.surfaceStrong,
+              shape: BoxShape.circle,
+              border: Border.all(
                 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: 18,
-                    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,
-                    ),
-                  ),
-                ],
+                    ? colors.accent.withValues(alpha: 0.25)
+                    : colors.border,
               ),
             ),
+            alignment: Alignment.center,
+            child: AppIcon(
+              system.isMuted
+                  ? 'assets/icons/volume-x.svg'
+                  : 'assets/icons/volume-2.svg',
+              size: 26,
+              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,
-          ),
-        ],
-      ),
+        ),
+        const SizedBox(width: 8),
+        _AlbumVolumeStep(
+          icon: Icons.add_rounded,
+          semanticLabel: 'Volume up',
+          onTap: enabled ? () => unawaited(system.adjustVolume(1)) : null,
+        ),
+      ],
     );
   }
 }
@@ -704,8 +677,8 @@ class _AlbumVolumeStep extends StatelessWidget {
       semanticLabel: semanticLabel,
       pressedScale: 0.92,
       child: Container(
-        width: 40,
-        height: 40,
+        width: 60,
+        height: 60,
         decoration: BoxDecoration(
           color: colors.surfaceStrong,
           shape: BoxShape.circle,
@@ -714,7 +687,7 @@ class _AlbumVolumeStep extends StatelessWidget {
         alignment: Alignment.center,
         child: Icon(
           icon,
-          size: 20,
+          size: 26,
           color: onTap == null ? colors.inkFaint : colors.inkMuted,
         ),
       ),
@@ -792,7 +765,7 @@ class _TrackRow extends StatelessWidget {
         borderRadius: BorderRadius.circular(12),
         onTap: onTap,
         child: Padding(
-          padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 7),
+          padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 12),
           child: Row(
             children: [
               SizedBox(

+ 3 - 0
lib/features/jellyfin/jellyfin_dashboard_screen.dart

@@ -14,6 +14,7 @@ import 'jellyfin_album_screen.dart';
 import 'jellyfin_dashboard_controller.dart';
 import 'jellyfin_playback_controller.dart';
 import 'widgets/jellyfin_back_button.dart';
+import 'widgets/jellyfin_status_strip.dart';
 
 class JellyfinDashboardScreen extends StatelessWidget {
   const JellyfinDashboardScreen({super.key, this.openAlbum});
@@ -321,6 +322,8 @@ class _DashboardHeader extends StatelessWidget {
             ],
           ),
         ),
+        const SizedBox(width: 12),
+        const JellyfinStatusStrip(),
       ],
     );
   }

+ 3 - 3
lib/features/jellyfin/widgets/jellyfin_back_button.dart

@@ -15,8 +15,8 @@ class JellyfinBackButton extends StatelessWidget {
       semanticLabel: 'Back',
       pressedScale: 0.92,
       child: Container(
-        width: 46,
-        height: 46,
+        width: 83,
+        height: 83,
         decoration: BoxDecoration(
           color: colors.surface,
           shape: BoxShape.circle,
@@ -25,7 +25,7 @@ class JellyfinBackButton extends StatelessWidget {
         alignment: Alignment.center,
         child: AppIcon(
           'assets/icons/arrow-left.svg',
-          size: 20,
+          size: 36,
           color: colors.ink,
         ),
       ),

+ 178 - 0
lib/features/jellyfin/widgets/jellyfin_status_strip.dart

@@ -0,0 +1,178 @@
+import 'dart:async';
+
+import 'package:flutter/material.dart';
+import 'package:intl/intl.dart';
+import 'package:provider/provider.dart';
+
+import '../../../core/theme/app_colors.dart';
+import '../../../core/widgets/app_icon.dart';
+import '../../../core/widgets/pressable.dart';
+import '../../home/gps_speed_controller.dart';
+import '../../home/obd_controller.dart';
+import '../../home/telemetry_settings_controller.dart';
+import '../../home/weather_controller.dart';
+
+/// Time, speed, temperature and a Home shortcut for the Jellyfin pages.
+class JellyfinStatusStrip extends StatefulWidget {
+  const JellyfinStatusStrip({super.key});
+
+  @override
+  State<JellyfinStatusStrip> createState() => _JellyfinStatusStripState();
+}
+
+class _JellyfinStatusStripState extends State<JellyfinStatusStrip> {
+  late DateTime _now = DateTime.now();
+  Timer? _timer;
+
+  @override
+  void initState() {
+    super.initState();
+    _scheduleTick();
+  }
+
+  void _scheduleTick() {
+    _timer?.cancel();
+    final now = DateTime.now();
+    final next = DateTime(
+      now.year,
+      now.month,
+      now.day,
+      now.hour,
+      now.minute,
+    ).add(const Duration(minutes: 1));
+    _timer = Timer(next.difference(now) + const Duration(milliseconds: 80), () {
+      if (!mounted) return;
+      setState(() => _now = DateTime.now());
+      _scheduleTick();
+    });
+  }
+
+  @override
+  void dispose() {
+    _timer?.cancel();
+    super.dispose();
+  }
+
+  @override
+  Widget build(BuildContext context) {
+    final colors = context.colors;
+    final theme = Theme.of(context);
+    final telemetry = context.watch<TelemetrySettingsController>();
+    final obd = context.watch<ObdController>();
+    final gps = context.watch<GpsSpeedController>();
+    final weather = context.watch<WeatherController>();
+
+    final useObdSpeed =
+        telemetry.speedEnabled && telemetry.speedUseObd && obd.hasTelemetry;
+    final speed = !telemetry.speedEnabled
+        ? null
+        : useObdSpeed
+        ? obd.speedKmh
+        : gps.speedKmh;
+    final useObdTemperature =
+        telemetry.temperatureUseObd && obd.ambientCelsius != null;
+    final temperature = !telemetry.temperatureEnabled
+        ? null
+        : useObdTemperature
+        ? obd.ambientCelsius
+        : (weather.hasTemperature ? weather.temperatureCelsius : null);
+
+    final locale = Localizations.localeOf(context).toLanguageTag();
+    final use24Hour = MediaQuery.alwaysUse24HourFormatOf(context);
+    final time = (use24Hour ? DateFormat.Hm(locale) : DateFormat.jm(locale))
+        .format(_now);
+
+    return Container(
+      key: const ValueKey('jellyfin-status-strip'),
+      padding: const EdgeInsets.fromLTRB(16, 8, 8, 8),
+      decoration: BoxDecoration(
+        color: colors.surface.withValues(alpha: 0.82),
+        borderRadius: BorderRadius.circular(999),
+        border: Border.all(color: colors.border),
+      ),
+      child: Row(
+        mainAxisSize: MainAxisSize.min,
+        children: [
+          Text(
+            time,
+            style: theme.textTheme.labelLarge?.copyWith(
+              color: colors.ink,
+              fontSize: 16,
+              fontFeatures: const [FontFeature.tabularFigures()],
+            ),
+          ),
+          const SizedBox(width: 14),
+          _StripMetric(
+            iconAsset: 'assets/icons/gauge.svg',
+            text: speed == null ? '-- km/h' : '${speed.round()} km/h',
+            available: speed != null,
+          ),
+          const SizedBox(width: 14),
+          _StripMetric(
+            iconAsset: 'assets/icons/thermometer.svg',
+            text: temperature == null ? '--°C' : '${temperature.round()}°C',
+            available: temperature != null,
+          ),
+          const SizedBox(width: 10),
+          Pressable(
+            onTap: () =>
+                Navigator.of(context).popUntil((route) => route.isFirst),
+            semanticLabel: 'Go to home',
+            pressedScale: 0.92,
+            child: Container(
+              width: 42,
+              height: 42,
+              decoration: BoxDecoration(
+                color: colors.surfaceStrong,
+                shape: BoxShape.circle,
+                border: Border.all(color: colors.border),
+              ),
+              alignment: Alignment.center,
+              child: AppIcon(
+                'assets/icons/home.svg',
+                size: 21,
+                color: colors.ink,
+              ),
+            ),
+          ),
+        ],
+      ),
+    );
+  }
+}
+
+class _StripMetric extends StatelessWidget {
+  const _StripMetric({
+    required this.iconAsset,
+    required this.text,
+    required this.available,
+  });
+
+  final String iconAsset;
+  final String text;
+  final bool available;
+
+  @override
+  Widget build(BuildContext context) {
+    final colors = context.colors;
+    final theme = Theme.of(context);
+    return Row(
+      mainAxisSize: MainAxisSize.min,
+      children: [
+        AppIcon(
+          iconAsset,
+          size: 18,
+          color: available ? colors.inkMuted : colors.inkFaint,
+        ),
+        const SizedBox(width: 6),
+        Text(
+          text,
+          style: theme.textTheme.labelLarge?.copyWith(
+            color: available ? colors.ink : colors.inkFaint,
+            fontFeatures: const [FontFeature.tabularFigures()],
+          ),
+        ),
+      ],
+    );
+  }
+}

+ 3 - 3
lib/features/lock/pin_setup_screen.dart

@@ -228,8 +228,8 @@ class _PinSetupScreenState extends State<PinSetupScreen>
                       semanticLabel: 'Back',
                       pressedScale: 0.92,
                       child: Container(
-                        width: 46,
-                        height: 46,
+                        width: 83,
+                        height: 83,
                         decoration: BoxDecoration(
                           color: colors.surface,
                           shape: BoxShape.circle,
@@ -238,7 +238,7 @@ class _PinSetupScreenState extends State<PinSetupScreen>
                         alignment: Alignment.center,
                         child: AppIcon(
                           'assets/icons/arrow-left.svg',
-                          size: 20,
+                          size: 36,
                           color: colors.ink,
                         ),
                       ),

+ 3 - 3
lib/features/settings/settings_screen.dart

@@ -60,8 +60,8 @@ class SettingsScreen extends StatelessWidget {
                           semanticLabel: 'Back',
                           pressedScale: 0.92,
                           child: Container(
-                            width: 46,
-                            height: 46,
+                            width: 83,
+                            height: 83,
                             decoration: BoxDecoration(
                               color: colors.surface,
                               shape: BoxShape.circle,
@@ -70,7 +70,7 @@ class SettingsScreen extends StatelessWidget {
                             alignment: Alignment.center,
                             child: AppIcon(
                               'assets/icons/arrow-left.svg',
-                              size: 20,
+                              size: 36,
                               color: colors.ink,
                             ),
                           ),

+ 30 - 4
test/jellyfin_album_screen_test.dart

@@ -1,12 +1,18 @@
 import 'package:cardash_launcher/core/theme/app_theme.dart';
+import 'package:cardash_launcher/data/repositories/settings_repository.dart';
 import 'package:cardash_launcher/data/services/jellyfin_library_service.dart';
+import 'package:cardash_launcher/features/home/gps_speed_controller.dart';
+import 'package:cardash_launcher/features/home/obd_controller.dart';
 import 'package:cardash_launcher/features/home/system_controller.dart';
+import 'package:cardash_launcher/features/home/telemetry_settings_controller.dart';
+import 'package:cardash_launcher/features/home/weather_controller.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';
 import 'package:flutter/material.dart';
 import 'package:flutter_test/flutter_test.dart';
 import 'package:provider/provider.dart';
+import 'package:shared_preferences/shared_preferences.dart';
 
 import 'jellyfin_fakes.dart';
 
@@ -42,6 +48,17 @@ void main() {
     await playback.playAlbum(albumOne);
 
     final system = SystemController(FakeSystemPlatform());
+    final prefs = await SharedPreferences.getInstance();
+    final settings = SettingsRepository(prefs);
+    final telemetry = FakeTelemetryPlatform();
+    final obd = ObdController(telemetry, settings)..initialize();
+    final gps = GpsSpeedController(telemetry);
+    final weather = WeatherController(telemetry);
+    final telemetrySettings = TelemetrySettingsController(
+      settings,
+      weather,
+      gps,
+    )..initialize();
 
     await tester.pumpWidget(
       MultiProvider(
@@ -52,6 +69,12 @@ void main() {
             value: playback,
           ),
           ChangeNotifierProvider<SystemController>.value(value: system),
+          ChangeNotifierProvider<ObdController>.value(value: obd),
+          ChangeNotifierProvider<GpsSpeedController>.value(value: gps),
+          ChangeNotifierProvider<WeatherController>.value(value: weather),
+          ChangeNotifierProvider<TelemetrySettingsController>.value(
+            value: telemetrySettings,
+          ),
         ],
         child: MaterialApp(
           theme: AppTheme.light(),
@@ -74,6 +97,8 @@ void main() {
     expect(find.bySemanticsLabel('Volume down'), findsOneWidget);
     expect(find.bySemanticsLabel('Volume up'), findsOneWidget);
     expect(find.bySemanticsLabel('Remove from favorites'), findsOneWidget);
+    expect(find.byKey(const ValueKey('jellyfin-status-strip')), findsOneWidget);
+    expect(find.bySemanticsLabel('Go to home'), findsOneWidget);
     final coverRect = tester.getRect(
       find.byKey(const ValueKey('jellyfin-album-cover')),
     );
@@ -124,15 +149,16 @@ void main() {
     final narrowCard = tester.getRect(
       find.byKey(const ValueKey('jellyfin-album-progress-card')),
     );
-    expect(
-      narrowCard.bottom,
-      moreOrLessEquals(narrowCover.bottom, epsilon: 0.5),
-    );
+    expect(narrowCard.bottom, greaterThanOrEqualTo(narrowCover.bottom));
     expect(tester.takeException(), isNull);
 
     await tester.pumpWidget(const SizedBox.shrink());
     playback.dispose();
     jellyfin.dispose();
     system.dispose();
+    obd.dispose();
+    gps.dispose();
+    weather.dispose();
+    telemetrySettings.dispose();
   });
 }

+ 26 - 0
test/jellyfin_dashboard_screen_test.dart

@@ -1,14 +1,20 @@
 import 'package:cardash_launcher/core/theme/app_theme.dart';
 import 'package:cardash_launcher/data/models/jellyfin_library.dart';
 import 'package:cardash_launcher/data/models/jellyfin_models.dart';
+import 'package:cardash_launcher/data/repositories/settings_repository.dart';
 import 'package:cardash_launcher/data/services/jellyfin_library_service.dart';
+import 'package:cardash_launcher/features/home/gps_speed_controller.dart';
+import 'package:cardash_launcher/features/home/obd_controller.dart';
 import 'package:cardash_launcher/features/home/system_controller.dart';
+import 'package:cardash_launcher/features/home/telemetry_settings_controller.dart';
+import 'package:cardash_launcher/features/home/weather_controller.dart';
 import 'package:cardash_launcher/features/jellyfin/jellyfin_dashboard_screen.dart';
 import 'package:cardash_launcher/features/jellyfin/jellyfin_playback_controller.dart';
 import 'package:cardash_launcher/features/settings/jellyfin_controller.dart';
 import 'package:flutter/material.dart';
 import 'package:flutter_test/flutter_test.dart';
 import 'package:provider/provider.dart';
+import 'package:shared_preferences/shared_preferences.dart';
 
 import 'jellyfin_fakes.dart';
 
@@ -84,6 +90,18 @@ Future<void> _pump(
   addTearDown(fixture.jellyfin.dispose);
   final system = SystemController(FakeSystemPlatform());
   addTearDown(system.dispose);
+  final prefs = await SharedPreferences.getInstance();
+  final settings = SettingsRepository(prefs);
+  final telemetry = FakeTelemetryPlatform();
+  final obd = ObdController(telemetry, settings)..initialize();
+  final gps = GpsSpeedController(telemetry);
+  final weather = WeatherController(telemetry);
+  final telemetrySettings = TelemetrySettingsController(settings, weather, gps)
+    ..initialize();
+  addTearDown(obd.dispose);
+  addTearDown(gps.dispose);
+  addTearDown(weather.dispose);
+  addTearDown(telemetrySettings.dispose);
 
   await tester.pumpWidget(
     MultiProvider(
@@ -96,6 +114,12 @@ Future<void> _pump(
           value: fixture.playback,
         ),
         ChangeNotifierProvider<SystemController>.value(value: system),
+        ChangeNotifierProvider<ObdController>.value(value: obd),
+        ChangeNotifierProvider<GpsSpeedController>.value(value: gps),
+        ChangeNotifierProvider<WeatherController>.value(value: weather),
+        ChangeNotifierProvider<TelemetrySettingsController>.value(
+          value: telemetrySettings,
+        ),
       ],
       child: MaterialApp(
         theme: AppTheme.light(),
@@ -122,6 +146,8 @@ void main() {
     expect(find.text('Album r1'), findsOneWidget);
     expect(find.text('Album r6'), findsOneWidget);
     expect(find.bySemanticsLabel('Shuffle random albums'), findsOneWidget);
+    expect(find.byKey(const ValueKey('jellyfin-status-strip')), findsOneWidget);
+    expect(find.bySemanticsLabel('Go to home'), findsOneWidget);
     expect(tester.takeException(), isNull);
   });
 

+ 45 - 0
test/jellyfin_fakes.dart

@@ -1,10 +1,15 @@
 import 'dart:typed_data';
 
 import 'package:cardash_launcher/data/models/app_entry.dart';
+import 'package:cardash_launcher/data/models/bluetooth_device_info.dart';
+import 'package:cardash_launcher/data/models/bluetooth_power_state.dart';
+import 'package:cardash_launcher/data/models/gps_speed.dart';
 import 'package:cardash_launcher/data/models/jellyfin_library.dart';
 import 'package:cardash_launcher/data/models/jellyfin_models.dart';
 import 'package:cardash_launcher/data/models/jellyfin_playback.dart';
+import 'package:cardash_launcher/data/models/location_reading.dart';
 import 'package:cardash_launcher/data/models/media_playback.dart';
+import 'package:cardash_launcher/data/models/obd_state.dart';
 import 'package:cardash_launcher/data/models/system_status.dart';
 import 'package:cardash_launcher/data/models/update_info.dart';
 import 'package:cardash_launcher/data/repositories/settings_repository.dart';
@@ -13,6 +18,7 @@ import 'package:cardash_launcher/data/services/jellyfin_playback_platform.dart';
 import 'package:cardash_launcher/data/services/jellyfin_service.dart';
 import 'package:cardash_launcher/data/services/native_bridge.dart';
 import 'package:cardash_launcher/data/services/secure_store.dart';
+import 'package:cardash_launcher/data/services/telemetry_bridge.dart';
 import 'package:cardash_launcher/features/settings/jellyfin_controller.dart';
 import 'package:shared_preferences/shared_preferences.dart';
 
@@ -168,6 +174,45 @@ class FakeSystemPlatform implements LauncherPlatform {
   }
 }
 
+class FakeTelemetryPlatform implements TelemetryPlatform {
+  @override
+  Future<List<BluetoothDeviceInfo>> listBondedDevices() async => const [];
+
+  @override
+  Future<BluetoothPowerState> bluetoothState() async => BluetoothPowerState.off;
+
+  @override
+  Future<bool> openBluetoothSettings() async => true;
+
+  @override
+  Future<bool> ensureBluetoothPermission() async => true;
+
+  @override
+  Future<bool> ensureLocationPermission() async => true;
+
+  @override
+  Future<LocationReading> getLocation() async =>
+      const LocationReading(latitude: 0, longitude: 0, place: '');
+
+  @override
+  Future<void> connectObd(String address) async {}
+
+  @override
+  Future<void> disconnectObd() async {}
+
+  @override
+  Future<ObdState> getObdState() async => const ObdState();
+
+  @override
+  Future<bool> startGpsSpeed() async => false;
+
+  @override
+  Future<void> stopGpsSpeed() async {}
+
+  @override
+  Future<GpsSpeed> getGpsSpeed() async => const GpsSpeed();
+}
+
 class FakeJellyfinLibraryGateway implements JellyfinLibraryGateway {
   List<JellyfinTrack> played = const [];
   List<JellyfinAlbum> added = const [];