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