jellyfin_album_screen.dart 28 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874
  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 wideBody(BoxConstraints constraints) {
  409. final coverWidth = (constraints.maxWidth * 0.30).clamp(280.0, 380.0);
  410. final controlsWidth = (constraints.maxWidth * 0.44).clamp(520.0, 600.0);
  411. return Row(
  412. crossAxisAlignment: CrossAxisAlignment.start,
  413. children: [
  414. SizedBox(width: coverWidth, child: cover()),
  415. const SizedBox(width: 22),
  416. Expanded(child: trackList()),
  417. const SizedBox(width: 22),
  418. SizedBox(
  419. width: controlsWidth,
  420. height: coverWidth,
  421. child: controls(minHeight: coverWidth),
  422. ),
  423. ],
  424. );
  425. }
  426. Widget narrowBody(BoxConstraints constraints) {
  427. final coverWidth = (constraints.maxWidth * 0.34).clamp(200.0, 260.0);
  428. return SingleChildScrollView(
  429. child: Column(
  430. crossAxisAlignment: CrossAxisAlignment.stretch,
  431. children: [
  432. Row(
  433. crossAxisAlignment: CrossAxisAlignment.start,
  434. children: [
  435. SizedBox(width: coverWidth, child: cover()),
  436. const SizedBox(width: 18),
  437. Expanded(child: controls(minHeight: coverWidth)),
  438. ],
  439. ),
  440. const SizedBox(height: 16),
  441. SizedBox(height: 260, child: trackList()),
  442. ],
  443. ),
  444. );
  445. }
  446. return Scaffold(
  447. backgroundColor: colors.canvas,
  448. body: Container(
  449. decoration: homeBackground(colors, theme.brightness),
  450. child: SafeArea(
  451. child: Padding(
  452. padding: const EdgeInsets.fromLTRB(36, 22, 36, 16),
  453. child: Column(
  454. crossAxisAlignment: CrossAxisAlignment.stretch,
  455. children: [
  456. _AlbumHeader(
  457. album: widget.album,
  458. isFavorite: _isFavorite,
  459. favoriteBusy: _favoriteBusy,
  460. onToggleFavorite: _toggleFavorite,
  461. ),
  462. const SizedBox(height: 14),
  463. Expanded(
  464. child: LayoutBuilder(
  465. builder: (context, constraints) {
  466. return constraints.maxWidth >= 1180
  467. ? wideBody(constraints)
  468. : narrowBody(constraints);
  469. },
  470. ),
  471. ),
  472. ],
  473. ),
  474. ),
  475. ),
  476. ),
  477. );
  478. }
  479. Future<void> _playTrack(List<JellyfinTrack> tracks, int index) async {
  480. final playback = context.read<JellyfinPlaybackController>();
  481. if (playback.loading) {
  482. await playback.playTrackAt(index);
  483. return;
  484. }
  485. if (!playback.isCurrentAlbum(widget.album)) {
  486. final error = await playback.playAlbum(widget.album);
  487. if (error != null) return;
  488. }
  489. await playback.playTrackAt(index);
  490. }
  491. }
  492. class _AlbumHeader extends StatelessWidget {
  493. const _AlbumHeader({
  494. required this.album,
  495. required this.isFavorite,
  496. required this.favoriteBusy,
  497. required this.onToggleFavorite,
  498. });
  499. final JellyfinAlbum album;
  500. final bool isFavorite;
  501. final bool favoriteBusy;
  502. final VoidCallback onToggleFavorite;
  503. @override
  504. Widget build(BuildContext context) {
  505. final colors = context.colors;
  506. final theme = Theme.of(context);
  507. final year = album.year;
  508. final title = year == null || year <= 0 || album.name.contains('($year)')
  509. ? album.name
  510. : '${album.name} ($year)';
  511. return Row(
  512. crossAxisAlignment: CrossAxisAlignment.start,
  513. children: [
  514. const JellyfinBackButton(),
  515. const SizedBox(width: 18),
  516. Expanded(
  517. child: Column(
  518. crossAxisAlignment: CrossAxisAlignment.start,
  519. children: [
  520. Text(
  521. 'Album',
  522. style: theme.textTheme.labelLarge?.copyWith(
  523. color: colors.inkMuted,
  524. ),
  525. ),
  526. const SizedBox(height: 2),
  527. Text(
  528. title,
  529. maxLines: 2,
  530. overflow: TextOverflow.ellipsis,
  531. style: theme.textTheme.headlineLarge,
  532. ),
  533. const SizedBox(height: 2),
  534. Text(
  535. album.artist.isEmpty ? 'Unknown artist' : album.artist,
  536. maxLines: 1,
  537. overflow: TextOverflow.ellipsis,
  538. style: theme.textTheme.bodySmall?.copyWith(
  539. color: colors.inkMuted,
  540. ),
  541. ),
  542. ],
  543. ),
  544. ),
  545. const SizedBox(width: 12),
  546. _FavoriteButton(
  547. isFavorite: isFavorite,
  548. busy: favoriteBusy,
  549. onTap: onToggleFavorite,
  550. ),
  551. ],
  552. );
  553. }
  554. }
  555. class _FavoriteButton extends StatelessWidget {
  556. const _FavoriteButton({
  557. required this.isFavorite,
  558. required this.busy,
  559. required this.onTap,
  560. });
  561. final bool isFavorite;
  562. final bool busy;
  563. final VoidCallback onTap;
  564. @override
  565. Widget build(BuildContext context) {
  566. final colors = context.colors;
  567. return Pressable(
  568. onTap: busy ? null : onTap,
  569. semanticLabel: isFavorite ? 'Remove from favorites' : 'Add to favorites',
  570. child: Container(
  571. width: 46,
  572. height: 46,
  573. decoration: BoxDecoration(
  574. color: isFavorite ? colors.accentSoft : colors.surface,
  575. shape: BoxShape.circle,
  576. border: Border.all(
  577. color: isFavorite
  578. ? colors.accent.withValues(alpha: 0.3)
  579. : colors.border,
  580. ),
  581. ),
  582. alignment: Alignment.center,
  583. child: busy
  584. ? SizedBox(
  585. width: 16,
  586. height: 16,
  587. child: CircularProgressIndicator(
  588. strokeWidth: 2,
  589. color: colors.accent,
  590. ),
  591. )
  592. : AppIcon(
  593. isFavorite
  594. ? 'assets/icons/heart-filled.svg'
  595. : 'assets/icons/heart.svg',
  596. size: 21,
  597. color: isFavorite ? colors.accent : colors.inkMuted,
  598. ),
  599. ),
  600. );
  601. }
  602. }
  603. class _AlbumVolumeCard extends StatelessWidget {
  604. const _AlbumVolumeCard({required this.system});
  605. final SystemController system;
  606. @override
  607. Widget build(BuildContext context) {
  608. final colors = context.colors;
  609. final theme = Theme.of(context);
  610. final enabled = !system.isVolumeFixed;
  611. return Container(
  612. padding: const EdgeInsets.all(8),
  613. decoration: BoxDecoration(
  614. color: colors.surface,
  615. borderRadius: BorderRadius.circular(18),
  616. border: Border.all(color: colors.border),
  617. ),
  618. child: Row(
  619. mainAxisSize: MainAxisSize.min,
  620. children: [
  621. _AlbumVolumeStep(
  622. icon: Icons.remove_rounded,
  623. semanticLabel: 'Volume down',
  624. onTap: enabled ? () => unawaited(system.adjustVolume(-1)) : null,
  625. ),
  626. const SizedBox(width: 6),
  627. Pressable(
  628. onTap: enabled ? () => unawaited(system.toggleMute()) : null,
  629. semanticLabel: system.isMuted ? 'Unmute' : 'Mute',
  630. pressedScale: 0.94,
  631. child: Container(
  632. height: 40,
  633. padding: const EdgeInsets.symmetric(horizontal: 10),
  634. decoration: BoxDecoration(
  635. color: system.isMuted
  636. ? colors.accentSoft
  637. : colors.surfaceStrong,
  638. borderRadius: BorderRadius.circular(999),
  639. border: Border.all(
  640. color: system.isMuted
  641. ? colors.accent.withValues(alpha: 0.25)
  642. : colors.border,
  643. ),
  644. ),
  645. child: Row(
  646. mainAxisSize: MainAxisSize.min,
  647. children: [
  648. AppIcon(
  649. system.isMuted
  650. ? 'assets/icons/volume-x.svg'
  651. : 'assets/icons/volume-2.svg',
  652. size: 18,
  653. color: system.isMuted ? colors.accent : colors.inkMuted,
  654. ),
  655. const SizedBox(width: 6),
  656. Text(
  657. system.isMuted ? 'Unmute' : 'Mute',
  658. style: theme.textTheme.labelMedium?.copyWith(
  659. color: system.isMuted ? colors.accent : colors.inkMuted,
  660. ),
  661. ),
  662. ],
  663. ),
  664. ),
  665. ),
  666. const SizedBox(width: 6),
  667. _AlbumVolumeStep(
  668. icon: Icons.add_rounded,
  669. semanticLabel: 'Volume up',
  670. onTap: enabled ? () => unawaited(system.adjustVolume(1)) : null,
  671. ),
  672. ],
  673. ),
  674. );
  675. }
  676. }
  677. class _AlbumVolumeStep extends StatelessWidget {
  678. const _AlbumVolumeStep({
  679. required this.icon,
  680. required this.semanticLabel,
  681. required this.onTap,
  682. });
  683. final IconData icon;
  684. final String semanticLabel;
  685. final VoidCallback? onTap;
  686. @override
  687. Widget build(BuildContext context) {
  688. final colors = context.colors;
  689. return Pressable(
  690. onTap: onTap,
  691. semanticLabel: semanticLabel,
  692. pressedScale: 0.92,
  693. child: Container(
  694. width: 40,
  695. height: 40,
  696. decoration: BoxDecoration(
  697. color: colors.surfaceStrong,
  698. shape: BoxShape.circle,
  699. border: Border.all(color: colors.border),
  700. ),
  701. alignment: Alignment.center,
  702. child: Icon(
  703. icon,
  704. size: 20,
  705. color: onTap == null ? colors.inkFaint : colors.inkMuted,
  706. ),
  707. ),
  708. );
  709. }
  710. }
  711. class _AlbumTransportButton extends StatelessWidget {
  712. const _AlbumTransportButton({
  713. required this.asset,
  714. required this.semanticLabel,
  715. required this.onTap,
  716. this.enabled = true,
  717. this.primary = false,
  718. });
  719. final String asset;
  720. final String semanticLabel;
  721. final VoidCallback onTap;
  722. final bool enabled;
  723. final bool primary;
  724. @override
  725. Widget build(BuildContext context) {
  726. final colors = context.colors;
  727. final size = primary ? 80.0 : 62.0;
  728. return Opacity(
  729. opacity: enabled ? 1 : 0.4,
  730. child: Pressable(
  731. onTap: enabled ? onTap : null,
  732. semanticLabel: semanticLabel,
  733. pressedScale: 0.92,
  734. child: Container(
  735. width: size,
  736. height: size,
  737. decoration: BoxDecoration(
  738. color: primary ? colors.accent : colors.surfaceStrong,
  739. shape: BoxShape.circle,
  740. border: primary ? null : Border.all(color: colors.border),
  741. ),
  742. alignment: Alignment.center,
  743. child: AppIcon(
  744. asset,
  745. size: primary ? 32 : 26,
  746. color: primary ? colors.accentInk : colors.ink,
  747. ),
  748. ),
  749. ),
  750. );
  751. }
  752. }
  753. class _TrackRow extends StatelessWidget {
  754. const _TrackRow({
  755. required this.track,
  756. required this.isCurrent,
  757. required this.isPlaying,
  758. required this.onTap,
  759. });
  760. final JellyfinTrack track;
  761. final bool isCurrent;
  762. final bool isPlaying;
  763. final VoidCallback onTap;
  764. @override
  765. Widget build(BuildContext context) {
  766. final colors = context.colors;
  767. final theme = Theme.of(context);
  768. final color = isCurrent ? colors.accent : colors.ink;
  769. return Material(
  770. color: isCurrent ? colors.accentSoft : Colors.transparent,
  771. borderRadius: BorderRadius.circular(12),
  772. child: InkWell(
  773. borderRadius: BorderRadius.circular(12),
  774. onTap: onTap,
  775. child: Padding(
  776. padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 7),
  777. child: Row(
  778. children: [
  779. SizedBox(
  780. width: 28,
  781. child: Text(
  782. track.displayIndex,
  783. style: theme.textTheme.bodySmall?.copyWith(
  784. color: isCurrent ? colors.accent : colors.inkFaint,
  785. ),
  786. ),
  787. ),
  788. const SizedBox(width: 6),
  789. Expanded(
  790. child: Text(
  791. track.name,
  792. maxLines: 1,
  793. overflow: TextOverflow.ellipsis,
  794. style: theme.textTheme.bodyMedium?.copyWith(
  795. color: color,
  796. fontWeight: isCurrent ? FontWeight.w600 : FontWeight.w400,
  797. ),
  798. ),
  799. ),
  800. const SizedBox(width: 8),
  801. Text(
  802. _formatDuration(track.duration),
  803. style: theme.textTheme.bodySmall?.copyWith(
  804. color: isCurrent ? colors.accent : colors.inkMuted,
  805. ),
  806. ),
  807. if (isCurrent) ...[
  808. const SizedBox(width: 8),
  809. AppIcon(
  810. isPlaying
  811. ? 'assets/icons/volume-2.svg'
  812. : 'assets/icons/play.svg',
  813. size: 14,
  814. color: colors.accent,
  815. ),
  816. ],
  817. ],
  818. ),
  819. ),
  820. ),
  821. );
  822. }
  823. }
  824. String _formatDuration(Duration duration) {
  825. final totalSeconds = duration.inSeconds;
  826. final minutes = totalSeconds ~/ 60;
  827. final seconds = totalSeconds % 60;
  828. final hours = minutes ~/ 60;
  829. final remainingMinutes = minutes % 60;
  830. String two(int value) => value.toString().padLeft(2, '0');
  831. if (hours > 0) {
  832. return '$hours:${two(remainingMinutes)}:${two(seconds)}';
  833. }
  834. return '$minutes:${two(seconds)}';
  835. }