jellyfin_dashboard_screen.dart 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687
  1. import 'dart:async';
  2. import 'dart:math' as math;
  3. import 'package:flutter/material.dart';
  4. import 'package:provider/provider.dart';
  5. import '../../core/theme/app_colors.dart';
  6. import '../../core/widgets/app_icon.dart';
  7. import '../../core/widgets/pressable.dart';
  8. import '../../data/models/jellyfin_library.dart';
  9. import '../../data/services/jellyfin_library_service.dart';
  10. import '../settings/jellyfin_controller.dart';
  11. import 'jellyfin_album_screen.dart';
  12. import 'jellyfin_dashboard_controller.dart';
  13. import 'jellyfin_playback_controller.dart';
  14. import 'widgets/jellyfin_back_button.dart';
  15. class JellyfinDashboardScreen extends StatelessWidget {
  16. const JellyfinDashboardScreen({super.key, this.openAlbum});
  17. /// When set, the album page is pushed once the dashboard is mounted. Used
  18. /// when Home is tapped while a Jellyfin album is already playing, so Back
  19. /// from the album lands on Albums instead of Home.
  20. final JellyfinAlbum? openAlbum;
  21. @override
  22. Widget build(BuildContext context) {
  23. return ChangeNotifierProvider(
  24. create: (context) {
  25. final controller = JellyfinDashboardController(
  26. library: context.read<JellyfinLibraryService>(),
  27. jellyfin: context.read<JellyfinController>(),
  28. );
  29. unawaited(controller.load());
  30. return controller;
  31. },
  32. child: _JellyfinDashboardRoute(openAlbum: openAlbum),
  33. );
  34. }
  35. }
  36. class _JellyfinDashboardRoute extends StatefulWidget {
  37. const _JellyfinDashboardRoute({this.openAlbum});
  38. final JellyfinAlbum? openAlbum;
  39. @override
  40. State<_JellyfinDashboardRoute> createState() =>
  41. _JellyfinDashboardRouteState();
  42. }
  43. class _JellyfinDashboardRouteState extends State<_JellyfinDashboardRoute> {
  44. @override
  45. void initState() {
  46. super.initState();
  47. final album = widget.openAlbum;
  48. if (album == null) return;
  49. WidgetsBinding.instance.addPostFrameCallback((_) {
  50. if (!mounted) return;
  51. Navigator.of(context).push(
  52. MaterialPageRoute<void>(
  53. builder: (_) => JellyfinAlbumScreen(album: album),
  54. ),
  55. );
  56. });
  57. }
  58. @override
  59. Widget build(BuildContext context) => const _JellyfinDashboardBody();
  60. }
  61. enum _JellyfinTab { albums, favorites }
  62. class _JellyfinDashboardBody extends StatefulWidget {
  63. const _JellyfinDashboardBody();
  64. @override
  65. State<_JellyfinDashboardBody> createState() => _JellyfinDashboardBodyState();
  66. }
  67. class _JellyfinDashboardBodyState extends State<_JellyfinDashboardBody> {
  68. _JellyfinTab _tab = _JellyfinTab.albums;
  69. @override
  70. Widget build(BuildContext context) {
  71. final colors = context.colors;
  72. final theme = Theme.of(context);
  73. final dashboard = context.watch<JellyfinDashboardController>();
  74. final playback = context.watch<JellyfinPlaybackController>();
  75. final jellyfin = context.watch<JellyfinController>();
  76. final library = context.read<JellyfinLibraryService>();
  77. final currentId = playback.currentAlbum?.id;
  78. final currentPlaying = playback.playing || playback.buffering;
  79. final headers = library.imageHeaders;
  80. String? imageFor(JellyfinAlbum album) =>
  81. library.imageUrl(album.id, album.imageTag)?.toString();
  82. Widget favoritesBody(int columns) {
  83. final error = dashboard.favoritesError;
  84. if (error != null) {
  85. return _DashboardError(
  86. message: error,
  87. onRetry: () => unawaited(dashboard.retryFavorites()),
  88. );
  89. }
  90. if (dashboard.favoritesLoading && !dashboard.favoritesLoaded) {
  91. return const Center(child: CircularProgressIndicator());
  92. }
  93. if (dashboard.favorites.isEmpty) {
  94. return Center(
  95. child: Text(
  96. 'No favorites',
  97. style: theme.textTheme.bodyLarge?.copyWith(color: colors.inkMuted),
  98. ),
  99. );
  100. }
  101. return GridView.builder(
  102. padding: const EdgeInsets.only(bottom: 8),
  103. gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
  104. crossAxisCount: columns,
  105. crossAxisSpacing: 12,
  106. mainAxisSpacing: 14,
  107. childAspectRatio: 0.78,
  108. ),
  109. itemCount: dashboard.favorites.length,
  110. itemBuilder: (context, index) {
  111. final album = dashboard.favorites[index];
  112. return _AlbumCard(
  113. album: album,
  114. imageUrl: imageFor(album),
  115. headers: headers,
  116. isCurrent: currentId == album.id,
  117. isPlaying: currentPlaying && currentId == album.id,
  118. onTap: () => _openAlbum(context, dashboard, playback, album),
  119. );
  120. },
  121. );
  122. }
  123. Widget body(int columns) {
  124. if (_tab == _JellyfinTab.favorites) {
  125. return favoritesBody(columns);
  126. }
  127. if (dashboard.loading && !dashboard.hasLoaded) {
  128. return const Center(child: CircularProgressIndicator());
  129. }
  130. final error = dashboard.error;
  131. if (error != null) {
  132. return _DashboardError(
  133. message: error,
  134. onRetry: () => unawaited(dashboard.retry()),
  135. );
  136. }
  137. return Column(
  138. children: [
  139. Expanded(
  140. child: _AlbumSection(
  141. title: 'Recently played',
  142. albums: dashboard.latestPlayed,
  143. emptyText: 'Nothing played yet.',
  144. columns: columns,
  145. imageFor: imageFor,
  146. headers: headers,
  147. currentAlbumId: currentId,
  148. currentPlaying: currentPlaying,
  149. onPlay: (album) =>
  150. _openAlbum(context, dashboard, playback, album),
  151. ),
  152. ),
  153. const SizedBox(height: 6),
  154. Expanded(
  155. child: _AlbumSection(
  156. title: 'Recently added',
  157. albums: dashboard.latestAdded,
  158. emptyText: 'No albums added yet.',
  159. columns: columns,
  160. imageFor: imageFor,
  161. headers: headers,
  162. currentAlbumId: currentId,
  163. currentPlaying: currentPlaying,
  164. onPlay: (album) =>
  165. _openAlbum(context, dashboard, playback, album),
  166. ),
  167. ),
  168. const SizedBox(height: 6),
  169. Expanded(
  170. child: _AlbumSection(
  171. title: 'Random',
  172. albums: dashboard.random,
  173. emptyText: dashboard.hasGenres
  174. ? 'No albums for the selected genres.'
  175. : 'Choose up to 5 genres in Settings.',
  176. columns: columns,
  177. error: dashboard.randomError,
  178. shuffling: dashboard.randomLoading,
  179. onShuffle: dashboard.hasGenres ? dashboard.shuffle : null,
  180. imageFor: imageFor,
  181. headers: headers,
  182. currentAlbumId: currentId,
  183. currentPlaying: currentPlaying,
  184. onPlay: (album) => _openAlbum(
  185. context,
  186. dashboard,
  187. playback,
  188. album,
  189. shuffleAfter: true,
  190. ),
  191. ),
  192. ),
  193. ],
  194. );
  195. }
  196. return Scaffold(
  197. backgroundColor: colors.canvas,
  198. body: Container(
  199. decoration: homeBackground(colors, theme.brightness),
  200. child: SafeArea(
  201. child: Padding(
  202. padding: const EdgeInsets.fromLTRB(36, 22, 36, 16),
  203. child: Column(
  204. crossAxisAlignment: CrossAxisAlignment.stretch,
  205. children: [
  206. _DashboardHeader(serverName: jellyfin.serverName),
  207. const SizedBox(height: 10),
  208. _DashboardTabs(
  209. selected: _tab,
  210. onSelect: (tab) {
  211. setState(() => _tab = tab);
  212. if (tab == _JellyfinTab.favorites) {
  213. unawaited(dashboard.loadFavorites());
  214. }
  215. },
  216. ),
  217. const SizedBox(height: 10),
  218. Expanded(
  219. child: LayoutBuilder(
  220. builder: (context, constraints) {
  221. return body(_columnsFor(constraints.maxWidth));
  222. },
  223. ),
  224. ),
  225. ],
  226. ),
  227. ),
  228. ),
  229. ),
  230. );
  231. }
  232. static const _rowGap = 12.0;
  233. static const _minSixColumnWidth = 160.0;
  234. int _columnsFor(double width) {
  235. final sixColumnWidth = (width - _rowGap * 5) / 6;
  236. return sixColumnWidth >= _minSixColumnWidth ? 6 : 5;
  237. }
  238. void _openAlbum(
  239. BuildContext context,
  240. JellyfinDashboardController dashboard,
  241. JellyfinPlaybackController playback,
  242. JellyfinAlbum album, {
  243. bool shuffleAfter = false,
  244. }) {
  245. dashboard.markPlayed(album);
  246. final started = playback.playAlbum(album);
  247. if (shuffleAfter) {
  248. unawaited(
  249. started.then((error) async {
  250. if (error == null) await dashboard.shuffle();
  251. }),
  252. );
  253. } else {
  254. unawaited(started);
  255. }
  256. unawaited(
  257. Navigator.of(context)
  258. .push(
  259. MaterialPageRoute<void>(
  260. builder: (_) => JellyfinAlbumScreen(album: album),
  261. ),
  262. )
  263. .then((_) => dashboard.loadFavorites()),
  264. );
  265. }
  266. }
  267. class _DashboardHeader extends StatelessWidget {
  268. const _DashboardHeader({this.serverName});
  269. final String? serverName;
  270. @override
  271. Widget build(BuildContext context) {
  272. final theme = Theme.of(context);
  273. final colors = context.colors;
  274. final name = serverName?.trim();
  275. return Row(
  276. children: [
  277. const JellyfinBackButton(),
  278. const SizedBox(width: 18),
  279. Expanded(
  280. child: Column(
  281. crossAxisAlignment: CrossAxisAlignment.start,
  282. children: [
  283. Text('Jellyfin', style: theme.textTheme.headlineLarge),
  284. const SizedBox(height: 2),
  285. Text(
  286. name == null || name.isEmpty
  287. ? 'Music library'
  288. : 'Music library · $name',
  289. maxLines: 1,
  290. overflow: TextOverflow.ellipsis,
  291. style: theme.textTheme.bodySmall?.copyWith(
  292. color: colors.inkMuted,
  293. ),
  294. ),
  295. ],
  296. ),
  297. ),
  298. ],
  299. );
  300. }
  301. }
  302. class _DashboardTabs extends StatelessWidget {
  303. const _DashboardTabs({required this.selected, required this.onSelect});
  304. final _JellyfinTab selected;
  305. final ValueChanged<_JellyfinTab> onSelect;
  306. @override
  307. Widget build(BuildContext context) {
  308. final theme = Theme.of(context);
  309. final colors = context.colors;
  310. Widget tab(String label, _JellyfinTab value) {
  311. final isSelected = selected == value;
  312. return Pressable(
  313. onTap: () => onSelect(value),
  314. semanticLabel: label,
  315. child: Column(
  316. mainAxisSize: MainAxisSize.min,
  317. children: [
  318. Text(
  319. label,
  320. style: theme.textTheme.titleMedium?.copyWith(
  321. color: isSelected ? colors.ink : colors.inkMuted,
  322. fontWeight: isSelected ? FontWeight.w700 : FontWeight.w500,
  323. ),
  324. ),
  325. const SizedBox(height: 4),
  326. Container(
  327. width: 38,
  328. height: 2,
  329. decoration: BoxDecoration(
  330. color: isSelected ? colors.accent : Colors.transparent,
  331. borderRadius: BorderRadius.circular(2),
  332. ),
  333. ),
  334. ],
  335. ),
  336. );
  337. }
  338. return Row(
  339. children: [
  340. tab('Albums', _JellyfinTab.albums),
  341. const SizedBox(width: 28),
  342. tab('Favorites', _JellyfinTab.favorites),
  343. const SizedBox(width: 28),
  344. Text(
  345. 'Artists',
  346. style: theme.textTheme.titleMedium?.copyWith(color: colors.inkFaint),
  347. ),
  348. ],
  349. );
  350. }
  351. }
  352. class _AlbumSection extends StatelessWidget {
  353. const _AlbumSection({
  354. required this.title,
  355. required this.albums,
  356. required this.emptyText,
  357. required this.columns,
  358. required this.imageFor,
  359. required this.headers,
  360. required this.currentAlbumId,
  361. required this.currentPlaying,
  362. required this.onPlay,
  363. this.error,
  364. this.shuffling = false,
  365. this.onShuffle,
  366. });
  367. final String title;
  368. final List<JellyfinAlbum> albums;
  369. final String emptyText;
  370. final int columns;
  371. final String? Function(JellyfinAlbum album) imageFor;
  372. final Map<String, String> headers;
  373. final String? currentAlbumId;
  374. final bool currentPlaying;
  375. final ValueChanged<JellyfinAlbum> onPlay;
  376. final String? error;
  377. final bool shuffling;
  378. final Future<void> Function()? onShuffle;
  379. @override
  380. Widget build(BuildContext context) {
  381. final theme = Theme.of(context);
  382. final colors = context.colors;
  383. return Column(
  384. crossAxisAlignment: CrossAxisAlignment.stretch,
  385. children: [
  386. Row(
  387. children: [
  388. Text(
  389. title,
  390. style: theme.textTheme.titleMedium?.copyWith(
  391. color: colors.ink,
  392. fontWeight: FontWeight.w700,
  393. ),
  394. ),
  395. if (onShuffle != null) ...[
  396. const Spacer(),
  397. _ShuffleButton(
  398. loading: shuffling,
  399. onTap: () => unawaited(onShuffle!()),
  400. ),
  401. ],
  402. ],
  403. ),
  404. const SizedBox(height: 6),
  405. Expanded(
  406. child: error != null
  407. ? Center(
  408. child: Text(
  409. error!,
  410. textAlign: TextAlign.center,
  411. style: theme.textTheme.bodySmall?.copyWith(
  412. color: colors.danger,
  413. ),
  414. ),
  415. )
  416. : albums.isEmpty
  417. ? Center(
  418. child: Text(
  419. emptyText,
  420. textAlign: TextAlign.center,
  421. style: theme.textTheme.bodySmall?.copyWith(
  422. color: colors.inkMuted,
  423. ),
  424. ),
  425. )
  426. : LayoutBuilder(
  427. builder: (context, constraints) {
  428. const gap = 12.0;
  429. final width = math.max(
  430. 0.0,
  431. (constraints.maxWidth - gap * (columns - 1)) / columns,
  432. );
  433. return Row(
  434. crossAxisAlignment: CrossAxisAlignment.stretch,
  435. children: [
  436. for (var i = 0; i < columns; i++) ...[
  437. if (i > 0) const SizedBox(width: gap),
  438. SizedBox(
  439. width: width,
  440. child: i < albums.length
  441. ? _AlbumCard(
  442. album: albums[i],
  443. imageUrl: imageFor(albums[i]),
  444. headers: headers,
  445. isCurrent: currentAlbumId == albums[i].id,
  446. isPlaying:
  447. currentPlaying &&
  448. currentAlbumId == albums[i].id,
  449. onTap: () => onPlay(albums[i]),
  450. )
  451. : const SizedBox.shrink(),
  452. ),
  453. ],
  454. ],
  455. );
  456. },
  457. ),
  458. ),
  459. ],
  460. );
  461. }
  462. }
  463. class _AlbumCard extends StatelessWidget {
  464. const _AlbumCard({
  465. required this.album,
  466. required this.imageUrl,
  467. required this.headers,
  468. required this.isCurrent,
  469. required this.isPlaying,
  470. required this.onTap,
  471. });
  472. final JellyfinAlbum album;
  473. final String? imageUrl;
  474. final Map<String, String> headers;
  475. final bool isCurrent;
  476. final bool isPlaying;
  477. final VoidCallback onTap;
  478. @override
  479. Widget build(BuildContext context) {
  480. final colors = context.colors;
  481. final theme = Theme.of(context);
  482. return LayoutBuilder(
  483. builder: (context, constraints) {
  484. final cover = math.max(
  485. 0.0,
  486. math.min(constraints.maxWidth, constraints.maxHeight - 46),
  487. );
  488. final placeholder = ColoredBox(
  489. color: colors.surfaceStrong,
  490. child: Center(
  491. child: AppIcon(
  492. 'assets/icons/headphones.svg',
  493. size: 28,
  494. color: colors.inkFaint,
  495. ),
  496. ),
  497. );
  498. return Pressable(
  499. onTap: onTap,
  500. semanticLabel: 'Open ${album.name}',
  501. pressedScale: 0.97,
  502. child: Column(
  503. crossAxisAlignment: CrossAxisAlignment.start,
  504. mainAxisSize: MainAxisSize.min,
  505. children: [
  506. Container(
  507. width: cover,
  508. height: cover,
  509. clipBehavior: Clip.antiAlias,
  510. decoration: BoxDecoration(
  511. borderRadius: BorderRadius.circular(15),
  512. border: Border.all(
  513. color: isCurrent ? colors.accent : colors.border,
  514. width: isCurrent ? 2 : 1,
  515. ),
  516. ),
  517. child: Stack(
  518. fit: StackFit.expand,
  519. children: [
  520. if (imageUrl == null)
  521. placeholder
  522. else
  523. Image.network(
  524. imageUrl!,
  525. headers: headers,
  526. fit: BoxFit.cover,
  527. errorBuilder: (_, _, _) => placeholder,
  528. ),
  529. if (isCurrent)
  530. Align(
  531. alignment: Alignment.bottomRight,
  532. child: Container(
  533. width: 26,
  534. height: 26,
  535. margin: const EdgeInsets.all(7),
  536. decoration: BoxDecoration(
  537. color: colors.canvas.withValues(alpha: 0.85),
  538. shape: BoxShape.circle,
  539. ),
  540. child: Center(
  541. child: AppIcon(
  542. isPlaying
  543. ? 'assets/icons/volume-2.svg'
  544. : 'assets/icons/play.svg',
  545. size: 13,
  546. color: colors.accent,
  547. ),
  548. ),
  549. ),
  550. ),
  551. ],
  552. ),
  553. ),
  554. const SizedBox(height: 6),
  555. Text(
  556. album.name,
  557. maxLines: 1,
  558. overflow: TextOverflow.ellipsis,
  559. style: theme.textTheme.bodyMedium?.copyWith(
  560. color: colors.ink,
  561. fontWeight: FontWeight.w600,
  562. ),
  563. ),
  564. Text(
  565. album.artist.isEmpty ? 'Unknown artist' : album.artist,
  566. maxLines: 1,
  567. overflow: TextOverflow.ellipsis,
  568. style: theme.textTheme.bodySmall?.copyWith(
  569. color: colors.inkMuted,
  570. ),
  571. ),
  572. ],
  573. ),
  574. );
  575. },
  576. );
  577. }
  578. }
  579. class _ShuffleButton extends StatelessWidget {
  580. const _ShuffleButton({required this.loading, required this.onTap});
  581. final bool loading;
  582. final VoidCallback onTap;
  583. @override
  584. Widget build(BuildContext context) {
  585. final colors = context.colors;
  586. return Pressable(
  587. onTap: loading ? null : onTap,
  588. semanticLabel: 'Shuffle random albums',
  589. pressedScale: 0.9,
  590. child: Container(
  591. width: 34,
  592. height: 34,
  593. decoration: BoxDecoration(
  594. color: colors.surfaceStrong,
  595. shape: BoxShape.circle,
  596. border: Border.all(color: colors.border),
  597. ),
  598. alignment: Alignment.center,
  599. child: loading
  600. ? SizedBox(
  601. width: 14,
  602. height: 14,
  603. child: CircularProgressIndicator(
  604. strokeWidth: 2,
  605. color: colors.accent,
  606. ),
  607. )
  608. : AppIcon(
  609. 'assets/icons/refresh-cw.svg',
  610. size: 16,
  611. color: colors.inkMuted,
  612. ),
  613. ),
  614. );
  615. }
  616. }
  617. class _DashboardError extends StatelessWidget {
  618. const _DashboardError({required this.message, required this.onRetry});
  619. final String message;
  620. final VoidCallback onRetry;
  621. @override
  622. Widget build(BuildContext context) {
  623. final colors = context.colors;
  624. final theme = Theme.of(context);
  625. return Center(
  626. child: Column(
  627. mainAxisSize: MainAxisSize.min,
  628. children: [
  629. Text(
  630. message,
  631. textAlign: TextAlign.center,
  632. style: theme.textTheme.bodyMedium?.copyWith(color: colors.inkMuted),
  633. ),
  634. const SizedBox(height: 10),
  635. TextButton(
  636. onPressed: onRetry,
  637. style: TextButton.styleFrom(foregroundColor: colors.accent),
  638. child: const Text('Retry'),
  639. ),
  640. ],
  641. ),
  642. );
  643. }
  644. }