jellyfin_settings_card.dart 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354
  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 'jellyfin_controller.dart';
  7. import 'settings_widgets.dart';
  8. /// Settings card for the optional Jellyfin music server. Quick Connect is the
  9. /// only sign-in path; the token itself never leaves secure storage.
  10. class JellyfinSettingsCard extends StatefulWidget {
  11. const JellyfinSettingsCard({super.key});
  12. @override
  13. State<JellyfinSettingsCard> createState() => _JellyfinSettingsCardState();
  14. }
  15. class _JellyfinSettingsCardState extends State<JellyfinSettingsCard>
  16. with WidgetsBindingObserver {
  17. late final TextEditingController _url;
  18. final FocusNode _urlFocus = FocusNode();
  19. @override
  20. void initState() {
  21. super.initState();
  22. _url = TextEditingController(
  23. text: context.read<JellyfinController>().serverUrl,
  24. );
  25. WidgetsBinding.instance.addObserver(this);
  26. }
  27. @override
  28. void dispose() {
  29. WidgetsBinding.instance.removeObserver(this);
  30. _url.dispose();
  31. _urlFocus.dispose();
  32. super.dispose();
  33. }
  34. @override
  35. void didChangeAppLifecycleState(AppLifecycleState state) {
  36. if (state != AppLifecycleState.resumed || !mounted) return;
  37. context.read<JellyfinController>().onAppResumed();
  38. }
  39. @override
  40. Widget build(BuildContext context) {
  41. final colors = context.colors;
  42. final theme = Theme.of(context);
  43. final jellyfin = context.watch<JellyfinController>();
  44. if (!_urlFocus.hasFocus && _url.text != jellyfin.serverUrl) {
  45. _url.value = TextEditingValue(
  46. text: jellyfin.serverUrl,
  47. selection: TextSelection.collapsed(offset: jellyfin.serverUrl.length),
  48. );
  49. }
  50. final statusColor = _statusColor(jellyfin, colors);
  51. final connected = jellyfin.connected;
  52. final showUrl = jellyfin.enabled && !connected && !jellyfin.pairing;
  53. return Column(
  54. crossAxisAlignment: CrossAxisAlignment.start,
  55. children: [
  56. Row(
  57. children: [
  58. Container(
  59. width: 42,
  60. height: 42,
  61. decoration: BoxDecoration(
  62. color: colors.accentSoft,
  63. borderRadius: BorderRadius.circular(14),
  64. ),
  65. alignment: Alignment.center,
  66. child: AppIcon(
  67. 'assets/icons/music.svg',
  68. size: 20,
  69. color: colors.accent,
  70. ),
  71. ),
  72. const SizedBox(width: 14),
  73. Expanded(
  74. child: Column(
  75. crossAxisAlignment: CrossAxisAlignment.start,
  76. children: [
  77. Text('Jellyfin', style: theme.textTheme.titleMedium),
  78. const SizedBox(height: 2),
  79. Row(
  80. children: [
  81. Container(
  82. width: 7,
  83. height: 7,
  84. decoration: BoxDecoration(
  85. color: statusColor,
  86. shape: BoxShape.circle,
  87. ),
  88. ),
  89. const SizedBox(width: 7),
  90. Expanded(
  91. child: Text(
  92. jellyfin.statusLabel,
  93. maxLines: 1,
  94. overflow: TextOverflow.ellipsis,
  95. style: theme.textTheme.bodySmall?.copyWith(
  96. color: colors.inkMuted,
  97. ),
  98. ),
  99. ),
  100. ],
  101. ),
  102. ],
  103. ),
  104. ),
  105. const SizedBox(width: 8),
  106. SettingsSwitch(
  107. value: jellyfin.enabled,
  108. semanticsLabel: jellyfin.enabled
  109. ? 'Turn off Jellyfin'
  110. : 'Turn on Jellyfin',
  111. onChanged: (value) {
  112. unawaited(jellyfin.setEnabled(value));
  113. },
  114. ),
  115. ],
  116. ),
  117. if (jellyfin.enabled) ...[
  118. const SizedBox(height: 12),
  119. Divider(color: colors.border, height: 24),
  120. if (jellyfin.pairing)
  121. _QuickConnectCode(controller: jellyfin)
  122. else ...[
  123. if (showUrl) ...[
  124. Row(
  125. crossAxisAlignment: CrossAxisAlignment.start,
  126. children: [
  127. Expanded(
  128. child: TextField(
  129. controller: _url,
  130. focusNode: _urlFocus,
  131. keyboardType: TextInputType.url,
  132. textInputAction: TextInputAction.go,
  133. autocorrect: false,
  134. enabled: !jellyfin.busy,
  135. style: theme.textTheme.bodyMedium,
  136. onSubmitted: (value) {
  137. _urlFocus.unfocus();
  138. unawaited(jellyfin.connect(value));
  139. },
  140. decoration: const InputDecoration(
  141. labelText: 'Server URL',
  142. hintText: 'https://jellyfin.example.com',
  143. isDense: true,
  144. ),
  145. ),
  146. ),
  147. const SizedBox(width: 10),
  148. Padding(
  149. padding: const EdgeInsets.only(top: 4),
  150. child: TextButton(
  151. onPressed: jellyfin.busy
  152. ? null
  153. : () {
  154. _urlFocus.unfocus();
  155. unawaited(jellyfin.connect(_url.text));
  156. },
  157. style: TextButton.styleFrom(
  158. foregroundColor: colors.accent,
  159. ),
  160. child: Text(jellyfin.busy ? 'Connecting…' : 'Connect'),
  161. ),
  162. ),
  163. ],
  164. ),
  165. const SizedBox(height: 8),
  166. ],
  167. if (connected) ...[
  168. Row(
  169. children: [
  170. Expanded(
  171. child: Text(
  172. jellyfin.userName == null
  173. ? 'Signed in.'
  174. : 'Signed in as ${jellyfin.userName}.',
  175. style: theme.textTheme.bodySmall?.copyWith(
  176. color: colors.inkMuted,
  177. ),
  178. ),
  179. ),
  180. TextButton(
  181. onPressed: () {
  182. unawaited(jellyfin.removePairing());
  183. },
  184. style: TextButton.styleFrom(foregroundColor: colors.danger),
  185. child: const Text('Remove pairing'),
  186. ),
  187. ],
  188. ),
  189. ] else if (jellyfin.status == JellyfinStatus.offline) ...[
  190. Row(
  191. children: [
  192. Expanded(
  193. child: Text(
  194. 'Jellyfin is paused until the connection returns. '
  195. 'It will retry automatically.',
  196. style: theme.textTheme.bodySmall?.copyWith(
  197. color: colors.inkMuted,
  198. ),
  199. ),
  200. ),
  201. TextButton(
  202. onPressed: () {
  203. unawaited(jellyfin.checkNow());
  204. },
  205. style: TextButton.styleFrom(foregroundColor: colors.accent),
  206. child: const Text('Retry now'),
  207. ),
  208. ],
  209. ),
  210. ] else if (jellyfin.busy) ...[
  211. Text(
  212. 'Checking the server…',
  213. style: theme.textTheme.bodySmall?.copyWith(
  214. color: colors.inkMuted,
  215. ),
  216. ),
  217. ] else if (!jellyfin.needsRepair) ...[
  218. Text(
  219. jellyfin.hasServerUrl
  220. ? 'CarDash reuses the saved pairing. Connect to check it, '
  221. 'or enter a different server URL.'
  222. : 'Enter the address of your Jellyfin server to pair it '
  223. 'with CarDash.',
  224. style: theme.textTheme.bodySmall?.copyWith(
  225. color: colors.inkMuted,
  226. ),
  227. ),
  228. ],
  229. ],
  230. if (jellyfin.error != null) ...[
  231. const SizedBox(height: 8),
  232. Text(
  233. jellyfin.error!,
  234. style: theme.textTheme.bodySmall?.copyWith(color: colors.danger),
  235. ),
  236. ],
  237. ] else if (jellyfin.needsRepair) ...[
  238. const SizedBox(height: 12),
  239. Text(
  240. '${jellyfin.error ?? 'The saved Jellyfin pairing is no longer valid.'} '
  241. 'Pair again to keep using Jellyfin.',
  242. style: theme.textTheme.bodySmall?.copyWith(color: colors.danger),
  243. ),
  244. const SizedBox(height: 4),
  245. TextButton(
  246. onPressed: () {
  247. unawaited(jellyfin.pairAgain());
  248. },
  249. style: TextButton.styleFrom(
  250. foregroundColor: colors.accent,
  251. padding: EdgeInsets.zero,
  252. alignment: Alignment.centerLeft,
  253. ),
  254. child: const Text('Pair again'),
  255. ),
  256. ],
  257. const SizedBox(height: 8),
  258. Text(
  259. 'Only the music library is read. The access token is stored on this '
  260. 'device and can be removed at any time.',
  261. style: theme.textTheme.bodySmall?.copyWith(color: colors.inkMuted),
  262. ),
  263. ],
  264. );
  265. }
  266. Color _statusColor(JellyfinController jellyfin, AppColors colors) {
  267. if (jellyfin.connected) return colors.accent;
  268. if (jellyfin.error != null ||
  269. jellyfin.status == JellyfinStatus.error ||
  270. jellyfin.needsRepair) {
  271. return colors.danger;
  272. }
  273. if (jellyfin.status == JellyfinStatus.offline) return colors.warning;
  274. return colors.inkFaint;
  275. }
  276. }
  277. class _QuickConnectCode extends StatelessWidget {
  278. const _QuickConnectCode({required this.controller});
  279. final JellyfinController controller;
  280. @override
  281. Widget build(BuildContext context) {
  282. final colors = context.colors;
  283. final theme = Theme.of(context);
  284. final code = controller.quickConnectCode ?? '';
  285. return Column(
  286. crossAxisAlignment: CrossAxisAlignment.start,
  287. children: [
  288. Container(
  289. width: double.infinity,
  290. padding: const EdgeInsets.symmetric(horizontal: 22, vertical: 18),
  291. decoration: BoxDecoration(
  292. color: colors.accentSoft,
  293. borderRadius: BorderRadius.circular(18),
  294. ),
  295. child: Column(
  296. crossAxisAlignment: CrossAxisAlignment.start,
  297. children: [
  298. Text(
  299. 'Quick Connect code',
  300. style: theme.textTheme.labelMedium?.copyWith(
  301. color: colors.inkMuted,
  302. ),
  303. ),
  304. const SizedBox(height: 4),
  305. SelectableText(
  306. code,
  307. style: theme.textTheme.headlineMedium?.copyWith(
  308. color: colors.ink,
  309. fontWeight: FontWeight.w700,
  310. letterSpacing: 6,
  311. ),
  312. ),
  313. const SizedBox(height: 6),
  314. Text(
  315. 'Open Jellyfin, choose Quick Connect and enter this code. '
  316. 'CarDash connects as soon as you approve it.',
  317. style: theme.textTheme.bodySmall?.copyWith(
  318. color: colors.inkMuted,
  319. ),
  320. ),
  321. ],
  322. ),
  323. ),
  324. const SizedBox(height: 2),
  325. TextButton(
  326. onPressed: controller.cancelPairing,
  327. style: TextButton.styleFrom(
  328. foregroundColor: colors.inkMuted,
  329. padding: EdgeInsets.zero,
  330. alignment: Alignment.centerLeft,
  331. ),
  332. child: const Text('Cancel'),
  333. ),
  334. ],
  335. );
  336. }
  337. }