| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828 |
- import 'dart:async';
- import 'package:flutter/material.dart';
- import 'package:provider/provider.dart';
- import '../../core/theme/app_colors.dart';
- import '../../core/widgets/app_icon.dart';
- import '../../core/widgets/pressable.dart';
- 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 '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});
- final JellyfinAlbum album;
- @override
- State<JellyfinAlbumScreen> createState() => _JellyfinAlbumScreenState();
- }
- class _JellyfinAlbumScreenState extends State<JellyfinAlbumScreen> {
- late final JellyfinLibraryService _library;
- late Future<List<JellyfinTrack>> _tracksFuture;
- late bool _isFavorite;
- bool _favoriteBusy = false;
- double? _seekDragMs;
- @override
- void initState() {
- super.initState();
- _library = context.read<JellyfinLibraryService>();
- _tracksFuture = _library.albumTracks(widget.album.id);
- _isFavorite = widget.album.isFavorite;
- unawaited(_loadFavorite());
- }
- void _reloadTracks() {
- setState(() {
- _tracksFuture = _library.albumTracks(widget.album.id);
- });
- }
- Future<void> _loadFavorite() async {
- try {
- final favorite = await _library.isFavorite(widget.album.id);
- if (!mounted) return;
- setState(() => _isFavorite = favorite);
- } catch (_) {
- // Keep the album's incoming state if the lookup fails.
- }
- }
- Future<void> _toggleFavorite() async {
- if (_favoriteBusy) return;
- final next = !_isFavorite;
- setState(() => _favoriteBusy = true);
- try {
- await _library.setFavorite(widget.album.id, next);
- if (!mounted) return;
- setState(() {
- _isFavorite = next;
- _favoriteBusy = false;
- });
- } catch (_) {
- if (!mounted) return;
- setState(() => _favoriteBusy = false);
- ScaffoldMessenger.of(context).showSnackBar(
- const SnackBar(content: Text('Could not update favorites.')),
- );
- }
- }
- @override
- Widget build(BuildContext context) {
- final colors = context.colors;
- final theme = Theme.of(context);
- final playback = context.watch<JellyfinPlaybackController>();
- final system = context.watch<SystemController>();
- final active = playback.isCurrentAlbum(widget.album);
- final playing = active && playback.playing;
- final currentItem = active ? playback.currentItem : null;
- final currentTitle = active
- ? (currentItem?.title ?? playback.snapshot.title ?? widget.album.name)
- : playback.loading
- ? 'Starting playback…'
- : widget.album.name;
- final currentArtist = active
- ? (currentItem?.artist ??
- playback.snapshot.artist ??
- widget.album.artist)
- : widget.album.artist;
- final currentTrackId = active
- ? (currentItem?.trackId ?? playback.snapshot.itemId)
- : null;
- final media = active
- ? playback.mediaPlayback
- : MediaPlayback(
- hasAccess: true,
- title: widget.album.name,
- artist: widget.album.artist,
- );
- final imageUrl = _library
- .imageUrl(widget.album.id, widget.album.imageTag, maxHeight: 600)
- ?.toString();
- final headers = _library.imageHeaders;
- Widget cover() {
- final placeholder = ColoredBox(
- color: colors.surfaceStrong,
- child: Center(
- child: AppIcon(
- 'assets/icons/headphones.svg',
- size: 54,
- color: colors.inkFaint,
- ),
- ),
- );
- return Align(
- alignment: Alignment.topCenter,
- child: ConstrainedBox(
- constraints: const BoxConstraints(maxWidth: 420),
- child: AspectRatio(
- aspectRatio: 1,
- child: Container(
- key: const ValueKey('jellyfin-album-cover'),
- clipBehavior: Clip.antiAlias,
- decoration: BoxDecoration(
- borderRadius: BorderRadius.circular(24),
- border: Border.all(color: colors.border),
- ),
- child: imageUrl == null
- ? placeholder
- : Image.network(
- imageUrl,
- headers: headers,
- fit: BoxFit.cover,
- errorBuilder: (_, _, _) => placeholder,
- ),
- ),
- ),
- ),
- );
- }
- Widget controls({double? minHeight}) {
- 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;
- final content = Column(
- crossAxisAlignment: CrossAxisAlignment.stretch,
- mainAxisSize: MainAxisSize.min,
- children: [
- 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,
- ),
- ),
- ],
- ),
- if (error != null) ...[
- const SizedBox(height: 8),
- Row(
- children: [
- Expanded(
- child: Text(
- error,
- style: theme.textTheme.bodySmall?.copyWith(
- color: colors.danger,
- ),
- ),
- ),
- TextButton(
- onPressed: () {
- unawaited(playback.playAlbum(widget.album));
- },
- style: TextButton.styleFrom(foregroundColor: colors.accent),
- child: const Text('Retry'),
- ),
- ],
- ),
- ],
- ],
- );
- 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: [
- 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,
- ),
- overlayShape: const RoundSliderOverlayShape(
- overlayRadius: 18,
- ),
- ),
- 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),
- 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),
- ),
- ],
- ),
- ),
- ],
- ),
- );
- 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,
- );
- }
- Widget trackList() {
- return Column(
- crossAxisAlignment: CrossAxisAlignment.stretch,
- children: [
- Row(
- children: [
- Text(
- 'Tracks',
- style: theme.textTheme.titleMedium?.copyWith(
- color: colors.ink,
- fontWeight: FontWeight.w700,
- ),
- ),
- ],
- ),
- const SizedBox(height: 6),
- Expanded(
- child: FutureBuilder<List<JellyfinTrack>>(
- future: _tracksFuture,
- builder: (context, snapshot) {
- if (snapshot.connectionState == ConnectionState.waiting) {
- return const Center(child: CircularProgressIndicator());
- }
- if (snapshot.hasError) {
- return Center(
- child: Column(
- mainAxisSize: MainAxisSize.min,
- children: [
- Text(
- 'Could not load tracks.',
- style: theme.textTheme.bodySmall?.copyWith(
- color: colors.inkMuted,
- ),
- ),
- TextButton(
- onPressed: _reloadTracks,
- style: TextButton.styleFrom(
- foregroundColor: colors.accent,
- ),
- child: const Text('Retry'),
- ),
- ],
- ),
- );
- }
- final tracks = snapshot.data ?? const <JellyfinTrack>[];
- if (tracks.isEmpty) {
- return Center(
- child: Text(
- 'No tracks in this album.',
- style: theme.textTheme.bodySmall?.copyWith(
- color: colors.inkMuted,
- ),
- ),
- );
- }
- return ListView.separated(
- padding: EdgeInsets.zero,
- itemCount: tracks.length,
- separatorBuilder: (_, _) =>
- Divider(color: colors.border, height: 1),
- itemBuilder: (context, index) {
- final track = tracks[index];
- return _TrackRow(
- track: track,
- isCurrent: track.id == currentTrackId,
- isPlaying: playing,
- onTap: () => _playTrack(tracks, index),
- );
- },
- );
- },
- ),
- ),
- ],
- );
- }
- Widget body(BoxConstraints constraints) {
- 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'),
- child: trackList(),
- ),
- ),
- ],
- );
- }
- return Scaffold(
- backgroundColor: colors.canvas,
- body: Container(
- decoration: homeBackground(colors, theme.brightness),
- child: SafeArea(
- child: Padding(
- padding: const EdgeInsets.fromLTRB(36, 22, 36, 16),
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.stretch,
- children: [
- _AlbumHeader(album: widget.album),
- const SizedBox(height: 14),
- Expanded(
- child: LayoutBuilder(
- builder: (context, constraints) => body(constraints),
- ),
- ),
- ],
- ),
- ),
- ),
- ),
- );
- }
- Future<void> _playTrack(List<JellyfinTrack> tracks, int index) async {
- final playback = context.read<JellyfinPlaybackController>();
- if (playback.loading) {
- await playback.playTrackAt(index);
- return;
- }
- if (!playback.isCurrentAlbum(widget.album)) {
- final error = await playback.playAlbum(widget.album);
- if (error != null) return;
- }
- await playback.playTrackAt(index);
- }
- }
- class _AlbumHeader extends StatelessWidget {
- const _AlbumHeader({required this.album});
- final JellyfinAlbum album;
- @override
- Widget build(BuildContext context) {
- final colors = context.colors;
- final theme = Theme.of(context);
- final year = album.year;
- final title = year == null || year <= 0 || album.name.contains('($year)')
- ? album.name
- : '${album.name} ($year)';
- return Row(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- const JellyfinBackButton(),
- const SizedBox(width: 18),
- Expanded(
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- Text(
- 'Album',
- style: theme.textTheme.labelLarge?.copyWith(
- color: colors.inkMuted,
- ),
- ),
- const SizedBox(height: 2),
- Text(
- title,
- maxLines: 2,
- overflow: TextOverflow.ellipsis,
- style: theme.textTheme.headlineLarge,
- ),
- const SizedBox(height: 2),
- Text(
- album.artist.isEmpty ? 'Unknown artist' : album.artist,
- maxLines: 1,
- overflow: TextOverflow.ellipsis,
- style: theme.textTheme.bodySmall?.copyWith(
- color: colors.inkMuted,
- ),
- ),
- ],
- ),
- ),
- const SizedBox(width: 12),
- const JellyfinStatusStrip(),
- ],
- );
- }
- }
- class _FavoriteButton extends StatelessWidget {
- const _FavoriteButton({
- required this.isFavorite,
- required this.busy,
- required this.onTap,
- });
- final bool isFavorite;
- final bool busy;
- final VoidCallback onTap;
- @override
- Widget build(BuildContext context) {
- final colors = context.colors;
- return Pressable(
- onTap: busy ? null : onTap,
- semanticLabel: isFavorite ? 'Remove from favorites' : 'Add to favorites',
- pressedScale: 0.94,
- child: Container(
- width: 69,
- height: 69,
- decoration: BoxDecoration(
- color: isFavorite ? colors.accentSoft : colors.surface,
- shape: BoxShape.circle,
- border: Border.all(
- color: isFavorite
- ? colors.accent.withValues(alpha: 0.3)
- : colors.border,
- ),
- ),
- alignment: Alignment.center,
- child: busy
- ? SizedBox(
- width: 24,
- height: 24,
- child: CircularProgressIndicator(
- strokeWidth: 2,
- color: colors.accent,
- ),
- )
- : AppIcon(
- isFavorite
- ? 'assets/icons/heart-filled.svg'
- : 'assets/icons/heart.svg',
- size: 32,
- color: isFavorite ? colors.accent : colors.inkMuted,
- ),
- ),
- );
- }
- }
- class _AlbumVolumeControls extends StatelessWidget {
- const _AlbumVolumeControls({required this.system});
- final SystemController system;
- @override
- Widget build(BuildContext context) {
- final colors = context.colors;
- final enabled = !system.isVolumeFixed;
- 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.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: 8),
- _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: 60,
- height: 60,
- decoration: BoxDecoration(
- color: colors.surfaceStrong,
- shape: BoxShape.circle,
- border: Border.all(color: colors.border),
- ),
- alignment: Alignment.center,
- child: Icon(
- icon,
- size: 26,
- 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 ? 80.0 : 62.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 ? 32 : 26,
- color: primary ? colors.accentInk : colors.ink,
- ),
- ),
- ),
- );
- }
- }
- class _TrackRow extends StatelessWidget {
- const _TrackRow({
- required this.track,
- required this.isCurrent,
- required this.isPlaying,
- required this.onTap,
- });
- final JellyfinTrack track;
- final bool isCurrent;
- final bool isPlaying;
- final VoidCallback onTap;
- @override
- Widget build(BuildContext context) {
- final colors = context.colors;
- final theme = Theme.of(context);
- final color = isCurrent ? colors.accent : colors.ink;
- return Material(
- color: isCurrent ? colors.accentSoft : Colors.transparent,
- borderRadius: BorderRadius.circular(12),
- child: InkWell(
- borderRadius: BorderRadius.circular(12),
- onTap: onTap,
- child: Padding(
- padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 12),
- child: Row(
- children: [
- SizedBox(
- width: 28,
- child: Text(
- track.displayIndex,
- style: theme.textTheme.bodySmall?.copyWith(
- color: isCurrent ? colors.accent : colors.inkFaint,
- ),
- ),
- ),
- const SizedBox(width: 6),
- Expanded(
- child: Text(
- track.name,
- maxLines: 1,
- overflow: TextOverflow.ellipsis,
- style: theme.textTheme.bodyMedium?.copyWith(
- color: color,
- fontWeight: isCurrent ? FontWeight.w600 : FontWeight.w400,
- ),
- ),
- ),
- const SizedBox(width: 8),
- Text(
- _formatDuration(track.duration),
- style: theme.textTheme.bodySmall?.copyWith(
- color: isCurrent ? colors.accent : colors.inkMuted,
- ),
- ),
- if (isCurrent) ...[
- const SizedBox(width: 8),
- AppIcon(
- isPlaying
- ? 'assets/icons/volume-2.svg'
- : 'assets/icons/play.svg',
- size: 14,
- color: colors.accent,
- ),
- ],
- ],
- ),
- ),
- ),
- );
- }
- }
- String _formatDuration(Duration duration) {
- final totalSeconds = duration.inSeconds;
- final minutes = totalSeconds ~/ 60;
- final seconds = totalSeconds % 60;
- final hours = minutes ~/ 60;
- final remainingMinutes = minutes % 60;
- String two(int value) => value.toString().padLeft(2, '0');
- if (hours > 0) {
- return '$hours:${two(remainingMinutes)}:${two(seconds)}';
- }
- return '$minutes:${two(seconds)}';
- }
|