jellyfin_album_screen.dart 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588
  1. import 'dart:async';
  2. import 'package:flutter/material.dart';
  3. import 'package:provider/provider.dart';
  4. import '../../core/theme/app_colors.dart';
  5. import '../../core/widgets/app_icon.dart';
  6. import '../../core/widgets/pressable.dart';
  7. import '../../data/models/jellyfin_library.dart';
  8. import '../../data/models/media_playback.dart';
  9. import '../../data/services/jellyfin_library_service.dart';
  10. import '../home/system_controller.dart';
  11. import '../home/widgets/volume_card.dart';
  12. import 'jellyfin_playback_controller.dart';
  13. import 'widgets/jellyfin_back_button.dart';
  14. class JellyfinAlbumScreen extends StatefulWidget {
  15. const JellyfinAlbumScreen({super.key, required this.album});
  16. final JellyfinAlbum album;
  17. @override
  18. State<JellyfinAlbumScreen> createState() => _JellyfinAlbumScreenState();
  19. }
  20. class _JellyfinAlbumScreenState extends State<JellyfinAlbumScreen> {
  21. late final JellyfinLibraryService _library;
  22. late Future<List<JellyfinTrack>> _tracksFuture;
  23. late bool _isFavorite;
  24. bool _favoriteBusy = false;
  25. @override
  26. void initState() {
  27. super.initState();
  28. _library = context.read<JellyfinLibraryService>();
  29. _tracksFuture = _library.albumTracks(widget.album.id);
  30. _isFavorite = widget.album.isFavorite;
  31. unawaited(_loadFavorite());
  32. }
  33. void _reloadTracks() {
  34. setState(() {
  35. _tracksFuture = _library.albumTracks(widget.album.id);
  36. });
  37. }
  38. Future<void> _loadFavorite() async {
  39. try {
  40. final favorite = await _library.isFavorite(widget.album.id);
  41. if (!mounted) return;
  42. setState(() => _isFavorite = favorite);
  43. } catch (_) {
  44. // Keep the album's incoming state if the lookup fails.
  45. }
  46. }
  47. Future<void> _toggleFavorite() async {
  48. if (_favoriteBusy) return;
  49. final next = !_isFavorite;
  50. setState(() => _favoriteBusy = true);
  51. try {
  52. await _library.setFavorite(widget.album.id, next);
  53. if (!mounted) return;
  54. setState(() {
  55. _isFavorite = next;
  56. _favoriteBusy = false;
  57. });
  58. } catch (_) {
  59. if (!mounted) return;
  60. setState(() => _favoriteBusy = false);
  61. ScaffoldMessenger.of(context).showSnackBar(
  62. const SnackBar(content: Text('Could not update favorites.')),
  63. );
  64. }
  65. }
  66. @override
  67. Widget build(BuildContext context) {
  68. final colors = context.colors;
  69. final theme = Theme.of(context);
  70. final playback = context.watch<JellyfinPlaybackController>();
  71. final system = context.watch<SystemController>();
  72. final active = playback.isCurrentAlbum(widget.album);
  73. final playing = active && playback.playing;
  74. final currentItem = active ? playback.currentItem : null;
  75. final currentTitle = active
  76. ? (currentItem?.title ?? playback.snapshot.title ?? widget.album.name)
  77. : playback.loading
  78. ? 'Starting playback…'
  79. : widget.album.name;
  80. final currentArtist = active
  81. ? (currentItem?.artist ??
  82. playback.snapshot.artist ??
  83. widget.album.artist)
  84. : widget.album.artist;
  85. final currentTrackId = active
  86. ? (currentItem?.trackId ?? playback.snapshot.itemId)
  87. : null;
  88. final media = active
  89. ? playback.mediaPlayback
  90. : MediaPlayback(
  91. hasAccess: true,
  92. title: widget.album.name,
  93. artist: widget.album.artist,
  94. );
  95. final imageUrl = _library
  96. .imageUrl(widget.album.id, widget.album.imageTag, maxHeight: 600)
  97. ?.toString();
  98. final headers = _library.imageHeaders;
  99. Widget cover() {
  100. final placeholder = ColoredBox(
  101. color: colors.surfaceStrong,
  102. child: Center(
  103. child: AppIcon(
  104. 'assets/icons/headphones.svg',
  105. size: 54,
  106. color: colors.inkFaint,
  107. ),
  108. ),
  109. );
  110. return Align(
  111. alignment: Alignment.topCenter,
  112. child: ConstrainedBox(
  113. constraints: const BoxConstraints(maxWidth: 420),
  114. child: AspectRatio(
  115. aspectRatio: 1,
  116. child: Container(
  117. clipBehavior: Clip.antiAlias,
  118. decoration: BoxDecoration(
  119. borderRadius: BorderRadius.circular(24),
  120. border: Border.all(color: colors.border),
  121. ),
  122. child: imageUrl == null
  123. ? placeholder
  124. : Image.network(
  125. imageUrl,
  126. headers: headers,
  127. fit: BoxFit.cover,
  128. errorBuilder: (_, _, _) => placeholder,
  129. ),
  130. ),
  131. ),
  132. ),
  133. );
  134. }
  135. Widget controls() {
  136. final error = playback.error;
  137. return Column(
  138. crossAxisAlignment: CrossAxisAlignment.stretch,
  139. mainAxisSize: MainAxisSize.min,
  140. children: [
  141. Text(
  142. active
  143. ? (playing ? 'Now playing' : 'Paused')
  144. : playback.loading
  145. ? 'Starting playback…'
  146. : 'Ready',
  147. style: theme.textTheme.labelLarge?.copyWith(color: colors.inkMuted),
  148. ),
  149. const SizedBox(height: 4),
  150. Text(
  151. currentTitle,
  152. maxLines: 2,
  153. overflow: TextOverflow.ellipsis,
  154. style: theme.textTheme.headlineSmall,
  155. ),
  156. const SizedBox(height: 2),
  157. Text(
  158. currentArtist.isEmpty ? 'Unknown artist' : currentArtist,
  159. maxLines: 1,
  160. overflow: TextOverflow.ellipsis,
  161. style: theme.textTheme.bodyMedium?.copyWith(color: colors.inkMuted),
  162. ),
  163. if (error != null) ...[
  164. const SizedBox(height: 8),
  165. Row(
  166. children: [
  167. Expanded(
  168. child: Text(
  169. error,
  170. style: theme.textTheme.bodySmall?.copyWith(
  171. color: colors.danger,
  172. ),
  173. ),
  174. ),
  175. TextButton(
  176. onPressed: () {
  177. unawaited(playback.playAlbum(widget.album));
  178. },
  179. style: TextButton.styleFrom(foregroundColor: colors.accent),
  180. child: const Text('Retry'),
  181. ),
  182. ],
  183. ),
  184. ],
  185. const SizedBox(height: 12),
  186. VolumeCard(
  187. volume: system.volume,
  188. onSetVolume: system.setVolume,
  189. onToggleMute: system.toggleMute,
  190. isMusicPlaying: playing,
  191. onPlayPause: () {
  192. if (playback.loading) return;
  193. if (playback.isCurrentAlbum(widget.album)) {
  194. unawaited(playback.toggle());
  195. } else {
  196. unawaited(playback.playAlbum(widget.album));
  197. }
  198. },
  199. media: media,
  200. large: true,
  201. onPrevious: active ? () => unawaited(playback.previous()) : null,
  202. onNext: active ? () => unawaited(playback.next()) : null,
  203. canPrevious: active && playback.canGoPrevious,
  204. canNext: active && playback.canGoNext,
  205. unconfirmed: system.volumeUnconfirmed,
  206. ),
  207. ],
  208. );
  209. }
  210. Widget trackList() {
  211. return Column(
  212. crossAxisAlignment: CrossAxisAlignment.stretch,
  213. children: [
  214. Row(
  215. children: [
  216. Text(
  217. 'Tracks',
  218. style: theme.textTheme.titleMedium?.copyWith(
  219. color: colors.ink,
  220. fontWeight: FontWeight.w700,
  221. ),
  222. ),
  223. ],
  224. ),
  225. const SizedBox(height: 6),
  226. Expanded(
  227. child: FutureBuilder<List<JellyfinTrack>>(
  228. future: _tracksFuture,
  229. builder: (context, snapshot) {
  230. if (snapshot.connectionState == ConnectionState.waiting) {
  231. return const Center(child: CircularProgressIndicator());
  232. }
  233. if (snapshot.hasError) {
  234. return Center(
  235. child: Column(
  236. mainAxisSize: MainAxisSize.min,
  237. children: [
  238. Text(
  239. 'Could not load tracks.',
  240. style: theme.textTheme.bodySmall?.copyWith(
  241. color: colors.inkMuted,
  242. ),
  243. ),
  244. TextButton(
  245. onPressed: _reloadTracks,
  246. style: TextButton.styleFrom(
  247. foregroundColor: colors.accent,
  248. ),
  249. child: const Text('Retry'),
  250. ),
  251. ],
  252. ),
  253. );
  254. }
  255. final tracks = snapshot.data ?? const <JellyfinTrack>[];
  256. if (tracks.isEmpty) {
  257. return Center(
  258. child: Text(
  259. 'No tracks in this album.',
  260. style: theme.textTheme.bodySmall?.copyWith(
  261. color: colors.inkMuted,
  262. ),
  263. ),
  264. );
  265. }
  266. return ListView.separated(
  267. padding: EdgeInsets.zero,
  268. itemCount: tracks.length,
  269. separatorBuilder: (_, _) =>
  270. Divider(color: colors.border, height: 1),
  271. itemBuilder: (context, index) {
  272. final track = tracks[index];
  273. return _TrackRow(
  274. track: track,
  275. isCurrent: track.id == currentTrackId,
  276. isPlaying: playing,
  277. onTap: () => _playTrack(tracks, index),
  278. );
  279. },
  280. );
  281. },
  282. ),
  283. ),
  284. ],
  285. );
  286. }
  287. Widget wideBody(BoxConstraints constraints) {
  288. final coverWidth = (constraints.maxWidth * 0.30).clamp(280.0, 380.0);
  289. final controlsWidth = (constraints.maxWidth * 0.44).clamp(520.0, 600.0);
  290. return Row(
  291. crossAxisAlignment: CrossAxisAlignment.start,
  292. children: [
  293. SizedBox(width: coverWidth, child: cover()),
  294. const SizedBox(width: 22),
  295. Expanded(child: trackList()),
  296. const SizedBox(width: 22),
  297. SizedBox(
  298. width: controlsWidth,
  299. child: SingleChildScrollView(child: controls()),
  300. ),
  301. ],
  302. );
  303. }
  304. Widget narrowBody(BoxConstraints constraints) {
  305. final coverWidth = (constraints.maxWidth * 0.34).clamp(200.0, 260.0);
  306. return SingleChildScrollView(
  307. child: Column(
  308. crossAxisAlignment: CrossAxisAlignment.stretch,
  309. children: [
  310. Row(
  311. crossAxisAlignment: CrossAxisAlignment.start,
  312. children: [
  313. SizedBox(width: coverWidth, child: cover()),
  314. const SizedBox(width: 18),
  315. Expanded(child: controls()),
  316. ],
  317. ),
  318. const SizedBox(height: 16),
  319. SizedBox(height: 260, child: trackList()),
  320. ],
  321. ),
  322. );
  323. }
  324. return Scaffold(
  325. backgroundColor: colors.canvas,
  326. body: Container(
  327. decoration: homeBackground(colors, theme.brightness),
  328. child: SafeArea(
  329. child: Padding(
  330. padding: const EdgeInsets.fromLTRB(36, 22, 36, 16),
  331. child: Column(
  332. crossAxisAlignment: CrossAxisAlignment.stretch,
  333. children: [
  334. _AlbumHeader(
  335. album: widget.album,
  336. isFavorite: _isFavorite,
  337. favoriteBusy: _favoriteBusy,
  338. onToggleFavorite: _toggleFavorite,
  339. ),
  340. const SizedBox(height: 14),
  341. Expanded(
  342. child: LayoutBuilder(
  343. builder: (context, constraints) {
  344. return constraints.maxWidth >= 1180
  345. ? wideBody(constraints)
  346. : narrowBody(constraints);
  347. },
  348. ),
  349. ),
  350. ],
  351. ),
  352. ),
  353. ),
  354. ),
  355. );
  356. }
  357. Future<void> _playTrack(List<JellyfinTrack> tracks, int index) async {
  358. final playback = context.read<JellyfinPlaybackController>();
  359. if (playback.loading) {
  360. await playback.playTrackAt(index);
  361. return;
  362. }
  363. if (!playback.isCurrentAlbum(widget.album)) {
  364. final error = await playback.playAlbum(widget.album);
  365. if (error != null) return;
  366. }
  367. await playback.playTrackAt(index);
  368. }
  369. }
  370. class _AlbumHeader extends StatelessWidget {
  371. const _AlbumHeader({
  372. required this.album,
  373. required this.isFavorite,
  374. required this.favoriteBusy,
  375. required this.onToggleFavorite,
  376. });
  377. final JellyfinAlbum album;
  378. final bool isFavorite;
  379. final bool favoriteBusy;
  380. final VoidCallback onToggleFavorite;
  381. @override
  382. Widget build(BuildContext context) {
  383. final colors = context.colors;
  384. final theme = Theme.of(context);
  385. return Row(
  386. crossAxisAlignment: CrossAxisAlignment.start,
  387. children: [
  388. const JellyfinBackButton(),
  389. const SizedBox(width: 18),
  390. Expanded(
  391. child: Column(
  392. crossAxisAlignment: CrossAxisAlignment.start,
  393. children: [
  394. Text(
  395. 'Album',
  396. style: theme.textTheme.labelLarge?.copyWith(
  397. color: colors.inkMuted,
  398. ),
  399. ),
  400. const SizedBox(height: 2),
  401. Text(
  402. album.name,
  403. maxLines: 2,
  404. overflow: TextOverflow.ellipsis,
  405. style: theme.textTheme.headlineLarge,
  406. ),
  407. const SizedBox(height: 2),
  408. Text(
  409. album.artist.isEmpty ? 'Unknown artist' : album.artist,
  410. maxLines: 1,
  411. overflow: TextOverflow.ellipsis,
  412. style: theme.textTheme.bodySmall?.copyWith(
  413. color: colors.inkMuted,
  414. ),
  415. ),
  416. ],
  417. ),
  418. ),
  419. const SizedBox(width: 12),
  420. _FavoriteButton(
  421. isFavorite: isFavorite,
  422. busy: favoriteBusy,
  423. onTap: onToggleFavorite,
  424. ),
  425. ],
  426. );
  427. }
  428. }
  429. class _FavoriteButton extends StatelessWidget {
  430. const _FavoriteButton({
  431. required this.isFavorite,
  432. required this.busy,
  433. required this.onTap,
  434. });
  435. final bool isFavorite;
  436. final bool busy;
  437. final VoidCallback onTap;
  438. @override
  439. Widget build(BuildContext context) {
  440. final colors = context.colors;
  441. return Pressable(
  442. onTap: busy ? null : onTap,
  443. semanticLabel: isFavorite ? 'Remove from favorites' : 'Add to favorites',
  444. child: Container(
  445. width: 46,
  446. height: 46,
  447. decoration: BoxDecoration(
  448. color: isFavorite ? colors.accentSoft : colors.surface,
  449. shape: BoxShape.circle,
  450. border: Border.all(
  451. color: isFavorite
  452. ? colors.accent.withValues(alpha: 0.3)
  453. : colors.border,
  454. ),
  455. ),
  456. alignment: Alignment.center,
  457. child: busy
  458. ? SizedBox(
  459. width: 16,
  460. height: 16,
  461. child: CircularProgressIndicator(
  462. strokeWidth: 2,
  463. color: colors.accent,
  464. ),
  465. )
  466. : AppIcon(
  467. isFavorite
  468. ? 'assets/icons/heart-filled.svg'
  469. : 'assets/icons/heart.svg',
  470. size: 21,
  471. color: isFavorite ? colors.accent : colors.inkMuted,
  472. ),
  473. ),
  474. );
  475. }
  476. }
  477. class _TrackRow extends StatelessWidget {
  478. const _TrackRow({
  479. required this.track,
  480. required this.isCurrent,
  481. required this.isPlaying,
  482. required this.onTap,
  483. });
  484. final JellyfinTrack track;
  485. final bool isCurrent;
  486. final bool isPlaying;
  487. final VoidCallback onTap;
  488. @override
  489. Widget build(BuildContext context) {
  490. final colors = context.colors;
  491. final theme = Theme.of(context);
  492. final color = isCurrent ? colors.accent : colors.ink;
  493. return Material(
  494. color: isCurrent ? colors.accentSoft : Colors.transparent,
  495. borderRadius: BorderRadius.circular(12),
  496. child: InkWell(
  497. borderRadius: BorderRadius.circular(12),
  498. onTap: onTap,
  499. child: Padding(
  500. padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 7),
  501. child: Row(
  502. children: [
  503. SizedBox(
  504. width: 28,
  505. child: Text(
  506. track.displayIndex,
  507. style: theme.textTheme.bodySmall?.copyWith(
  508. color: isCurrent ? colors.accent : colors.inkFaint,
  509. ),
  510. ),
  511. ),
  512. const SizedBox(width: 6),
  513. Expanded(
  514. child: Text(
  515. track.name,
  516. maxLines: 1,
  517. overflow: TextOverflow.ellipsis,
  518. style: theme.textTheme.bodyMedium?.copyWith(
  519. color: color,
  520. fontWeight: isCurrent ? FontWeight.w600 : FontWeight.w400,
  521. ),
  522. ),
  523. ),
  524. const SizedBox(width: 8),
  525. Text(
  526. _formatDuration(track.duration),
  527. style: theme.textTheme.bodySmall?.copyWith(
  528. color: isCurrent ? colors.accent : colors.inkMuted,
  529. ),
  530. ),
  531. if (isCurrent) ...[
  532. const SizedBox(width: 8),
  533. AppIcon(
  534. isPlaying
  535. ? 'assets/icons/volume-2.svg'
  536. : 'assets/icons/play.svg',
  537. size: 14,
  538. color: colors.accent,
  539. ),
  540. ],
  541. ],
  542. ),
  543. ),
  544. ),
  545. );
  546. }
  547. }
  548. String _formatDuration(Duration duration) {
  549. final totalSeconds = duration.inSeconds;
  550. final minutes = totalSeconds ~/ 60;
  551. final seconds = totalSeconds % 60;
  552. final hours = minutes ~/ 60;
  553. final remainingMinutes = minutes % 60;
  554. String two(int value) => value.toString().padLeft(2, '0');
  555. if (hours > 0) {
  556. return '$hours:${two(remainingMinutes)}:${two(seconds)}';
  557. }
  558. return '$minutes:${two(seconds)}';
  559. }