jellyfin_dashboard_screen.dart 21 KB

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