| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482 |
- 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 '../../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';
- 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;
- @override
- void initState() {
- super.initState();
- _library = context.read<JellyfinLibraryService>();
- _tracksFuture = _library.albumTracks(widget.album.id);
- }
- void _reloadTracks() {
- setState(() {
- _tracksFuture = _library.albumTracks(widget.album.id);
- });
- }
- @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(
- 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() {
- final error = playback.error;
- 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),
- ),
- 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'),
- ),
- ],
- ),
- ],
- 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,
- ),
- ],
- );
- }
- 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 wideBody(BoxConstraints constraints) {
- final coverWidth = (constraints.maxWidth * 0.30).clamp(280.0, 380.0);
- final controlsWidth = (constraints.maxWidth * 0.44).clamp(520.0, 600.0);
- return Row(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- SizedBox(width: coverWidth, child: cover()),
- const SizedBox(width: 22),
- Expanded(child: trackList()),
- const SizedBox(width: 22),
- SizedBox(
- width: controlsWidth,
- child: SingleChildScrollView(child: controls()),
- ),
- ],
- );
- }
- Widget narrowBody(BoxConstraints constraints) {
- final coverWidth = (constraints.maxWidth * 0.34).clamp(200.0, 260.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()),
- ],
- ),
- const SizedBox(height: 16),
- SizedBox(height: 260, 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) {
- return constraints.maxWidth >= 1180
- ? wideBody(constraints)
- : narrowBody(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);
- 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(
- album.name,
- 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,
- ),
- ),
- ],
- ),
- ),
- ],
- );
- }
- }
- 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: 7),
- 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)}';
- }
|