jellyfin_settings_card.dart 17 KB

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