jellyfin_album_screen.dart 27 KB

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