jellyfin_album_screen.dart 27 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870
  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. return Row(
  508. crossAxisAlignment: CrossAxisAlignment.start,
  509. children: [
  510. const JellyfinBackButton(),
  511. const SizedBox(width: 18),
  512. Expanded(
  513. child: Column(
  514. crossAxisAlignment: CrossAxisAlignment.start,
  515. children: [
  516. Text(
  517. 'Album',
  518. style: theme.textTheme.labelLarge?.copyWith(
  519. color: colors.inkMuted,
  520. ),
  521. ),
  522. const SizedBox(height: 2),
  523. Text(
  524. album.name,
  525. maxLines: 2,
  526. overflow: TextOverflow.ellipsis,
  527. style: theme.textTheme.headlineLarge,
  528. ),
  529. const SizedBox(height: 2),
  530. Text(
  531. album.artist.isEmpty ? 'Unknown artist' : album.artist,
  532. maxLines: 1,
  533. overflow: TextOverflow.ellipsis,
  534. style: theme.textTheme.bodySmall?.copyWith(
  535. color: colors.inkMuted,
  536. ),
  537. ),
  538. ],
  539. ),
  540. ),
  541. const SizedBox(width: 12),
  542. _FavoriteButton(
  543. isFavorite: isFavorite,
  544. busy: favoriteBusy,
  545. onTap: onToggleFavorite,
  546. ),
  547. ],
  548. );
  549. }
  550. }
  551. class _FavoriteButton extends StatelessWidget {
  552. const _FavoriteButton({
  553. required this.isFavorite,
  554. required this.busy,
  555. required this.onTap,
  556. });
  557. final bool isFavorite;
  558. final bool busy;
  559. final VoidCallback onTap;
  560. @override
  561. Widget build(BuildContext context) {
  562. final colors = context.colors;
  563. return Pressable(
  564. onTap: busy ? null : onTap,
  565. semanticLabel: isFavorite ? 'Remove from favorites' : 'Add to favorites',
  566. child: Container(
  567. width: 46,
  568. height: 46,
  569. decoration: BoxDecoration(
  570. color: isFavorite ? colors.accentSoft : colors.surface,
  571. shape: BoxShape.circle,
  572. border: Border.all(
  573. color: isFavorite
  574. ? colors.accent.withValues(alpha: 0.3)
  575. : colors.border,
  576. ),
  577. ),
  578. alignment: Alignment.center,
  579. child: busy
  580. ? SizedBox(
  581. width: 16,
  582. height: 16,
  583. child: CircularProgressIndicator(
  584. strokeWidth: 2,
  585. color: colors.accent,
  586. ),
  587. )
  588. : AppIcon(
  589. isFavorite
  590. ? 'assets/icons/heart-filled.svg'
  591. : 'assets/icons/heart.svg',
  592. size: 21,
  593. color: isFavorite ? colors.accent : colors.inkMuted,
  594. ),
  595. ),
  596. );
  597. }
  598. }
  599. class _AlbumVolumeCard extends StatelessWidget {
  600. const _AlbumVolumeCard({required this.system});
  601. final SystemController system;
  602. @override
  603. Widget build(BuildContext context) {
  604. final colors = context.colors;
  605. final theme = Theme.of(context);
  606. final enabled = !system.isVolumeFixed;
  607. return Container(
  608. padding: const EdgeInsets.all(8),
  609. decoration: BoxDecoration(
  610. color: colors.surface,
  611. borderRadius: BorderRadius.circular(18),
  612. border: Border.all(color: colors.border),
  613. ),
  614. child: Row(
  615. mainAxisSize: MainAxisSize.min,
  616. children: [
  617. _AlbumVolumeStep(
  618. icon: Icons.remove_rounded,
  619. semanticLabel: 'Volume down',
  620. onTap: enabled ? () => unawaited(system.adjustVolume(-1)) : null,
  621. ),
  622. const SizedBox(width: 6),
  623. Pressable(
  624. onTap: enabled ? () => unawaited(system.toggleMute()) : null,
  625. semanticLabel: system.isMuted ? 'Unmute' : 'Mute',
  626. pressedScale: 0.94,
  627. child: Container(
  628. height: 40,
  629. padding: const EdgeInsets.symmetric(horizontal: 10),
  630. decoration: BoxDecoration(
  631. color: system.isMuted
  632. ? colors.accentSoft
  633. : colors.surfaceStrong,
  634. borderRadius: BorderRadius.circular(999),
  635. border: Border.all(
  636. color: system.isMuted
  637. ? colors.accent.withValues(alpha: 0.25)
  638. : colors.border,
  639. ),
  640. ),
  641. child: Row(
  642. mainAxisSize: MainAxisSize.min,
  643. children: [
  644. AppIcon(
  645. system.isMuted
  646. ? 'assets/icons/volume-x.svg'
  647. : 'assets/icons/volume-2.svg',
  648. size: 18,
  649. color: system.isMuted ? colors.accent : colors.inkMuted,
  650. ),
  651. const SizedBox(width: 6),
  652. Text(
  653. system.isMuted ? 'Unmute' : 'Mute',
  654. style: theme.textTheme.labelMedium?.copyWith(
  655. color: system.isMuted ? colors.accent : colors.inkMuted,
  656. ),
  657. ),
  658. ],
  659. ),
  660. ),
  661. ),
  662. const SizedBox(width: 6),
  663. _AlbumVolumeStep(
  664. icon: Icons.add_rounded,
  665. semanticLabel: 'Volume up',
  666. onTap: enabled ? () => unawaited(system.adjustVolume(1)) : null,
  667. ),
  668. ],
  669. ),
  670. );
  671. }
  672. }
  673. class _AlbumVolumeStep extends StatelessWidget {
  674. const _AlbumVolumeStep({
  675. required this.icon,
  676. required this.semanticLabel,
  677. required this.onTap,
  678. });
  679. final IconData icon;
  680. final String semanticLabel;
  681. final VoidCallback? onTap;
  682. @override
  683. Widget build(BuildContext context) {
  684. final colors = context.colors;
  685. return Pressable(
  686. onTap: onTap,
  687. semanticLabel: semanticLabel,
  688. pressedScale: 0.92,
  689. child: Container(
  690. width: 40,
  691. height: 40,
  692. decoration: BoxDecoration(
  693. color: colors.surfaceStrong,
  694. shape: BoxShape.circle,
  695. border: Border.all(color: colors.border),
  696. ),
  697. alignment: Alignment.center,
  698. child: Icon(
  699. icon,
  700. size: 20,
  701. color: onTap == null ? colors.inkFaint : colors.inkMuted,
  702. ),
  703. ),
  704. );
  705. }
  706. }
  707. class _AlbumTransportButton extends StatelessWidget {
  708. const _AlbumTransportButton({
  709. required this.asset,
  710. required this.semanticLabel,
  711. required this.onTap,
  712. this.enabled = true,
  713. this.primary = false,
  714. });
  715. final String asset;
  716. final String semanticLabel;
  717. final VoidCallback onTap;
  718. final bool enabled;
  719. final bool primary;
  720. @override
  721. Widget build(BuildContext context) {
  722. final colors = context.colors;
  723. final size = primary ? 80.0 : 62.0;
  724. return Opacity(
  725. opacity: enabled ? 1 : 0.4,
  726. child: Pressable(
  727. onTap: enabled ? onTap : null,
  728. semanticLabel: semanticLabel,
  729. pressedScale: 0.92,
  730. child: Container(
  731. width: size,
  732. height: size,
  733. decoration: BoxDecoration(
  734. color: primary ? colors.accent : colors.surfaceStrong,
  735. shape: BoxShape.circle,
  736. border: primary ? null : Border.all(color: colors.border),
  737. ),
  738. alignment: Alignment.center,
  739. child: AppIcon(
  740. asset,
  741. size: primary ? 32 : 26,
  742. color: primary ? colors.accentInk : colors.ink,
  743. ),
  744. ),
  745. ),
  746. );
  747. }
  748. }
  749. class _TrackRow extends StatelessWidget {
  750. const _TrackRow({
  751. required this.track,
  752. required this.isCurrent,
  753. required this.isPlaying,
  754. required this.onTap,
  755. });
  756. final JellyfinTrack track;
  757. final bool isCurrent;
  758. final bool isPlaying;
  759. final VoidCallback onTap;
  760. @override
  761. Widget build(BuildContext context) {
  762. final colors = context.colors;
  763. final theme = Theme.of(context);
  764. final color = isCurrent ? colors.accent : colors.ink;
  765. return Material(
  766. color: isCurrent ? colors.accentSoft : Colors.transparent,
  767. borderRadius: BorderRadius.circular(12),
  768. child: InkWell(
  769. borderRadius: BorderRadius.circular(12),
  770. onTap: onTap,
  771. child: Padding(
  772. padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 7),
  773. child: Row(
  774. children: [
  775. SizedBox(
  776. width: 28,
  777. child: Text(
  778. track.displayIndex,
  779. style: theme.textTheme.bodySmall?.copyWith(
  780. color: isCurrent ? colors.accent : colors.inkFaint,
  781. ),
  782. ),
  783. ),
  784. const SizedBox(width: 6),
  785. Expanded(
  786. child: Text(
  787. track.name,
  788. maxLines: 1,
  789. overflow: TextOverflow.ellipsis,
  790. style: theme.textTheme.bodyMedium?.copyWith(
  791. color: color,
  792. fontWeight: isCurrent ? FontWeight.w600 : FontWeight.w400,
  793. ),
  794. ),
  795. ),
  796. const SizedBox(width: 8),
  797. Text(
  798. _formatDuration(track.duration),
  799. style: theme.textTheme.bodySmall?.copyWith(
  800. color: isCurrent ? colors.accent : colors.inkMuted,
  801. ),
  802. ),
  803. if (isCurrent) ...[
  804. const SizedBox(width: 8),
  805. AppIcon(
  806. isPlaying
  807. ? 'assets/icons/volume-2.svg'
  808. : 'assets/icons/play.svg',
  809. size: 14,
  810. color: colors.accent,
  811. ),
  812. ],
  813. ],
  814. ),
  815. ),
  816. ),
  817. );
  818. }
  819. }
  820. String _formatDuration(Duration duration) {
  821. final totalSeconds = duration.inSeconds;
  822. final minutes = totalSeconds ~/ 60;
  823. final seconds = totalSeconds % 60;
  824. final hours = minutes ~/ 60;
  825. final remainingMinutes = minutes % 60;
  826. String two(int value) => value.toString().padLeft(2, '0');
  827. if (hours > 0) {
  828. return '$hours:${two(remainingMinutes)}:${two(seconds)}';
  829. }
  830. return '$minutes:${two(seconds)}';
  831. }