jellyfin_album_screen.dart 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828
  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. import 'widgets/jellyfin_status_strip.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. double? _seekDragMs;
  26. @override
  27. void initState() {
  28. super.initState();
  29. _library = context.read<JellyfinLibraryService>();
  30. _tracksFuture = _library.albumTracks(widget.album.id);
  31. _isFavorite = widget.album.isFavorite;
  32. unawaited(_loadFavorite());
  33. }
  34. void _reloadTracks() {
  35. setState(() {
  36. _tracksFuture = _library.albumTracks(widget.album.id);
  37. });
  38. }
  39. Future<void> _loadFavorite() async {
  40. try {
  41. final favorite = await _library.isFavorite(widget.album.id);
  42. if (!mounted) return;
  43. setState(() => _isFavorite = favorite);
  44. } catch (_) {
  45. // Keep the album's incoming state if the lookup fails.
  46. }
  47. }
  48. Future<void> _toggleFavorite() async {
  49. if (_favoriteBusy) return;
  50. final next = !_isFavorite;
  51. setState(() => _favoriteBusy = true);
  52. try {
  53. await _library.setFavorite(widget.album.id, next);
  54. if (!mounted) return;
  55. setState(() {
  56. _isFavorite = next;
  57. _favoriteBusy = false;
  58. });
  59. } catch (_) {
  60. if (!mounted) return;
  61. setState(() => _favoriteBusy = false);
  62. ScaffoldMessenger.of(context).showSnackBar(
  63. const SnackBar(content: Text('Could not update favorites.')),
  64. );
  65. }
  66. }
  67. @override
  68. Widget build(BuildContext context) {
  69. final colors = context.colors;
  70. final theme = Theme.of(context);
  71. final playback = context.watch<JellyfinPlaybackController>();
  72. final system = context.watch<SystemController>();
  73. final active = playback.isCurrentAlbum(widget.album);
  74. final playing = active && playback.playing;
  75. final currentItem = active ? playback.currentItem : null;
  76. final currentTitle = active
  77. ? (currentItem?.title ?? playback.snapshot.title ?? widget.album.name)
  78. : playback.loading
  79. ? 'Starting playback…'
  80. : widget.album.name;
  81. final currentArtist = active
  82. ? (currentItem?.artist ??
  83. playback.snapshot.artist ??
  84. widget.album.artist)
  85. : widget.album.artist;
  86. final currentTrackId = active
  87. ? (currentItem?.trackId ?? playback.snapshot.itemId)
  88. : null;
  89. final media = active
  90. ? playback.mediaPlayback
  91. : MediaPlayback(
  92. hasAccess: true,
  93. title: widget.album.name,
  94. artist: widget.album.artist,
  95. );
  96. final imageUrl = _library
  97. .imageUrl(widget.album.id, widget.album.imageTag, maxHeight: 600)
  98. ?.toString();
  99. final headers = _library.imageHeaders;
  100. Widget cover() {
  101. final placeholder = ColoredBox(
  102. color: colors.surfaceStrong,
  103. child: Center(
  104. child: AppIcon(
  105. 'assets/icons/headphones.svg',
  106. size: 54,
  107. color: colors.inkFaint,
  108. ),
  109. ),
  110. );
  111. return Align(
  112. alignment: Alignment.topCenter,
  113. child: ConstrainedBox(
  114. constraints: const BoxConstraints(maxWidth: 420),
  115. child: AspectRatio(
  116. aspectRatio: 1,
  117. child: Container(
  118. key: const ValueKey('jellyfin-album-cover'),
  119. clipBehavior: Clip.antiAlias,
  120. decoration: BoxDecoration(
  121. borderRadius: BorderRadius.circular(24),
  122. border: Border.all(color: colors.border),
  123. ),
  124. child: imageUrl == null
  125. ? placeholder
  126. : Image.network(
  127. imageUrl,
  128. headers: headers,
  129. fit: BoxFit.cover,
  130. errorBuilder: (_, _, _) => placeholder,
  131. ),
  132. ),
  133. ),
  134. ),
  135. );
  136. }
  137. Widget controls({double? minHeight}) {
  138. final error = playback.error;
  139. final durationMs = media.durationMs > 0
  140. ? media.durationMs
  141. : (playback.currentItem?.durationMs ?? 0);
  142. final total = Duration(milliseconds: durationMs);
  143. final rawPosition = (_seekDragMs ?? media.positionMs.toDouble()).clamp(
  144. 0.0,
  145. durationMs > 0 ? durationMs.toDouble() : 1.0,
  146. );
  147. final position = Duration(milliseconds: rawPosition.round());
  148. final canSeek = active && durationMs > 0;
  149. final canToggle = !playback.loading;
  150. final content = Column(
  151. crossAxisAlignment: CrossAxisAlignment.stretch,
  152. mainAxisSize: MainAxisSize.min,
  153. children: [
  154. Column(
  155. crossAxisAlignment: CrossAxisAlignment.start,
  156. children: [
  157. Text(
  158. active
  159. ? (playing ? 'Now playing' : 'Paused')
  160. : playback.loading
  161. ? 'Starting playback…'
  162. : 'Ready',
  163. style: theme.textTheme.labelLarge?.copyWith(
  164. color: colors.inkMuted,
  165. ),
  166. ),
  167. const SizedBox(height: 4),
  168. Text(
  169. currentTitle,
  170. maxLines: 2,
  171. overflow: TextOverflow.ellipsis,
  172. style: theme.textTheme.headlineSmall,
  173. ),
  174. const SizedBox(height: 2),
  175. Text(
  176. currentArtist.isEmpty ? 'Unknown artist' : currentArtist,
  177. maxLines: 1,
  178. overflow: TextOverflow.ellipsis,
  179. style: theme.textTheme.bodyMedium?.copyWith(
  180. color: colors.inkMuted,
  181. ),
  182. ),
  183. ],
  184. ),
  185. if (error != null) ...[
  186. const SizedBox(height: 8),
  187. Row(
  188. children: [
  189. Expanded(
  190. child: Text(
  191. error,
  192. style: theme.textTheme.bodySmall?.copyWith(
  193. color: colors.danger,
  194. ),
  195. ),
  196. ),
  197. TextButton(
  198. onPressed: () {
  199. unawaited(playback.playAlbum(widget.album));
  200. },
  201. style: TextButton.styleFrom(foregroundColor: colors.accent),
  202. child: const Text('Retry'),
  203. ),
  204. ],
  205. ),
  206. ],
  207. ],
  208. );
  209. final card = Container(
  210. key: const ValueKey('jellyfin-album-progress-card'),
  211. padding: const EdgeInsets.fromLTRB(18, 12, 18, 14),
  212. decoration: BoxDecoration(
  213. color: colors.surface,
  214. borderRadius: BorderRadius.circular(20),
  215. border: Border.all(color: colors.border),
  216. ),
  217. child: Column(
  218. mainAxisSize: MainAxisSize.min,
  219. children: [
  220. Row(
  221. children: [
  222. Expanded(
  223. child: SliderTheme(
  224. data: SliderTheme.of(context).copyWith(
  225. trackHeight: 4,
  226. activeTrackColor: colors.accent,
  227. inactiveTrackColor: colors.border,
  228. thumbColor: colors.accent,
  229. overlayColor: colors.accent.withValues(alpha: 0.12),
  230. thumbShape: const RoundSliderThumbShape(
  231. enabledThumbRadius: 8,
  232. ),
  233. overlayShape: const RoundSliderOverlayShape(
  234. overlayRadius: 18,
  235. ),
  236. ),
  237. child: Slider(
  238. min: 0,
  239. max: durationMs > 0 ? durationMs.toDouble() : 1,
  240. value: durationMs > 0 ? rawPosition : 0,
  241. onChanged: canSeek
  242. ? (value) {
  243. setState(() => _seekDragMs = value);
  244. }
  245. : null,
  246. onChangeEnd: canSeek
  247. ? (value) {
  248. setState(() => _seekDragMs = null);
  249. unawaited(
  250. playback.seek(
  251. Duration(milliseconds: value.round()),
  252. ),
  253. );
  254. }
  255. : null,
  256. ),
  257. ),
  258. ),
  259. const SizedBox(width: 10),
  260. Text(
  261. '${_formatDuration(position)} / ${_formatDuration(total)}',
  262. style: theme.textTheme.bodySmall?.copyWith(
  263. color: colors.inkMuted,
  264. fontFeatures: const [FontFeature.tabularFigures()],
  265. ),
  266. ),
  267. ],
  268. ),
  269. const SizedBox(height: 10),
  270. SizedBox(
  271. height: 80,
  272. child: Stack(
  273. alignment: Alignment.center,
  274. children: [
  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: 20),
  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: 20),
  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. Align(
  311. alignment: Alignment.centerLeft,
  312. child: _FavoriteButton(
  313. isFavorite: _isFavorite,
  314. busy: _favoriteBusy,
  315. onTap: _toggleFavorite,
  316. ),
  317. ),
  318. Align(
  319. alignment: Alignment.centerRight,
  320. child: _AlbumVolumeControls(system: system),
  321. ),
  322. ],
  323. ),
  324. ),
  325. ],
  326. ),
  327. );
  328. final column = Column(
  329. crossAxisAlignment: CrossAxisAlignment.stretch,
  330. mainAxisSize: MainAxisSize.min,
  331. mainAxisAlignment: MainAxisAlignment.spaceBetween,
  332. children: [
  333. content,
  334. Padding(padding: const EdgeInsets.only(top: 14), child: card),
  335. ],
  336. );
  337. if (minHeight == null) return column;
  338. return ConstrainedBox(
  339. constraints: BoxConstraints(minHeight: minHeight),
  340. child: column,
  341. );
  342. }
  343. Widget trackList() {
  344. return Column(
  345. crossAxisAlignment: CrossAxisAlignment.stretch,
  346. children: [
  347. Row(
  348. children: [
  349. Text(
  350. 'Tracks',
  351. style: theme.textTheme.titleMedium?.copyWith(
  352. color: colors.ink,
  353. fontWeight: FontWeight.w700,
  354. ),
  355. ),
  356. ],
  357. ),
  358. const SizedBox(height: 6),
  359. Expanded(
  360. child: FutureBuilder<List<JellyfinTrack>>(
  361. future: _tracksFuture,
  362. builder: (context, snapshot) {
  363. if (snapshot.connectionState == ConnectionState.waiting) {
  364. return const Center(child: CircularProgressIndicator());
  365. }
  366. if (snapshot.hasError) {
  367. return Center(
  368. child: Column(
  369. mainAxisSize: MainAxisSize.min,
  370. children: [
  371. Text(
  372. 'Could not load tracks.',
  373. style: theme.textTheme.bodySmall?.copyWith(
  374. color: colors.inkMuted,
  375. ),
  376. ),
  377. TextButton(
  378. onPressed: _reloadTracks,
  379. style: TextButton.styleFrom(
  380. foregroundColor: colors.accent,
  381. ),
  382. child: const Text('Retry'),
  383. ),
  384. ],
  385. ),
  386. );
  387. }
  388. final tracks = snapshot.data ?? const <JellyfinTrack>[];
  389. if (tracks.isEmpty) {
  390. return Center(
  391. child: Text(
  392. 'No tracks in this album.',
  393. style: theme.textTheme.bodySmall?.copyWith(
  394. color: colors.inkMuted,
  395. ),
  396. ),
  397. );
  398. }
  399. return ListView.separated(
  400. padding: EdgeInsets.zero,
  401. itemCount: tracks.length,
  402. separatorBuilder: (_, _) =>
  403. Divider(color: colors.border, height: 1),
  404. itemBuilder: (context, index) {
  405. final track = tracks[index];
  406. return _TrackRow(
  407. track: track,
  408. isCurrent: track.id == currentTrackId,
  409. isPlaying: playing,
  410. onTap: () => _playTrack(tracks, index),
  411. );
  412. },
  413. );
  414. },
  415. ),
  416. ),
  417. ],
  418. );
  419. }
  420. Widget body(BoxConstraints constraints) {
  421. final coverWidth = (constraints.maxWidth * 0.21).clamp(140.0, 252.0);
  422. return Column(
  423. crossAxisAlignment: CrossAxisAlignment.stretch,
  424. children: [
  425. Row(
  426. crossAxisAlignment: CrossAxisAlignment.start,
  427. children: [
  428. SizedBox(width: coverWidth, child: cover()),
  429. const SizedBox(width: 18),
  430. Expanded(child: controls(minHeight: coverWidth)),
  431. ],
  432. ),
  433. const SizedBox(height: 16),
  434. Expanded(
  435. child: SizedBox.expand(
  436. key: const ValueKey('jellyfin-album-track-list'),
  437. child: trackList(),
  438. ),
  439. ),
  440. ],
  441. );
  442. }
  443. return Scaffold(
  444. backgroundColor: colors.canvas,
  445. body: Container(
  446. decoration: homeBackground(colors, theme.brightness),
  447. child: SafeArea(
  448. child: Padding(
  449. padding: const EdgeInsets.fromLTRB(36, 22, 36, 16),
  450. child: Column(
  451. crossAxisAlignment: CrossAxisAlignment.stretch,
  452. children: [
  453. _AlbumHeader(album: widget.album),
  454. const SizedBox(height: 14),
  455. Expanded(
  456. child: LayoutBuilder(
  457. builder: (context, constraints) => body(constraints),
  458. ),
  459. ),
  460. ],
  461. ),
  462. ),
  463. ),
  464. ),
  465. );
  466. }
  467. Future<void> _playTrack(List<JellyfinTrack> tracks, int index) async {
  468. final playback = context.read<JellyfinPlaybackController>();
  469. if (playback.loading) {
  470. await playback.playTrackAt(index);
  471. return;
  472. }
  473. if (!playback.isCurrentAlbum(widget.album)) {
  474. final error = await playback.playAlbum(widget.album);
  475. if (error != null) return;
  476. }
  477. await playback.playTrackAt(index);
  478. }
  479. }
  480. class _AlbumHeader extends StatelessWidget {
  481. const _AlbumHeader({required this.album});
  482. final JellyfinAlbum album;
  483. @override
  484. Widget build(BuildContext context) {
  485. final colors = context.colors;
  486. final theme = Theme.of(context);
  487. final year = album.year;
  488. final title = year == null || year <= 0 || album.name.contains('($year)')
  489. ? album.name
  490. : '${album.name} ($year)';
  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. title,
  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. const JellyfinStatusStrip(),
  527. ],
  528. );
  529. }
  530. }
  531. class _FavoriteButton extends StatelessWidget {
  532. const _FavoriteButton({
  533. required this.isFavorite,
  534. required this.busy,
  535. required this.onTap,
  536. });
  537. final bool isFavorite;
  538. final bool busy;
  539. final VoidCallback onTap;
  540. @override
  541. Widget build(BuildContext context) {
  542. final colors = context.colors;
  543. return Pressable(
  544. onTap: busy ? null : onTap,
  545. semanticLabel: isFavorite ? 'Remove from favorites' : 'Add to favorites',
  546. pressedScale: 0.94,
  547. child: Container(
  548. width: 69,
  549. height: 69,
  550. decoration: BoxDecoration(
  551. color: isFavorite ? colors.accentSoft : colors.surface,
  552. shape: BoxShape.circle,
  553. border: Border.all(
  554. color: isFavorite
  555. ? colors.accent.withValues(alpha: 0.3)
  556. : colors.border,
  557. ),
  558. ),
  559. alignment: Alignment.center,
  560. child: busy
  561. ? SizedBox(
  562. width: 24,
  563. height: 24,
  564. child: CircularProgressIndicator(
  565. strokeWidth: 2,
  566. color: colors.accent,
  567. ),
  568. )
  569. : AppIcon(
  570. isFavorite
  571. ? 'assets/icons/heart-filled.svg'
  572. : 'assets/icons/heart.svg',
  573. size: 32,
  574. color: isFavorite ? colors.accent : colors.inkMuted,
  575. ),
  576. ),
  577. );
  578. }
  579. }
  580. class _AlbumVolumeControls extends StatelessWidget {
  581. const _AlbumVolumeControls({required this.system});
  582. final SystemController system;
  583. @override
  584. Widget build(BuildContext context) {
  585. final colors = context.colors;
  586. final enabled = !system.isVolumeFixed;
  587. return Row(
  588. mainAxisSize: MainAxisSize.min,
  589. children: [
  590. _AlbumVolumeStep(
  591. icon: Icons.remove_rounded,
  592. semanticLabel: 'Volume down',
  593. onTap: enabled ? () => unawaited(system.adjustVolume(-1)) : null,
  594. ),
  595. const SizedBox(width: 8),
  596. Pressable(
  597. onTap: enabled ? () => unawaited(system.toggleMute()) : null,
  598. semanticLabel: system.isMuted ? 'Unmute' : 'Mute',
  599. pressedScale: 0.92,
  600. child: Container(
  601. width: 60,
  602. height: 60,
  603. decoration: BoxDecoration(
  604. color: system.isMuted ? colors.accentSoft : colors.surfaceStrong,
  605. shape: BoxShape.circle,
  606. border: Border.all(
  607. color: system.isMuted
  608. ? colors.accent.withValues(alpha: 0.25)
  609. : colors.border,
  610. ),
  611. ),
  612. alignment: Alignment.center,
  613. child: AppIcon(
  614. system.isMuted
  615. ? 'assets/icons/volume-x.svg'
  616. : 'assets/icons/volume-2.svg',
  617. size: 26,
  618. color: system.isMuted ? colors.accent : colors.inkMuted,
  619. ),
  620. ),
  621. ),
  622. const SizedBox(width: 8),
  623. _AlbumVolumeStep(
  624. icon: Icons.add_rounded,
  625. semanticLabel: 'Volume up',
  626. onTap: enabled ? () => unawaited(system.adjustVolume(1)) : null,
  627. ),
  628. ],
  629. );
  630. }
  631. }
  632. class _AlbumVolumeStep extends StatelessWidget {
  633. const _AlbumVolumeStep({
  634. required this.icon,
  635. required this.semanticLabel,
  636. required this.onTap,
  637. });
  638. final IconData icon;
  639. final String semanticLabel;
  640. final VoidCallback? onTap;
  641. @override
  642. Widget build(BuildContext context) {
  643. final colors = context.colors;
  644. return Pressable(
  645. onTap: onTap,
  646. semanticLabel: semanticLabel,
  647. pressedScale: 0.92,
  648. child: Container(
  649. width: 60,
  650. height: 60,
  651. decoration: BoxDecoration(
  652. color: colors.surfaceStrong,
  653. shape: BoxShape.circle,
  654. border: Border.all(color: colors.border),
  655. ),
  656. alignment: Alignment.center,
  657. child: Icon(
  658. icon,
  659. size: 26,
  660. color: onTap == null ? colors.inkFaint : colors.inkMuted,
  661. ),
  662. ),
  663. );
  664. }
  665. }
  666. class _AlbumTransportButton extends StatelessWidget {
  667. const _AlbumTransportButton({
  668. required this.asset,
  669. required this.semanticLabel,
  670. required this.onTap,
  671. this.enabled = true,
  672. this.primary = false,
  673. });
  674. final String asset;
  675. final String semanticLabel;
  676. final VoidCallback onTap;
  677. final bool enabled;
  678. final bool primary;
  679. @override
  680. Widget build(BuildContext context) {
  681. final colors = context.colors;
  682. final size = primary ? 80.0 : 62.0;
  683. return Opacity(
  684. opacity: enabled ? 1 : 0.4,
  685. child: Pressable(
  686. onTap: enabled ? onTap : null,
  687. semanticLabel: semanticLabel,
  688. pressedScale: 0.92,
  689. child: Container(
  690. width: size,
  691. height: size,
  692. decoration: BoxDecoration(
  693. color: primary ? colors.accent : colors.surfaceStrong,
  694. shape: BoxShape.circle,
  695. border: primary ? null : Border.all(color: colors.border),
  696. ),
  697. alignment: Alignment.center,
  698. child: AppIcon(
  699. asset,
  700. size: primary ? 32 : 26,
  701. color: primary ? colors.accentInk : colors.ink,
  702. ),
  703. ),
  704. ),
  705. );
  706. }
  707. }
  708. class _TrackRow extends StatelessWidget {
  709. const _TrackRow({
  710. required this.track,
  711. required this.isCurrent,
  712. required this.isPlaying,
  713. required this.onTap,
  714. });
  715. final JellyfinTrack track;
  716. final bool isCurrent;
  717. final bool isPlaying;
  718. final VoidCallback onTap;
  719. @override
  720. Widget build(BuildContext context) {
  721. final colors = context.colors;
  722. final theme = Theme.of(context);
  723. final color = isCurrent ? colors.accent : colors.ink;
  724. return Material(
  725. color: isCurrent ? colors.accentSoft : Colors.transparent,
  726. borderRadius: BorderRadius.circular(12),
  727. child: InkWell(
  728. borderRadius: BorderRadius.circular(12),
  729. onTap: onTap,
  730. child: Padding(
  731. padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 12),
  732. child: Row(
  733. children: [
  734. SizedBox(
  735. width: 28,
  736. child: Text(
  737. track.displayIndex,
  738. style: theme.textTheme.bodySmall?.copyWith(
  739. color: isCurrent ? colors.accent : colors.inkFaint,
  740. ),
  741. ),
  742. ),
  743. const SizedBox(width: 6),
  744. Expanded(
  745. child: Text(
  746. track.name,
  747. maxLines: 1,
  748. overflow: TextOverflow.ellipsis,
  749. style: theme.textTheme.bodyMedium?.copyWith(
  750. color: color,
  751. fontWeight: isCurrent ? FontWeight.w600 : FontWeight.w400,
  752. ),
  753. ),
  754. ),
  755. const SizedBox(width: 8),
  756. Text(
  757. _formatDuration(track.duration),
  758. style: theme.textTheme.bodySmall?.copyWith(
  759. color: isCurrent ? colors.accent : colors.inkMuted,
  760. ),
  761. ),
  762. if (isCurrent) ...[
  763. const SizedBox(width: 8),
  764. AppIcon(
  765. isPlaying
  766. ? 'assets/icons/volume-2.svg'
  767. : 'assets/icons/play.svg',
  768. size: 14,
  769. color: colors.accent,
  770. ),
  771. ],
  772. ],
  773. ),
  774. ),
  775. ),
  776. );
  777. }
  778. }
  779. String _formatDuration(Duration duration) {
  780. final totalSeconds = duration.inSeconds;
  781. final minutes = totalSeconds ~/ 60;
  782. final seconds = totalSeconds % 60;
  783. final hours = minutes ~/ 60;
  784. final remainingMinutes = minutes % 60;
  785. String two(int value) => value.toString().padLeft(2, '0');
  786. if (hours > 0) {
  787. return '$hours:${two(remainingMinutes)}:${two(seconds)}';
  788. }
  789. return '$minutes:${two(seconds)}';
  790. }