|
|
@@ -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(
|