import 'dart:async'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../../core/theme/app_colors.dart'; import '../../core/widgets/app_icon.dart'; import '../../data/models/jellyfin_library.dart'; import '../../data/services/jellyfin_library_service.dart'; import '../jellyfin/jellyfin_playback_controller.dart'; import 'jellyfin_controller.dart'; import 'jellyfin_genre_dialog.dart'; import 'settings_widgets.dart'; /// Settings card for the optional Jellyfin music server. Quick Connect is the /// only sign-in path; the token itself never leaves secure storage. class JellyfinSettingsCard extends StatefulWidget { const JellyfinSettingsCard({super.key, this.playback}); final JellyfinPlaybackController? playback; @override State createState() => _JellyfinSettingsCardState(); } class _JellyfinSettingsCardState extends State with WidgetsBindingObserver { late final TextEditingController _url; final FocusNode _urlFocus = FocusNode(); @override void initState() { super.initState(); _url = TextEditingController( text: context.read().serverUrl, ); WidgetsBinding.instance.addObserver(this); } @override void dispose() { WidgetsBinding.instance.removeObserver(this); _url.dispose(); _urlFocus.dispose(); super.dispose(); } @override void didChangeAppLifecycleState(AppLifecycleState state) { if (state != AppLifecycleState.resumed || !mounted) return; context.read().onAppResumed(); } @override Widget build(BuildContext context) { final colors = context.colors; final theme = Theme.of(context); final jellyfin = context.watch(); if (!_urlFocus.hasFocus && _url.text != jellyfin.serverUrl) { _url.value = TextEditingValue( text: jellyfin.serverUrl, selection: TextSelection.collapsed(offset: jellyfin.serverUrl.length), ); } final statusColor = _statusColor(jellyfin, colors); final connected = jellyfin.connected; final showUrl = jellyfin.enabled && !connected && !jellyfin.pairing; return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Container( width: 42, height: 42, decoration: BoxDecoration( color: colors.accentSoft, borderRadius: BorderRadius.circular(14), ), alignment: Alignment.center, child: AppIcon( 'assets/icons/music.svg', size: 20, color: colors.accent, ), ), const SizedBox(width: 14), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text('Jellyfin', style: theme.textTheme.titleMedium), const SizedBox(height: 2), Row( children: [ Container( width: 7, height: 7, decoration: BoxDecoration( color: statusColor, shape: BoxShape.circle, ), ), const SizedBox(width: 7), Expanded( child: Text( jellyfin.statusLabel, maxLines: 1, overflow: TextOverflow.ellipsis, style: theme.textTheme.bodySmall?.copyWith( color: colors.inkMuted, ), ), ), ], ), ], ), ), const SizedBox(width: 8), SettingsSwitch( value: jellyfin.enabled, semanticsLabel: jellyfin.enabled ? 'Turn off Jellyfin' : 'Turn on Jellyfin', onChanged: (value) { unawaited(jellyfin.setEnabled(value)); }, ), ], ), if (jellyfin.enabled) ...[ const SizedBox(height: 12), Divider(color: colors.border, height: 24), if (jellyfin.pairing) _QuickConnectCode(controller: jellyfin) else ...[ if (showUrl) ...[ Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Expanded( child: TextField( controller: _url, focusNode: _urlFocus, keyboardType: TextInputType.url, textInputAction: TextInputAction.go, autocorrect: false, enabled: !jellyfin.busy, style: theme.textTheme.bodyMedium, onSubmitted: (value) { _urlFocus.unfocus(); unawaited(jellyfin.connect(value)); }, decoration: const InputDecoration( labelText: 'Server URL', hintText: 'https://jellyfin.example.com', isDense: true, ), ), ), const SizedBox(width: 10), Padding( padding: const EdgeInsets.only(top: 4), child: TextButton( onPressed: jellyfin.busy ? null : () { _urlFocus.unfocus(); unawaited(jellyfin.connect(_url.text)); }, style: TextButton.styleFrom( foregroundColor: colors.accent, ), child: Text(jellyfin.busy ? 'Connecting…' : 'Connect'), ), ), ], ), const SizedBox(height: 8), ], if (connected) ...[ Row( children: [ Expanded( child: Text( jellyfin.userName == null ? 'Signed in.' : 'Signed in as ${jellyfin.userName}.', style: theme.textTheme.bodySmall?.copyWith( color: colors.inkMuted, ), ), ), TextButton( onPressed: () { unawaited(jellyfin.removePairing()); }, style: TextButton.styleFrom(foregroundColor: colors.danger), child: const Text('Remove pairing'), ), ], ), ] else if (jellyfin.status == JellyfinStatus.offline) ...[ Row( children: [ Expanded( child: Text( 'Jellyfin is paused until the connection returns. ' 'It will retry automatically.', style: theme.textTheme.bodySmall?.copyWith( color: colors.inkMuted, ), ), ), TextButton( onPressed: () { unawaited(jellyfin.checkNow()); }, style: TextButton.styleFrom(foregroundColor: colors.accent), child: const Text('Retry now'), ), ], ), ] else if (jellyfin.busy) ...[ Text( 'Checking the server…', style: theme.textTheme.bodySmall?.copyWith( color: colors.inkMuted, ), ), ] else if (!jellyfin.needsRepair) ...[ Text( jellyfin.hasSession ? 'CarDash reuses the saved pairing. Connect to check it, ' 'or enter a different server URL.' : jellyfin.hasServerUrl ? 'Press Connect to pair this server with a Quick Connect ' 'code.' : 'Enter the address of your Jellyfin server to pair it ' 'with CarDash.', style: theme.textTheme.bodySmall?.copyWith( color: colors.inkMuted, ), ), ], ], if (jellyfin.error != null) ...[ const SizedBox(height: 8), Text( jellyfin.error!, style: theme.textTheme.bodySmall?.copyWith(color: colors.danger), ), ], const SizedBox(height: 4), Divider(color: colors.border, height: 24), _GenreRow( controller: jellyfin, onChoose: () => _openGenres(jellyfin), ), const SizedBox(height: 4), Divider(color: colors.border, height: 24), _AudioQualityRow(controller: jellyfin), if (jellyfin.configured && widget.playback != null) ...[ const SizedBox(height: 4), Divider(color: colors.border, height: 24), _PlaybackNotificationRow(playback: widget.playback!), ], ] else if (jellyfin.needsRepair) ...[ const SizedBox(height: 12), Text( '${jellyfin.error ?? 'The saved Jellyfin pairing is no longer valid.'} ' 'Pair again to keep using Jellyfin.', style: theme.textTheme.bodySmall?.copyWith(color: colors.danger), ), const SizedBox(height: 4), TextButton( onPressed: () { unawaited(jellyfin.pairAgain()); }, style: TextButton.styleFrom( foregroundColor: colors.accent, padding: EdgeInsets.zero, alignment: Alignment.centerLeft, ), child: const Text('Pair again'), ), ], const SizedBox(height: 8), Text( 'CarDash reads your music library and reports playback so Jellyfin ' 'can track recently played. The access token is stored on this ' 'device and can be removed at any time.', style: theme.textTheme.bodySmall?.copyWith(color: colors.inkMuted), ), ], ); } Future _openGenres(JellyfinController jellyfin) async { final library = context.read(); final result = await showJellyfinGenreDialog( context, library: library, selected: jellyfin.genreSelections, ); if (result == null || !mounted) return; await jellyfin.setGenreSelections(result); } Color _statusColor(JellyfinController jellyfin, AppColors colors) { if (jellyfin.connected) return colors.accent; if (jellyfin.error != null || jellyfin.status == JellyfinStatus.error || jellyfin.needsRepair) { return colors.danger; } if (jellyfin.status == JellyfinStatus.offline) return colors.warning; return colors.inkFaint; } } class _PlaybackNotificationRow extends StatefulWidget { const _PlaybackNotificationRow({required this.playback}); final JellyfinPlaybackController playback; @override State<_PlaybackNotificationRow> createState() => _PlaybackNotificationRowState(); } class _PlaybackNotificationRowState extends State<_PlaybackNotificationRow> with WidgetsBindingObserver { bool? _granted; @override void initState() { super.initState(); WidgetsBinding.instance.addObserver(this); unawaited(_check()); } @override void dispose() { WidgetsBinding.instance.removeObserver(this); super.dispose(); } @override void didChangeAppLifecycleState(AppLifecycleState state) { if (state == AppLifecycleState.resumed) { unawaited(_check()); } } Future _check() async { final granted = await widget.playback.notificationPermissionGranted(); if (mounted) setState(() => _granted = granted); } Future _request() async { final granted = await widget.playback.requestNotificationPermission(); if (mounted) setState(() => _granted = granted); } @override Widget build(BuildContext context) { final granted = _granted; if (granted == null) return const SizedBox.shrink(); final colors = context.colors; final theme = Theme.of(context); return Row( children: [ Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text('Playback notifications', style: theme.textTheme.bodyLarge), const SizedBox(height: 2), Text( granted ? 'Allowed. Android shows the media controls.' : 'Hidden while Android notifications are off.', maxLines: 2, overflow: TextOverflow.ellipsis, style: theme.textTheme.bodySmall?.copyWith( color: granted ? colors.inkMuted : colors.inkFaint, ), ), ], ), ), if (!granted) TextButton( onPressed: () => unawaited(_request()), style: TextButton.styleFrom(foregroundColor: colors.accent), child: const Text('Allow'), ), ], ); } } class _AudioQualityRow extends StatelessWidget { const _AudioQualityRow({required this.controller}); final JellyfinController controller; @override Widget build(BuildContext context) { final colors = context.colors; final theme = Theme.of(context); final quality = controller.audioQuality; return Row( children: [ Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text('Streaming quality', style: theme.textTheme.bodyLarge), const SizedBox(height: 2), Text( quality.isDirect ? 'Direct plays the original file. Applies to the next album.' : 'Transcodes to MP3 at ${quality.label}. ' 'Applies to the next album.', style: theme.textTheme.bodySmall?.copyWith( color: colors.inkMuted, ), ), ], ), ), const SizedBox(width: 8), DropdownButton( value: quality, isDense: true, borderRadius: BorderRadius.circular(12), dropdownColor: colors.surface, underline: const SizedBox.shrink(), icon: Icon( Icons.expand_more_rounded, size: 20, color: colors.inkMuted, ), style: theme.textTheme.bodyMedium?.copyWith(color: colors.ink), items: [ for (final option in JellyfinAudioQuality.values) DropdownMenuItem(value: option, child: Text(option.label)), ], onChanged: (value) { if (value == null) return; unawaited(controller.setAudioQuality(value)); }, ), ], ); } } class _GenreRow extends StatelessWidget { const _GenreRow({required this.controller, required this.onChoose}); final JellyfinController controller; final VoidCallback onChoose; @override Widget build(BuildContext context) { final colors = context.colors; final theme = Theme.of(context); final selections = controller.genreSelections; final value = selections.isEmpty ? (controller.configured ? 'Not chosen — the Random row is hidden' : 'Pair Jellyfin first') : selections.map((selection) => selection.label).join(', '); return Row( children: [ Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text('Random genres', style: theme.textTheme.bodyLarge), const SizedBox(height: 2), Text( value, maxLines: 1, overflow: TextOverflow.ellipsis, style: theme.textTheme.bodySmall?.copyWith( color: controller.configured ? colors.inkMuted : colors.inkFaint, ), ), ], ), ), const SizedBox(width: 8), TextButton( onPressed: controller.configured ? onChoose : null, style: TextButton.styleFrom(foregroundColor: colors.accent), child: const Text('Choose'), ), ], ); } } class _QuickConnectCode extends StatelessWidget { const _QuickConnectCode({required this.controller}); final JellyfinController controller; @override Widget build(BuildContext context) { final colors = context.colors; final theme = Theme.of(context); final code = controller.quickConnectCode ?? ''; return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Container( width: double.infinity, padding: const EdgeInsets.symmetric(horizontal: 22, vertical: 18), decoration: BoxDecoration( color: colors.accentSoft, borderRadius: BorderRadius.circular(18), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( 'Quick Connect code', style: theme.textTheme.labelMedium?.copyWith( color: colors.inkMuted, ), ), const SizedBox(height: 4), SelectableText( code, style: theme.textTheme.headlineMedium?.copyWith( color: colors.ink, fontWeight: FontWeight.w700, letterSpacing: 6, ), ), const SizedBox(height: 6), Text( 'Open Jellyfin, choose Quick Connect and enter this code. ' 'CarDash connects as soon as you approve it.', style: theme.textTheme.bodySmall?.copyWith( color: colors.inkMuted, ), ), ], ), ), const SizedBox(height: 2), TextButton( onPressed: controller.cancelPairing, style: TextButton.styleFrom( foregroundColor: colors.inkMuted, padding: EdgeInsets.zero, alignment: Alignment.centerLeft, ), child: const Text('Cancel'), ), ], ); } }