jellyfin_dashboard_screen.dart 17 KB

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