jellyfin_album_screen.dart 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587
  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. ),
  206. ],
  207. );
  208. }
  209. Widget trackList() {
  210. return Column(
  211. crossAxisAlignment: CrossAxisAlignment.stretch,
  212. children: [
  213. Row(
  214. children: [
  215. Text(
  216. 'Tracks',
  217. style: theme.textTheme.titleMedium?.copyWith(
  218. color: colors.ink,
  219. fontWeight: FontWeight.w700,
  220. ),
  221. ),
  222. ],
  223. ),
  224. const SizedBox(height: 6),
  225. Expanded(
  226. child: FutureBuilder<List<JellyfinTrack>>(
  227. future: _tracksFuture,
  228. builder: (context, snapshot) {
  229. if (snapshot.connectionState == ConnectionState.waiting) {
  230. return const Center(child: CircularProgressIndicator());
  231. }
  232. if (snapshot.hasError) {
  233. return Center(
  234. child: Column(
  235. mainAxisSize: MainAxisSize.min,
  236. children: [
  237. Text(
  238. 'Could not load tracks.',
  239. style: theme.textTheme.bodySmall?.copyWith(
  240. color: colors.inkMuted,
  241. ),
  242. ),
  243. TextButton(
  244. onPressed: _reloadTracks,
  245. style: TextButton.styleFrom(
  246. foregroundColor: colors.accent,
  247. ),
  248. child: const Text('Retry'),
  249. ),
  250. ],
  251. ),
  252. );
  253. }
  254. final tracks = snapshot.data ?? const <JellyfinTrack>[];
  255. if (tracks.isEmpty) {
  256. return Center(
  257. child: Text(
  258. 'No tracks in this album.',
  259. style: theme.textTheme.bodySmall?.copyWith(
  260. color: colors.inkMuted,
  261. ),
  262. ),
  263. );
  264. }
  265. return ListView.separated(
  266. padding: EdgeInsets.zero,
  267. itemCount: tracks.length,
  268. separatorBuilder: (_, _) =>
  269. Divider(color: colors.border, height: 1),
  270. itemBuilder: (context, index) {
  271. final track = tracks[index];
  272. return _TrackRow(
  273. track: track,
  274. isCurrent: track.id == currentTrackId,
  275. isPlaying: playing,
  276. onTap: () => _playTrack(tracks, index),
  277. );
  278. },
  279. );
  280. },
  281. ),
  282. ),
  283. ],
  284. );
  285. }
  286. Widget wideBody(BoxConstraints constraints) {
  287. final coverWidth = (constraints.maxWidth * 0.30).clamp(280.0, 380.0);
  288. final controlsWidth = (constraints.maxWidth * 0.44).clamp(520.0, 600.0);
  289. return Row(
  290. crossAxisAlignment: CrossAxisAlignment.start,
  291. children: [
  292. SizedBox(width: coverWidth, child: cover()),
  293. const SizedBox(width: 22),
  294. Expanded(child: trackList()),
  295. const SizedBox(width: 22),
  296. SizedBox(
  297. width: controlsWidth,
  298. child: SingleChildScrollView(child: controls()),
  299. ),
  300. ],
  301. );
  302. }
  303. Widget narrowBody(BoxConstraints constraints) {
  304. final coverWidth = (constraints.maxWidth * 0.34).clamp(200.0, 260.0);
  305. return SingleChildScrollView(
  306. child: Column(
  307. crossAxisAlignment: CrossAxisAlignment.stretch,
  308. children: [
  309. Row(
  310. crossAxisAlignment: CrossAxisAlignment.start,
  311. children: [
  312. SizedBox(width: coverWidth, child: cover()),
  313. const SizedBox(width: 18),
  314. Expanded(child: controls()),
  315. ],
  316. ),
  317. const SizedBox(height: 16),
  318. SizedBox(height: 260, child: trackList()),
  319. ],
  320. ),
  321. );
  322. }
  323. return Scaffold(
  324. backgroundColor: colors.canvas,
  325. body: Container(
  326. decoration: homeBackground(colors, theme.brightness),
  327. child: SafeArea(
  328. child: Padding(
  329. padding: const EdgeInsets.fromLTRB(36, 22, 36, 16),
  330. child: Column(
  331. crossAxisAlignment: CrossAxisAlignment.stretch,
  332. children: [
  333. _AlbumHeader(
  334. album: widget.album,
  335. isFavorite: _isFavorite,
  336. favoriteBusy: _favoriteBusy,
  337. onToggleFavorite: _toggleFavorite,
  338. ),
  339. const SizedBox(height: 14),
  340. Expanded(
  341. child: LayoutBuilder(
  342. builder: (context, constraints) {
  343. return constraints.maxWidth >= 1180
  344. ? wideBody(constraints)
  345. : narrowBody(constraints);
  346. },
  347. ),
  348. ),
  349. ],
  350. ),
  351. ),
  352. ),
  353. ),
  354. );
  355. }
  356. Future<void> _playTrack(List<JellyfinTrack> tracks, int index) async {
  357. final playback = context.read<JellyfinPlaybackController>();
  358. if (playback.loading) {
  359. await playback.playTrackAt(index);
  360. return;
  361. }
  362. if (!playback.isCurrentAlbum(widget.album)) {
  363. final error = await playback.playAlbum(widget.album);
  364. if (error != null) return;
  365. }
  366. await playback.playTrackAt(index);
  367. }
  368. }
  369. class _AlbumHeader extends StatelessWidget {
  370. const _AlbumHeader({
  371. required this.album,
  372. required this.isFavorite,
  373. required this.favoriteBusy,
  374. required this.onToggleFavorite,
  375. });
  376. final JellyfinAlbum album;
  377. final bool isFavorite;
  378. final bool favoriteBusy;
  379. final VoidCallback onToggleFavorite;
  380. @override
  381. Widget build(BuildContext context) {
  382. final colors = context.colors;
  383. final theme = Theme.of(context);
  384. return Row(
  385. crossAxisAlignment: CrossAxisAlignment.start,
  386. children: [
  387. const JellyfinBackButton(),
  388. const SizedBox(width: 18),
  389. Expanded(
  390. child: Column(
  391. crossAxisAlignment: CrossAxisAlignment.start,
  392. children: [
  393. Text(
  394. 'Album',
  395. style: theme.textTheme.labelLarge?.copyWith(
  396. color: colors.inkMuted,
  397. ),
  398. ),
  399. const SizedBox(height: 2),
  400. Text(
  401. album.name,
  402. maxLines: 2,
  403. overflow: TextOverflow.ellipsis,
  404. style: theme.textTheme.headlineLarge,
  405. ),
  406. const SizedBox(height: 2),
  407. Text(
  408. album.artist.isEmpty ? 'Unknown artist' : album.artist,
  409. maxLines: 1,
  410. overflow: TextOverflow.ellipsis,
  411. style: theme.textTheme.bodySmall?.copyWith(
  412. color: colors.inkMuted,
  413. ),
  414. ),
  415. ],
  416. ),
  417. ),
  418. const SizedBox(width: 12),
  419. _FavoriteButton(
  420. isFavorite: isFavorite,
  421. busy: favoriteBusy,
  422. onTap: onToggleFavorite,
  423. ),
  424. ],
  425. );
  426. }
  427. }
  428. class _FavoriteButton extends StatelessWidget {
  429. const _FavoriteButton({
  430. required this.isFavorite,
  431. required this.busy,
  432. required this.onTap,
  433. });
  434. final bool isFavorite;
  435. final bool busy;
  436. final VoidCallback onTap;
  437. @override
  438. Widget build(BuildContext context) {
  439. final colors = context.colors;
  440. return Pressable(
  441. onTap: busy ? null : onTap,
  442. semanticLabel: isFavorite ? 'Remove from favorites' : 'Add to favorites',
  443. child: Container(
  444. width: 46,
  445. height: 46,
  446. decoration: BoxDecoration(
  447. color: isFavorite ? colors.accentSoft : colors.surface,
  448. shape: BoxShape.circle,
  449. border: Border.all(
  450. color: isFavorite
  451. ? colors.accent.withValues(alpha: 0.3)
  452. : colors.border,
  453. ),
  454. ),
  455. alignment: Alignment.center,
  456. child: busy
  457. ? SizedBox(
  458. width: 16,
  459. height: 16,
  460. child: CircularProgressIndicator(
  461. strokeWidth: 2,
  462. color: colors.accent,
  463. ),
  464. )
  465. : AppIcon(
  466. isFavorite
  467. ? 'assets/icons/heart-filled.svg'
  468. : 'assets/icons/heart.svg',
  469. size: 21,
  470. color: isFavorite ? colors.accent : colors.inkMuted,
  471. ),
  472. ),
  473. );
  474. }
  475. }
  476. class _TrackRow extends StatelessWidget {
  477. const _TrackRow({
  478. required this.track,
  479. required this.isCurrent,
  480. required this.isPlaying,
  481. required this.onTap,
  482. });
  483. final JellyfinTrack track;
  484. final bool isCurrent;
  485. final bool isPlaying;
  486. final VoidCallback onTap;
  487. @override
  488. Widget build(BuildContext context) {
  489. final colors = context.colors;
  490. final theme = Theme.of(context);
  491. final color = isCurrent ? colors.accent : colors.ink;
  492. return Material(
  493. color: isCurrent ? colors.accentSoft : Colors.transparent,
  494. borderRadius: BorderRadius.circular(12),
  495. child: InkWell(
  496. borderRadius: BorderRadius.circular(12),
  497. onTap: onTap,
  498. child: Padding(
  499. padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 7),
  500. child: Row(
  501. children: [
  502. SizedBox(
  503. width: 28,
  504. child: Text(
  505. track.displayIndex,
  506. style: theme.textTheme.bodySmall?.copyWith(
  507. color: isCurrent ? colors.accent : colors.inkFaint,
  508. ),
  509. ),
  510. ),
  511. const SizedBox(width: 6),
  512. Expanded(
  513. child: Text(
  514. track.name,
  515. maxLines: 1,
  516. overflow: TextOverflow.ellipsis,
  517. style: theme.textTheme.bodyMedium?.copyWith(
  518. color: color,
  519. fontWeight: isCurrent ? FontWeight.w600 : FontWeight.w400,
  520. ),
  521. ),
  522. ),
  523. const SizedBox(width: 8),
  524. Text(
  525. _formatDuration(track.duration),
  526. style: theme.textTheme.bodySmall?.copyWith(
  527. color: isCurrent ? colors.accent : colors.inkMuted,
  528. ),
  529. ),
  530. if (isCurrent) ...[
  531. const SizedBox(width: 8),
  532. AppIcon(
  533. isPlaying
  534. ? 'assets/icons/volume-2.svg'
  535. : 'assets/icons/play.svg',
  536. size: 14,
  537. color: colors.accent,
  538. ),
  539. ],
  540. ],
  541. ),
  542. ),
  543. ),
  544. );
  545. }
  546. }
  547. String _formatDuration(Duration duration) {
  548. final totalSeconds = duration.inSeconds;
  549. final minutes = totalSeconds ~/ 60;
  550. final seconds = totalSeconds % 60;
  551. final hours = minutes ~/ 60;
  552. final remainingMinutes = minutes % 60;
  553. String two(int value) => value.toString().padLeft(2, '0');
  554. if (hours > 0) {
  555. return '$hours:${two(remainingMinutes)}:${two(seconds)}';
  556. }
  557. return '$minutes:${two(seconds)}';
  558. }