jellyfin_album_screen.dart 27 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854
  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 '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. late bool _isFavorite;
  23. bool _favoriteBusy = false;
  24. double? _seekDragMs;
  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. final durationMs = media.durationMs > 0
  138. ? media.durationMs
  139. : (playback.currentItem?.durationMs ?? 0);
  140. final total = Duration(milliseconds: durationMs);
  141. final rawPosition = (_seekDragMs ?? media.positionMs.toDouble()).clamp(
  142. 0.0,
  143. durationMs > 0 ? durationMs.toDouble() : 1.0,
  144. );
  145. final position = Duration(milliseconds: rawPosition.round());
  146. final canSeek = active && durationMs > 0;
  147. final canToggle = !playback.loading;
  148. return Column(
  149. crossAxisAlignment: CrossAxisAlignment.stretch,
  150. mainAxisSize: MainAxisSize.min,
  151. children: [
  152. Row(
  153. crossAxisAlignment: CrossAxisAlignment.start,
  154. children: [
  155. Expanded(
  156. child: Column(
  157. crossAxisAlignment: CrossAxisAlignment.start,
  158. children: [
  159. Text(
  160. active
  161. ? (playing ? 'Now playing' : 'Paused')
  162. : playback.loading
  163. ? 'Starting playback…'
  164. : 'Ready',
  165. style: theme.textTheme.labelLarge?.copyWith(
  166. color: colors.inkMuted,
  167. ),
  168. ),
  169. const SizedBox(height: 4),
  170. Text(
  171. currentTitle,
  172. maxLines: 2,
  173. overflow: TextOverflow.ellipsis,
  174. style: theme.textTheme.headlineSmall,
  175. ),
  176. const SizedBox(height: 2),
  177. Text(
  178. currentArtist.isEmpty ? 'Unknown artist' : currentArtist,
  179. maxLines: 1,
  180. overflow: TextOverflow.ellipsis,
  181. style: theme.textTheme.bodyMedium?.copyWith(
  182. color: colors.inkMuted,
  183. ),
  184. ),
  185. ],
  186. ),
  187. ),
  188. const SizedBox(width: 12),
  189. _AlbumVolumeCard(system: system),
  190. ],
  191. ),
  192. if (error != null) ...[
  193. const SizedBox(height: 8),
  194. Row(
  195. children: [
  196. Expanded(
  197. child: Text(
  198. error,
  199. style: theme.textTheme.bodySmall?.copyWith(
  200. color: colors.danger,
  201. ),
  202. ),
  203. ),
  204. TextButton(
  205. onPressed: () {
  206. unawaited(playback.playAlbum(widget.album));
  207. },
  208. style: TextButton.styleFrom(foregroundColor: colors.accent),
  209. child: const Text('Retry'),
  210. ),
  211. ],
  212. ),
  213. ],
  214. const SizedBox(height: 14),
  215. Container(
  216. padding: const EdgeInsets.fromLTRB(18, 12, 18, 14),
  217. decoration: BoxDecoration(
  218. color: colors.surface,
  219. borderRadius: BorderRadius.circular(20),
  220. border: Border.all(color: colors.border),
  221. ),
  222. child: Column(
  223. mainAxisSize: MainAxisSize.min,
  224. children: [
  225. Row(
  226. children: [
  227. Expanded(
  228. child: SliderTheme(
  229. data: SliderTheme.of(context).copyWith(
  230. trackHeight: 4,
  231. activeTrackColor: colors.accent,
  232. inactiveTrackColor: colors.border,
  233. thumbColor: colors.accent,
  234. overlayColor: colors.accent.withValues(alpha: 0.12),
  235. thumbShape: const RoundSliderThumbShape(
  236. enabledThumbRadius: 7,
  237. ),
  238. overlayShape: const RoundSliderOverlayShape(
  239. overlayRadius: 16,
  240. ),
  241. ),
  242. child: Slider(
  243. min: 0,
  244. max: durationMs > 0 ? durationMs.toDouble() : 1,
  245. value: durationMs > 0 ? rawPosition : 0,
  246. onChanged: canSeek
  247. ? (value) {
  248. setState(() => _seekDragMs = value);
  249. }
  250. : null,
  251. onChangeEnd: canSeek
  252. ? (value) {
  253. setState(() => _seekDragMs = null);
  254. unawaited(
  255. playback.seek(
  256. Duration(milliseconds: value.round()),
  257. ),
  258. );
  259. }
  260. : null,
  261. ),
  262. ),
  263. ),
  264. const SizedBox(width: 10),
  265. Text(
  266. '${_formatDuration(position)} / ${_formatDuration(total)}',
  267. style: theme.textTheme.bodySmall?.copyWith(
  268. color: colors.inkMuted,
  269. fontFeatures: const [FontFeature.tabularFigures()],
  270. ),
  271. ),
  272. ],
  273. ),
  274. const SizedBox(height: 10),
  275. Row(
  276. mainAxisAlignment: MainAxisAlignment.center,
  277. children: [
  278. _AlbumTransportButton(
  279. asset: 'assets/icons/skip-back.svg',
  280. semanticLabel: 'Previous track',
  281. enabled: active && playback.canGoPrevious,
  282. onTap: () => unawaited(playback.previous()),
  283. ),
  284. const SizedBox(width: 18),
  285. _AlbumTransportButton(
  286. asset: playing
  287. ? 'assets/icons/pause.svg'
  288. : 'assets/icons/play.svg',
  289. semanticLabel: playing ? 'Pause music' : 'Play music',
  290. primary: true,
  291. enabled: canToggle,
  292. onTap: () {
  293. if (playback.loading) return;
  294. if (active) {
  295. unawaited(playback.toggle());
  296. } else {
  297. unawaited(playback.playAlbum(widget.album));
  298. }
  299. },
  300. ),
  301. const SizedBox(width: 18),
  302. _AlbumTransportButton(
  303. asset: 'assets/icons/skip-forward.svg',
  304. semanticLabel: 'Next track',
  305. enabled: active && playback.canGoNext,
  306. onTap: () => unawaited(playback.next()),
  307. ),
  308. ],
  309. ),
  310. ],
  311. ),
  312. ),
  313. ],
  314. );
  315. }
  316. Widget trackList() {
  317. return Column(
  318. crossAxisAlignment: CrossAxisAlignment.stretch,
  319. children: [
  320. Row(
  321. children: [
  322. Text(
  323. 'Tracks',
  324. style: theme.textTheme.titleMedium?.copyWith(
  325. color: colors.ink,
  326. fontWeight: FontWeight.w700,
  327. ),
  328. ),
  329. ],
  330. ),
  331. const SizedBox(height: 6),
  332. Expanded(
  333. child: FutureBuilder<List<JellyfinTrack>>(
  334. future: _tracksFuture,
  335. builder: (context, snapshot) {
  336. if (snapshot.connectionState == ConnectionState.waiting) {
  337. return const Center(child: CircularProgressIndicator());
  338. }
  339. if (snapshot.hasError) {
  340. return Center(
  341. child: Column(
  342. mainAxisSize: MainAxisSize.min,
  343. children: [
  344. Text(
  345. 'Could not load tracks.',
  346. style: theme.textTheme.bodySmall?.copyWith(
  347. color: colors.inkMuted,
  348. ),
  349. ),
  350. TextButton(
  351. onPressed: _reloadTracks,
  352. style: TextButton.styleFrom(
  353. foregroundColor: colors.accent,
  354. ),
  355. child: const Text('Retry'),
  356. ),
  357. ],
  358. ),
  359. );
  360. }
  361. final tracks = snapshot.data ?? const <JellyfinTrack>[];
  362. if (tracks.isEmpty) {
  363. return Center(
  364. child: Text(
  365. 'No tracks in this album.',
  366. style: theme.textTheme.bodySmall?.copyWith(
  367. color: colors.inkMuted,
  368. ),
  369. ),
  370. );
  371. }
  372. return ListView.separated(
  373. padding: EdgeInsets.zero,
  374. itemCount: tracks.length,
  375. separatorBuilder: (_, _) =>
  376. Divider(color: colors.border, height: 1),
  377. itemBuilder: (context, index) {
  378. final track = tracks[index];
  379. return _TrackRow(
  380. track: track,
  381. isCurrent: track.id == currentTrackId,
  382. isPlaying: playing,
  383. onTap: () => _playTrack(tracks, index),
  384. );
  385. },
  386. );
  387. },
  388. ),
  389. ),
  390. ],
  391. );
  392. }
  393. Widget wideBody(BoxConstraints constraints) {
  394. final coverWidth = (constraints.maxWidth * 0.30).clamp(280.0, 380.0);
  395. final controlsWidth = (constraints.maxWidth * 0.44).clamp(520.0, 600.0);
  396. return Row(
  397. crossAxisAlignment: CrossAxisAlignment.start,
  398. children: [
  399. SizedBox(width: coverWidth, child: cover()),
  400. const SizedBox(width: 22),
  401. Expanded(child: trackList()),
  402. const SizedBox(width: 22),
  403. SizedBox(
  404. width: controlsWidth,
  405. child: SingleChildScrollView(child: controls()),
  406. ),
  407. ],
  408. );
  409. }
  410. Widget narrowBody(BoxConstraints constraints) {
  411. final coverWidth = (constraints.maxWidth * 0.34).clamp(200.0, 260.0);
  412. return SingleChildScrollView(
  413. child: Column(
  414. crossAxisAlignment: CrossAxisAlignment.stretch,
  415. children: [
  416. Row(
  417. crossAxisAlignment: CrossAxisAlignment.start,
  418. children: [
  419. SizedBox(width: coverWidth, child: cover()),
  420. const SizedBox(width: 18),
  421. Expanded(child: controls()),
  422. ],
  423. ),
  424. const SizedBox(height: 16),
  425. SizedBox(height: 260, child: trackList()),
  426. ],
  427. ),
  428. );
  429. }
  430. return Scaffold(
  431. backgroundColor: colors.canvas,
  432. body: Container(
  433. decoration: homeBackground(colors, theme.brightness),
  434. child: SafeArea(
  435. child: Padding(
  436. padding: const EdgeInsets.fromLTRB(36, 22, 36, 16),
  437. child: Column(
  438. crossAxisAlignment: CrossAxisAlignment.stretch,
  439. children: [
  440. _AlbumHeader(
  441. album: widget.album,
  442. isFavorite: _isFavorite,
  443. favoriteBusy: _favoriteBusy,
  444. onToggleFavorite: _toggleFavorite,
  445. ),
  446. const SizedBox(height: 14),
  447. Expanded(
  448. child: LayoutBuilder(
  449. builder: (context, constraints) {
  450. return constraints.maxWidth >= 1180
  451. ? wideBody(constraints)
  452. : narrowBody(constraints);
  453. },
  454. ),
  455. ),
  456. ],
  457. ),
  458. ),
  459. ),
  460. ),
  461. );
  462. }
  463. Future<void> _playTrack(List<JellyfinTrack> tracks, int index) async {
  464. final playback = context.read<JellyfinPlaybackController>();
  465. if (playback.loading) {
  466. await playback.playTrackAt(index);
  467. return;
  468. }
  469. if (!playback.isCurrentAlbum(widget.album)) {
  470. final error = await playback.playAlbum(widget.album);
  471. if (error != null) return;
  472. }
  473. await playback.playTrackAt(index);
  474. }
  475. }
  476. class _AlbumHeader extends StatelessWidget {
  477. const _AlbumHeader({
  478. required this.album,
  479. required this.isFavorite,
  480. required this.favoriteBusy,
  481. required this.onToggleFavorite,
  482. });
  483. final JellyfinAlbum album;
  484. final bool isFavorite;
  485. final bool favoriteBusy;
  486. final VoidCallback onToggleFavorite;
  487. @override
  488. Widget build(BuildContext context) {
  489. final colors = context.colors;
  490. final theme = Theme.of(context);
  491. return Row(
  492. crossAxisAlignment: CrossAxisAlignment.start,
  493. children: [
  494. const JellyfinBackButton(),
  495. const SizedBox(width: 18),
  496. Expanded(
  497. child: Column(
  498. crossAxisAlignment: CrossAxisAlignment.start,
  499. children: [
  500. Text(
  501. 'Album',
  502. style: theme.textTheme.labelLarge?.copyWith(
  503. color: colors.inkMuted,
  504. ),
  505. ),
  506. const SizedBox(height: 2),
  507. Text(
  508. album.name,
  509. maxLines: 2,
  510. overflow: TextOverflow.ellipsis,
  511. style: theme.textTheme.headlineLarge,
  512. ),
  513. const SizedBox(height: 2),
  514. Text(
  515. album.artist.isEmpty ? 'Unknown artist' : album.artist,
  516. maxLines: 1,
  517. overflow: TextOverflow.ellipsis,
  518. style: theme.textTheme.bodySmall?.copyWith(
  519. color: colors.inkMuted,
  520. ),
  521. ),
  522. ],
  523. ),
  524. ),
  525. const SizedBox(width: 12),
  526. _FavoriteButton(
  527. isFavorite: isFavorite,
  528. busy: favoriteBusy,
  529. onTap: onToggleFavorite,
  530. ),
  531. ],
  532. );
  533. }
  534. }
  535. class _FavoriteButton extends StatelessWidget {
  536. const _FavoriteButton({
  537. required this.isFavorite,
  538. required this.busy,
  539. required this.onTap,
  540. });
  541. final bool isFavorite;
  542. final bool busy;
  543. final VoidCallback onTap;
  544. @override
  545. Widget build(BuildContext context) {
  546. final colors = context.colors;
  547. return Pressable(
  548. onTap: busy ? null : onTap,
  549. semanticLabel: isFavorite ? 'Remove from favorites' : 'Add to favorites',
  550. child: Container(
  551. width: 46,
  552. height: 46,
  553. decoration: BoxDecoration(
  554. color: isFavorite ? colors.accentSoft : colors.surface,
  555. shape: BoxShape.circle,
  556. border: Border.all(
  557. color: isFavorite
  558. ? colors.accent.withValues(alpha: 0.3)
  559. : colors.border,
  560. ),
  561. ),
  562. alignment: Alignment.center,
  563. child: busy
  564. ? SizedBox(
  565. width: 16,
  566. height: 16,
  567. child: CircularProgressIndicator(
  568. strokeWidth: 2,
  569. color: colors.accent,
  570. ),
  571. )
  572. : AppIcon(
  573. isFavorite
  574. ? 'assets/icons/heart-filled.svg'
  575. : 'assets/icons/heart.svg',
  576. size: 21,
  577. color: isFavorite ? colors.accent : colors.inkMuted,
  578. ),
  579. ),
  580. );
  581. }
  582. }
  583. class _AlbumVolumeCard extends StatelessWidget {
  584. const _AlbumVolumeCard({required this.system});
  585. final SystemController system;
  586. @override
  587. Widget build(BuildContext context) {
  588. final colors = context.colors;
  589. final theme = Theme.of(context);
  590. final enabled = !system.isVolumeFixed;
  591. return Container(
  592. padding: const EdgeInsets.all(8),
  593. decoration: BoxDecoration(
  594. color: colors.surface,
  595. borderRadius: BorderRadius.circular(18),
  596. border: Border.all(color: colors.border),
  597. ),
  598. child: Row(
  599. mainAxisSize: MainAxisSize.min,
  600. children: [
  601. _AlbumVolumeStep(
  602. icon: Icons.remove_rounded,
  603. semanticLabel: 'Volume down',
  604. onTap: enabled ? () => unawaited(system.adjustVolume(-1)) : null,
  605. ),
  606. const SizedBox(width: 6),
  607. Pressable(
  608. onTap: enabled ? () => unawaited(system.toggleMute()) : null,
  609. semanticLabel: system.isMuted ? 'Unmute' : 'Mute',
  610. pressedScale: 0.94,
  611. child: Container(
  612. height: 36,
  613. padding: const EdgeInsets.symmetric(horizontal: 10),
  614. decoration: BoxDecoration(
  615. color: system.isMuted
  616. ? colors.accentSoft
  617. : colors.surfaceStrong,
  618. borderRadius: BorderRadius.circular(999),
  619. border: Border.all(
  620. color: system.isMuted
  621. ? colors.accent.withValues(alpha: 0.25)
  622. : colors.border,
  623. ),
  624. ),
  625. child: Row(
  626. mainAxisSize: MainAxisSize.min,
  627. children: [
  628. AppIcon(
  629. system.isMuted
  630. ? 'assets/icons/volume-x.svg'
  631. : 'assets/icons/volume-2.svg',
  632. size: 16,
  633. color: system.isMuted ? colors.accent : colors.inkMuted,
  634. ),
  635. const SizedBox(width: 6),
  636. Text(
  637. system.isMuted ? 'Unmute' : 'Mute',
  638. style: theme.textTheme.labelMedium?.copyWith(
  639. color: system.isMuted ? colors.accent : colors.inkMuted,
  640. ),
  641. ),
  642. ],
  643. ),
  644. ),
  645. ),
  646. const SizedBox(width: 6),
  647. _AlbumVolumeStep(
  648. icon: Icons.add_rounded,
  649. semanticLabel: 'Volume up',
  650. onTap: enabled ? () => unawaited(system.adjustVolume(1)) : null,
  651. ),
  652. ],
  653. ),
  654. );
  655. }
  656. }
  657. class _AlbumVolumeStep extends StatelessWidget {
  658. const _AlbumVolumeStep({
  659. required this.icon,
  660. required this.semanticLabel,
  661. required this.onTap,
  662. });
  663. final IconData icon;
  664. final String semanticLabel;
  665. final VoidCallback? onTap;
  666. @override
  667. Widget build(BuildContext context) {
  668. final colors = context.colors;
  669. return Pressable(
  670. onTap: onTap,
  671. semanticLabel: semanticLabel,
  672. pressedScale: 0.92,
  673. child: Container(
  674. width: 36,
  675. height: 36,
  676. decoration: BoxDecoration(
  677. color: colors.surfaceStrong,
  678. shape: BoxShape.circle,
  679. border: Border.all(color: colors.border),
  680. ),
  681. alignment: Alignment.center,
  682. child: Icon(
  683. icon,
  684. size: 18,
  685. color: onTap == null ? colors.inkFaint : colors.inkMuted,
  686. ),
  687. ),
  688. );
  689. }
  690. }
  691. class _AlbumTransportButton extends StatelessWidget {
  692. const _AlbumTransportButton({
  693. required this.asset,
  694. required this.semanticLabel,
  695. required this.onTap,
  696. this.enabled = true,
  697. this.primary = false,
  698. });
  699. final String asset;
  700. final String semanticLabel;
  701. final VoidCallback onTap;
  702. final bool enabled;
  703. final bool primary;
  704. @override
  705. Widget build(BuildContext context) {
  706. final colors = context.colors;
  707. final size = primary ? 68.0 : 54.0;
  708. return Opacity(
  709. opacity: enabled ? 1 : 0.4,
  710. child: Pressable(
  711. onTap: enabled ? onTap : null,
  712. semanticLabel: semanticLabel,
  713. pressedScale: 0.92,
  714. child: Container(
  715. width: size,
  716. height: size,
  717. decoration: BoxDecoration(
  718. color: primary ? colors.accent : colors.surfaceStrong,
  719. shape: BoxShape.circle,
  720. border: primary ? null : Border.all(color: colors.border),
  721. ),
  722. alignment: Alignment.center,
  723. child: AppIcon(
  724. asset,
  725. size: primary ? 28 : 22,
  726. color: primary ? colors.accentInk : colors.ink,
  727. ),
  728. ),
  729. ),
  730. );
  731. }
  732. }
  733. class _TrackRow extends StatelessWidget {
  734. const _TrackRow({
  735. required this.track,
  736. required this.isCurrent,
  737. required this.isPlaying,
  738. required this.onTap,
  739. });
  740. final JellyfinTrack track;
  741. final bool isCurrent;
  742. final bool isPlaying;
  743. final VoidCallback onTap;
  744. @override
  745. Widget build(BuildContext context) {
  746. final colors = context.colors;
  747. final theme = Theme.of(context);
  748. final color = isCurrent ? colors.accent : colors.ink;
  749. return Material(
  750. color: isCurrent ? colors.accentSoft : Colors.transparent,
  751. borderRadius: BorderRadius.circular(12),
  752. child: InkWell(
  753. borderRadius: BorderRadius.circular(12),
  754. onTap: onTap,
  755. child: Padding(
  756. padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 7),
  757. child: Row(
  758. children: [
  759. SizedBox(
  760. width: 28,
  761. child: Text(
  762. track.displayIndex,
  763. style: theme.textTheme.bodySmall?.copyWith(
  764. color: isCurrent ? colors.accent : colors.inkFaint,
  765. ),
  766. ),
  767. ),
  768. const SizedBox(width: 6),
  769. Expanded(
  770. child: Text(
  771. track.name,
  772. maxLines: 1,
  773. overflow: TextOverflow.ellipsis,
  774. style: theme.textTheme.bodyMedium?.copyWith(
  775. color: color,
  776. fontWeight: isCurrent ? FontWeight.w600 : FontWeight.w400,
  777. ),
  778. ),
  779. ),
  780. const SizedBox(width: 8),
  781. Text(
  782. _formatDuration(track.duration),
  783. style: theme.textTheme.bodySmall?.copyWith(
  784. color: isCurrent ? colors.accent : colors.inkMuted,
  785. ),
  786. ),
  787. if (isCurrent) ...[
  788. const SizedBox(width: 8),
  789. AppIcon(
  790. isPlaying
  791. ? 'assets/icons/volume-2.svg'
  792. : 'assets/icons/play.svg',
  793. size: 14,
  794. color: colors.accent,
  795. ),
  796. ],
  797. ],
  798. ),
  799. ),
  800. ),
  801. );
  802. }
  803. }
  804. String _formatDuration(Duration duration) {
  805. final totalSeconds = duration.inSeconds;
  806. final minutes = totalSeconds ~/ 60;
  807. final seconds = totalSeconds % 60;
  808. final hours = minutes ~/ 60;
  809. final remainingMinutes = minutes % 60;
  810. String two(int value) => value.toString().padLeft(2, '0');
  811. if (hours > 0) {
  812. return '$hours:${two(remainingMinutes)}:${two(seconds)}';
  813. }
  814. return '$minutes:${two(seconds)}';
  815. }