jellyfin_settings_card.dart 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577
  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 '../../data/models/jellyfin_library.dart';
  7. import '../../data/services/jellyfin_library_service.dart';
  8. import '../jellyfin/jellyfin_playback_controller.dart';
  9. import 'jellyfin_controller.dart';
  10. import 'jellyfin_genre_dialog.dart';
  11. import 'settings_widgets.dart';
  12. /// Settings card for the optional Jellyfin music server. Quick Connect is the
  13. /// only sign-in path; the token itself never leaves secure storage.
  14. class JellyfinSettingsCard extends StatefulWidget {
  15. const JellyfinSettingsCard({super.key, this.playback});
  16. final JellyfinPlaybackController? playback;
  17. @override
  18. State<JellyfinSettingsCard> createState() => _JellyfinSettingsCardState();
  19. }
  20. class _JellyfinSettingsCardState extends State<JellyfinSettingsCard>
  21. with WidgetsBindingObserver {
  22. late final TextEditingController _url;
  23. final FocusNode _urlFocus = FocusNode();
  24. @override
  25. void initState() {
  26. super.initState();
  27. _url = TextEditingController(
  28. text: context.read<JellyfinController>().serverUrl,
  29. );
  30. WidgetsBinding.instance.addObserver(this);
  31. }
  32. @override
  33. void dispose() {
  34. WidgetsBinding.instance.removeObserver(this);
  35. _url.dispose();
  36. _urlFocus.dispose();
  37. super.dispose();
  38. }
  39. @override
  40. void didChangeAppLifecycleState(AppLifecycleState state) {
  41. if (state != AppLifecycleState.resumed || !mounted) return;
  42. context.read<JellyfinController>().onAppResumed();
  43. }
  44. @override
  45. Widget build(BuildContext context) {
  46. final colors = context.colors;
  47. final theme = Theme.of(context);
  48. final jellyfin = context.watch<JellyfinController>();
  49. if (!_urlFocus.hasFocus && _url.text != jellyfin.serverUrl) {
  50. _url.value = TextEditingValue(
  51. text: jellyfin.serverUrl,
  52. selection: TextSelection.collapsed(offset: jellyfin.serverUrl.length),
  53. );
  54. }
  55. final statusColor = _statusColor(jellyfin, colors);
  56. final connected = jellyfin.connected;
  57. final showUrl = jellyfin.enabled && !connected && !jellyfin.pairing;
  58. return Column(
  59. crossAxisAlignment: CrossAxisAlignment.start,
  60. children: [
  61. Row(
  62. children: [
  63. Container(
  64. width: 42,
  65. height: 42,
  66. decoration: BoxDecoration(
  67. color: colors.accentSoft,
  68. borderRadius: BorderRadius.circular(14),
  69. ),
  70. alignment: Alignment.center,
  71. child: AppIcon(
  72. 'assets/icons/music.svg',
  73. size: 20,
  74. color: colors.accent,
  75. ),
  76. ),
  77. const SizedBox(width: 14),
  78. Expanded(
  79. child: Column(
  80. crossAxisAlignment: CrossAxisAlignment.start,
  81. children: [
  82. Text('Jellyfin', style: theme.textTheme.titleMedium),
  83. const SizedBox(height: 2),
  84. Row(
  85. children: [
  86. Container(
  87. width: 7,
  88. height: 7,
  89. decoration: BoxDecoration(
  90. color: statusColor,
  91. shape: BoxShape.circle,
  92. ),
  93. ),
  94. const SizedBox(width: 7),
  95. Expanded(
  96. child: Text(
  97. jellyfin.statusLabel,
  98. maxLines: 1,
  99. overflow: TextOverflow.ellipsis,
  100. style: theme.textTheme.bodySmall?.copyWith(
  101. color: colors.inkMuted,
  102. ),
  103. ),
  104. ),
  105. ],
  106. ),
  107. ],
  108. ),
  109. ),
  110. const SizedBox(width: 8),
  111. SettingsSwitch(
  112. value: jellyfin.enabled,
  113. semanticsLabel: jellyfin.enabled
  114. ? 'Turn off Jellyfin'
  115. : 'Turn on Jellyfin',
  116. onChanged: (value) {
  117. unawaited(jellyfin.setEnabled(value));
  118. },
  119. ),
  120. ],
  121. ),
  122. if (jellyfin.enabled) ...[
  123. const SizedBox(height: 12),
  124. Divider(color: colors.border, height: 24),
  125. if (jellyfin.pairing)
  126. _QuickConnectCode(controller: jellyfin)
  127. else ...[
  128. if (showUrl) ...[
  129. Row(
  130. crossAxisAlignment: CrossAxisAlignment.start,
  131. children: [
  132. Expanded(
  133. child: TextField(
  134. controller: _url,
  135. focusNode: _urlFocus,
  136. keyboardType: TextInputType.url,
  137. textInputAction: TextInputAction.go,
  138. autocorrect: false,
  139. enabled: !jellyfin.busy,
  140. style: theme.textTheme.bodyMedium,
  141. onSubmitted: (value) {
  142. _urlFocus.unfocus();
  143. unawaited(jellyfin.connect(value));
  144. },
  145. decoration: const InputDecoration(
  146. labelText: 'Server URL',
  147. hintText: 'https://jellyfin.example.com',
  148. isDense: true,
  149. ),
  150. ),
  151. ),
  152. const SizedBox(width: 10),
  153. Padding(
  154. padding: const EdgeInsets.only(top: 4),
  155. child: TextButton(
  156. onPressed: jellyfin.busy
  157. ? null
  158. : () {
  159. _urlFocus.unfocus();
  160. unawaited(jellyfin.connect(_url.text));
  161. },
  162. style: TextButton.styleFrom(
  163. foregroundColor: colors.accent,
  164. ),
  165. child: Text(jellyfin.busy ? 'Connecting…' : 'Connect'),
  166. ),
  167. ),
  168. ],
  169. ),
  170. const SizedBox(height: 8),
  171. ],
  172. if (connected) ...[
  173. Row(
  174. children: [
  175. Expanded(
  176. child: Text(
  177. jellyfin.userName == null
  178. ? 'Signed in.'
  179. : 'Signed in as ${jellyfin.userName}.',
  180. style: theme.textTheme.bodySmall?.copyWith(
  181. color: colors.inkMuted,
  182. ),
  183. ),
  184. ),
  185. TextButton(
  186. onPressed: () {
  187. unawaited(jellyfin.removePairing());
  188. },
  189. style: TextButton.styleFrom(foregroundColor: colors.danger),
  190. child: const Text('Remove pairing'),
  191. ),
  192. ],
  193. ),
  194. ] else if (jellyfin.status == JellyfinStatus.offline) ...[
  195. Row(
  196. children: [
  197. Expanded(
  198. child: Text(
  199. 'Jellyfin is paused until the connection returns. '
  200. 'It will retry automatically.',
  201. style: theme.textTheme.bodySmall?.copyWith(
  202. color: colors.inkMuted,
  203. ),
  204. ),
  205. ),
  206. TextButton(
  207. onPressed: () {
  208. unawaited(jellyfin.checkNow());
  209. },
  210. style: TextButton.styleFrom(foregroundColor: colors.accent),
  211. child: const Text('Retry now'),
  212. ),
  213. ],
  214. ),
  215. ] else if (jellyfin.busy) ...[
  216. Text(
  217. 'Checking the server…',
  218. style: theme.textTheme.bodySmall?.copyWith(
  219. color: colors.inkMuted,
  220. ),
  221. ),
  222. ] else if (!jellyfin.needsRepair) ...[
  223. Text(
  224. jellyfin.hasSession
  225. ? 'CarDash reuses the saved pairing. Connect to check it, '
  226. 'or enter a different server URL.'
  227. : jellyfin.hasServerUrl
  228. ? 'Press Connect to pair this server with a Quick Connect '
  229. 'code.'
  230. : 'Enter the address of your Jellyfin server to pair it '
  231. 'with CarDash.',
  232. style: theme.textTheme.bodySmall?.copyWith(
  233. color: colors.inkMuted,
  234. ),
  235. ),
  236. ],
  237. ],
  238. if (jellyfin.error != null) ...[
  239. const SizedBox(height: 8),
  240. Text(
  241. jellyfin.error!,
  242. style: theme.textTheme.bodySmall?.copyWith(color: colors.danger),
  243. ),
  244. ],
  245. const SizedBox(height: 4),
  246. Divider(color: colors.border, height: 24),
  247. _GenreRow(
  248. controller: jellyfin,
  249. onChoose: () => _openGenres(jellyfin),
  250. ),
  251. const SizedBox(height: 4),
  252. Divider(color: colors.border, height: 24),
  253. _AudioQualityRow(controller: jellyfin),
  254. if (jellyfin.configured && widget.playback != null) ...[
  255. const SizedBox(height: 4),
  256. Divider(color: colors.border, height: 24),
  257. _PlaybackNotificationRow(playback: widget.playback!),
  258. ],
  259. ] else if (jellyfin.needsRepair) ...[
  260. const SizedBox(height: 12),
  261. Text(
  262. '${jellyfin.error ?? 'The saved Jellyfin pairing is no longer valid.'} '
  263. 'Pair again to keep using Jellyfin.',
  264. style: theme.textTheme.bodySmall?.copyWith(color: colors.danger),
  265. ),
  266. const SizedBox(height: 4),
  267. TextButton(
  268. onPressed: () {
  269. unawaited(jellyfin.pairAgain());
  270. },
  271. style: TextButton.styleFrom(
  272. foregroundColor: colors.accent,
  273. padding: EdgeInsets.zero,
  274. alignment: Alignment.centerLeft,
  275. ),
  276. child: const Text('Pair again'),
  277. ),
  278. ],
  279. const SizedBox(height: 8),
  280. Text(
  281. 'CarDash reads your music library and reports playback so Jellyfin '
  282. 'can track recently played. The access token is stored on this '
  283. 'device and can be removed at any time.',
  284. style: theme.textTheme.bodySmall?.copyWith(color: colors.inkMuted),
  285. ),
  286. ],
  287. );
  288. }
  289. Future<void> _openGenres(JellyfinController jellyfin) async {
  290. final library = context.read<JellyfinLibraryService>();
  291. final result = await showJellyfinGenreDialog(
  292. context,
  293. library: library,
  294. selected: jellyfin.genreSelections,
  295. );
  296. if (result == null || !mounted) return;
  297. await jellyfin.setGenreSelections(result);
  298. }
  299. Color _statusColor(JellyfinController jellyfin, AppColors colors) {
  300. if (jellyfin.connected) return colors.accent;
  301. if (jellyfin.error != null ||
  302. jellyfin.status == JellyfinStatus.error ||
  303. jellyfin.needsRepair) {
  304. return colors.danger;
  305. }
  306. if (jellyfin.status == JellyfinStatus.offline) return colors.warning;
  307. return colors.inkFaint;
  308. }
  309. }
  310. class _PlaybackNotificationRow extends StatefulWidget {
  311. const _PlaybackNotificationRow({required this.playback});
  312. final JellyfinPlaybackController playback;
  313. @override
  314. State<_PlaybackNotificationRow> createState() =>
  315. _PlaybackNotificationRowState();
  316. }
  317. class _PlaybackNotificationRowState extends State<_PlaybackNotificationRow>
  318. with WidgetsBindingObserver {
  319. bool? _granted;
  320. @override
  321. void initState() {
  322. super.initState();
  323. WidgetsBinding.instance.addObserver(this);
  324. unawaited(_check());
  325. }
  326. @override
  327. void dispose() {
  328. WidgetsBinding.instance.removeObserver(this);
  329. super.dispose();
  330. }
  331. @override
  332. void didChangeAppLifecycleState(AppLifecycleState state) {
  333. if (state == AppLifecycleState.resumed) {
  334. unawaited(_check());
  335. }
  336. }
  337. Future<void> _check() async {
  338. final granted = await widget.playback.notificationPermissionGranted();
  339. if (mounted) setState(() => _granted = granted);
  340. }
  341. Future<void> _request() async {
  342. final granted = await widget.playback.requestNotificationPermission();
  343. if (mounted) setState(() => _granted = granted);
  344. }
  345. @override
  346. Widget build(BuildContext context) {
  347. final granted = _granted;
  348. if (granted == null) return const SizedBox.shrink();
  349. final colors = context.colors;
  350. final theme = Theme.of(context);
  351. return Row(
  352. children: [
  353. Expanded(
  354. child: Column(
  355. crossAxisAlignment: CrossAxisAlignment.start,
  356. children: [
  357. Text('Playback notifications', style: theme.textTheme.bodyLarge),
  358. const SizedBox(height: 2),
  359. Text(
  360. granted
  361. ? 'Allowed. Android shows the media controls.'
  362. : 'Hidden while Android notifications are off.',
  363. maxLines: 2,
  364. overflow: TextOverflow.ellipsis,
  365. style: theme.textTheme.bodySmall?.copyWith(
  366. color: granted ? colors.inkMuted : colors.inkFaint,
  367. ),
  368. ),
  369. ],
  370. ),
  371. ),
  372. if (!granted)
  373. TextButton(
  374. onPressed: () => unawaited(_request()),
  375. style: TextButton.styleFrom(foregroundColor: colors.accent),
  376. child: const Text('Allow'),
  377. ),
  378. ],
  379. );
  380. }
  381. }
  382. class _AudioQualityRow extends StatelessWidget {
  383. const _AudioQualityRow({required this.controller});
  384. final JellyfinController controller;
  385. @override
  386. Widget build(BuildContext context) {
  387. final colors = context.colors;
  388. final theme = Theme.of(context);
  389. final quality = controller.audioQuality;
  390. return Row(
  391. children: [
  392. Expanded(
  393. child: Column(
  394. crossAxisAlignment: CrossAxisAlignment.start,
  395. children: [
  396. Text('Streaming quality', style: theme.textTheme.bodyLarge),
  397. const SizedBox(height: 2),
  398. Text(
  399. quality.isDirect
  400. ? 'Direct plays the original file. Applies to the next album.'
  401. : 'Transcodes to MP3 at ${quality.label}. '
  402. 'Applies to the next album.',
  403. style: theme.textTheme.bodySmall?.copyWith(
  404. color: colors.inkMuted,
  405. ),
  406. ),
  407. ],
  408. ),
  409. ),
  410. const SizedBox(width: 8),
  411. DropdownButton<JellyfinAudioQuality>(
  412. value: quality,
  413. isDense: true,
  414. borderRadius: BorderRadius.circular(12),
  415. dropdownColor: colors.surface,
  416. underline: const SizedBox.shrink(),
  417. icon: Icon(
  418. Icons.expand_more_rounded,
  419. size: 20,
  420. color: colors.inkMuted,
  421. ),
  422. style: theme.textTheme.bodyMedium?.copyWith(color: colors.ink),
  423. items: [
  424. for (final option in JellyfinAudioQuality.values)
  425. DropdownMenuItem(value: option, child: Text(option.label)),
  426. ],
  427. onChanged: (value) {
  428. if (value == null) return;
  429. unawaited(controller.setAudioQuality(value));
  430. },
  431. ),
  432. ],
  433. );
  434. }
  435. }
  436. class _GenreRow extends StatelessWidget {
  437. const _GenreRow({required this.controller, required this.onChoose});
  438. final JellyfinController controller;
  439. final VoidCallback onChoose;
  440. @override
  441. Widget build(BuildContext context) {
  442. final colors = context.colors;
  443. final theme = Theme.of(context);
  444. final selections = controller.genreSelections;
  445. final value = selections.isEmpty
  446. ? (controller.configured
  447. ? 'Not chosen — the Random row is hidden'
  448. : 'Pair Jellyfin first')
  449. : selections.map((selection) => selection.label).join(', ');
  450. return Row(
  451. children: [
  452. Expanded(
  453. child: Column(
  454. crossAxisAlignment: CrossAxisAlignment.start,
  455. children: [
  456. Text('Random genres', style: theme.textTheme.bodyLarge),
  457. const SizedBox(height: 2),
  458. Text(
  459. value,
  460. maxLines: 1,
  461. overflow: TextOverflow.ellipsis,
  462. style: theme.textTheme.bodySmall?.copyWith(
  463. color: controller.configured
  464. ? colors.inkMuted
  465. : colors.inkFaint,
  466. ),
  467. ),
  468. ],
  469. ),
  470. ),
  471. const SizedBox(width: 8),
  472. TextButton(
  473. onPressed: controller.configured ? onChoose : null,
  474. style: TextButton.styleFrom(foregroundColor: colors.accent),
  475. child: const Text('Choose'),
  476. ),
  477. ],
  478. );
  479. }
  480. }
  481. class _QuickConnectCode extends StatelessWidget {
  482. const _QuickConnectCode({required this.controller});
  483. final JellyfinController controller;
  484. @override
  485. Widget build(BuildContext context) {
  486. final colors = context.colors;
  487. final theme = Theme.of(context);
  488. final code = controller.quickConnectCode ?? '';
  489. return Column(
  490. crossAxisAlignment: CrossAxisAlignment.start,
  491. children: [
  492. Container(
  493. width: double.infinity,
  494. padding: const EdgeInsets.symmetric(horizontal: 22, vertical: 18),
  495. decoration: BoxDecoration(
  496. color: colors.accentSoft,
  497. borderRadius: BorderRadius.circular(18),
  498. ),
  499. child: Column(
  500. crossAxisAlignment: CrossAxisAlignment.start,
  501. children: [
  502. Text(
  503. 'Quick Connect code',
  504. style: theme.textTheme.labelMedium?.copyWith(
  505. color: colors.inkMuted,
  506. ),
  507. ),
  508. const SizedBox(height: 4),
  509. SelectableText(
  510. code,
  511. style: theme.textTheme.headlineMedium?.copyWith(
  512. color: colors.ink,
  513. fontWeight: FontWeight.w700,
  514. letterSpacing: 6,
  515. ),
  516. ),
  517. const SizedBox(height: 6),
  518. Text(
  519. 'Open Jellyfin, choose Quick Connect and enter this code. '
  520. 'CarDash connects as soon as you approve it.',
  521. style: theme.textTheme.bodySmall?.copyWith(
  522. color: colors.inkMuted,
  523. ),
  524. ),
  525. ],
  526. ),
  527. ),
  528. const SizedBox(height: 2),
  529. TextButton(
  530. onPressed: controller.cancelPairing,
  531. style: TextButton.styleFrom(
  532. foregroundColor: colors.inkMuted,
  533. padding: EdgeInsets.zero,
  534. alignment: Alignment.centerLeft,
  535. ),
  536. child: const Text('Cancel'),
  537. ),
  538. ],
  539. );
  540. }
  541. }