jellyfin_album_screen.dart 15 KB

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