jellyfin_settings_card.dart 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357
  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.hasSession
  220. ? 'CarDash reuses the saved pairing. Connect to check it, '
  221. 'or enter a different server URL.'
  222. : jellyfin.hasServerUrl
  223. ? 'Press Connect to pair this server with a Quick Connect '
  224. 'code.'
  225. : 'Enter the address of your Jellyfin server to pair it '
  226. 'with CarDash.',
  227. style: theme.textTheme.bodySmall?.copyWith(
  228. color: colors.inkMuted,
  229. ),
  230. ),
  231. ],
  232. ],
  233. if (jellyfin.error != null) ...[
  234. const SizedBox(height: 8),
  235. Text(
  236. jellyfin.error!,
  237. style: theme.textTheme.bodySmall?.copyWith(color: colors.danger),
  238. ),
  239. ],
  240. ] else if (jellyfin.needsRepair) ...[
  241. const SizedBox(height: 12),
  242. Text(
  243. '${jellyfin.error ?? 'The saved Jellyfin pairing is no longer valid.'} '
  244. 'Pair again to keep using Jellyfin.',
  245. style: theme.textTheme.bodySmall?.copyWith(color: colors.danger),
  246. ),
  247. const SizedBox(height: 4),
  248. TextButton(
  249. onPressed: () {
  250. unawaited(jellyfin.pairAgain());
  251. },
  252. style: TextButton.styleFrom(
  253. foregroundColor: colors.accent,
  254. padding: EdgeInsets.zero,
  255. alignment: Alignment.centerLeft,
  256. ),
  257. child: const Text('Pair again'),
  258. ),
  259. ],
  260. const SizedBox(height: 8),
  261. Text(
  262. 'Only the music library is read. The access token is stored on this '
  263. 'device and can be removed at any time.',
  264. style: theme.textTheme.bodySmall?.copyWith(color: colors.inkMuted),
  265. ),
  266. ],
  267. );
  268. }
  269. Color _statusColor(JellyfinController jellyfin, AppColors colors) {
  270. if (jellyfin.connected) return colors.accent;
  271. if (jellyfin.error != null ||
  272. jellyfin.status == JellyfinStatus.error ||
  273. jellyfin.needsRepair) {
  274. return colors.danger;
  275. }
  276. if (jellyfin.status == JellyfinStatus.offline) return colors.warning;
  277. return colors.inkFaint;
  278. }
  279. }
  280. class _QuickConnectCode extends StatelessWidget {
  281. const _QuickConnectCode({required this.controller});
  282. final JellyfinController controller;
  283. @override
  284. Widget build(BuildContext context) {
  285. final colors = context.colors;
  286. final theme = Theme.of(context);
  287. final code = controller.quickConnectCode ?? '';
  288. return Column(
  289. crossAxisAlignment: CrossAxisAlignment.start,
  290. children: [
  291. Container(
  292. width: double.infinity,
  293. padding: const EdgeInsets.symmetric(horizontal: 22, vertical: 18),
  294. decoration: BoxDecoration(
  295. color: colors.accentSoft,
  296. borderRadius: BorderRadius.circular(18),
  297. ),
  298. child: Column(
  299. crossAxisAlignment: CrossAxisAlignment.start,
  300. children: [
  301. Text(
  302. 'Quick Connect code',
  303. style: theme.textTheme.labelMedium?.copyWith(
  304. color: colors.inkMuted,
  305. ),
  306. ),
  307. const SizedBox(height: 4),
  308. SelectableText(
  309. code,
  310. style: theme.textTheme.headlineMedium?.copyWith(
  311. color: colors.ink,
  312. fontWeight: FontWeight.w700,
  313. letterSpacing: 6,
  314. ),
  315. ),
  316. const SizedBox(height: 6),
  317. Text(
  318. 'Open Jellyfin, choose Quick Connect and enter this code. '
  319. 'CarDash connects as soon as you approve it.',
  320. style: theme.textTheme.bodySmall?.copyWith(
  321. color: colors.inkMuted,
  322. ),
  323. ),
  324. ],
  325. ),
  326. ),
  327. const SizedBox(height: 2),
  328. TextButton(
  329. onPressed: controller.cancelPairing,
  330. style: TextButton.styleFrom(
  331. foregroundColor: colors.inkMuted,
  332. padding: EdgeInsets.zero,
  333. alignment: Alignment.centerLeft,
  334. ),
  335. child: const Text('Cancel'),
  336. ),
  337. ],
  338. );
  339. }
  340. }