import 'dart:async'; import 'package:flutter/foundation.dart'; import '../../data/models/jellyfin_models.dart'; import '../../data/repositories/settings_repository.dart'; import '../../data/services/jellyfin_service.dart'; import '../../data/services/secure_store.dart'; enum JellyfinStatus { disabled, needsSetup, connecting, pairing, connected, offline, error, } /// Owns the optional Jellyfin session. Quick Connect is the only sign-in path: /// the user approves a code in Jellyfin and CarDash keeps the returned token in /// secure storage. A rejected token disables the integration and clears it so /// the user has to pair again; an unreachable server is treated as a temporary /// outage and retried with backoff. class JellyfinController extends ChangeNotifier { JellyfinController( this._settings, this._gateway, this._secureStore, { this.pollInterval = const Duration(seconds: 3), this.pairingLifetime = const Duration(minutes: 10), this.healthInterval = const Duration(minutes: 1), List? retryDelays, }) : retryDelays = retryDelays ?? const [ Duration(seconds: 5), Duration(seconds: 10), Duration(seconds: 20), Duration(seconds: 30), Duration(seconds: 60), ]; static const tokenKey = 'jellyfin.accessToken'; final SettingsRepository _settings; final JellyfinGateway _gateway; final SecureStore _secureStore; final Duration pollInterval; final Duration pairingLifetime; final Duration healthInterval; final List retryDelays; bool _enabled = false; bool _needsRepair = false; bool _busy = false; bool _polling = false; bool _started = false; bool _disposed = false; JellyfinStatus _status = JellyfinStatus.disabled; String _serverUrl = ''; String? _serverName; String? _userName; String? _error; String? _code; String? _secret; String? _token; String _deviceId = ''; int _generation = 0; int _retryAttempt = 0; Timer? _pollTimer; Timer? _pairingTimer; Timer? _healthTimer; Timer? _retryTimer; bool get enabled => _enabled; bool get connected => _status == JellyfinStatus.connected; bool get busy => _busy; bool get pairing => _status == JellyfinStatus.pairing; bool get needsRepair => _needsRepair; bool get hasServerUrl => _serverUrl.isNotEmpty; bool get hasSession => _token != null; JellyfinStatus get status => _status; String get serverUrl => _serverUrl; String? get serverName => _serverName; String? get userName => _userName; String? get error => _error; String? get quickConnectCode => _code; String get statusLabel { if (!_enabled) { return _needsRepair ? 'Pairing needed' : 'Off'; } switch (_status) { case JellyfinStatus.disabled: return 'Off'; case JellyfinStatus.needsSetup: return 'Not connected'; case JellyfinStatus.connecting: return 'Connecting…'; case JellyfinStatus.pairing: return 'Waiting for approval'; case JellyfinStatus.connected: return _serverName == null ? 'Connected' : 'Connected to $_serverName'; case JellyfinStatus.offline: return 'No internet — retrying'; case JellyfinStatus.error: return 'Not connected'; } } /// Loads persisted state. Does not touch the network; call [start] once the /// app is running so a cold start is never delayed by Jellyfin. Future initialize() async { _enabled = _settings.jellyfinEnabled; _serverUrl = _settings.jellyfinServerUrl; _serverName = _settings.jellyfinServerName; _userName = _settings.jellyfinUserName; _deviceId = await _settings.ensureJellyfinDeviceId(); _token = await _secureStore.read(tokenKey); if (!_enabled) { _status = JellyfinStatus.disabled; return; } if (_serverUrl.isEmpty || _token == null) { _status = JellyfinStatus.needsSetup; return; } _status = JellyfinStatus.connecting; } /// Starts the first health check. Safe to call more than once. void start() { if (_started || _disposed) return; _started = true; if (_enabled && _token != null && _serverUrl.isNotEmpty) { unawaited(_validate()); } } Future setEnabled(bool value) async { if (_disposed || value == _enabled) return; if (!value) { _generation++; _cancelTimers(); _enabled = false; _needsRepair = false; _busy = false; _code = null; _secret = null; _error = null; _status = JellyfinStatus.disabled; await _settings.setJellyfinEnabled(false); _notify(); return; } _enabled = true; _needsRepair = false; _error = null; await _settings.setJellyfinEnabled(true); if (_serverUrl.isEmpty) { _status = JellyfinStatus.needsSetup; _notify(); return; } if (_token != null) { await _validate(); } else { _status = JellyfinStatus.needsSetup; _notify(); } } /// Saves the URL and connects. With a saved token the session is reused; /// otherwise a Quick Connect code is requested. Future connect(String rawUrl) async { final normalized = displayJellyfinUrl(rawUrl); if (normalized == null) { _error = 'Enter a valid http or https server URL.'; _status = _enabled ? JellyfinStatus.needsSetup : JellyfinStatus.disabled; _notify(); return; } final changed = _serverUrl.isNotEmpty && _serverUrl != normalized; _serverUrl = normalized; _error = null; await _settings.setJellyfinServerUrl(normalized); if (!_enabled) { _enabled = true; await _settings.setJellyfinEnabled(true); } if (changed) { await _clearSavedSession(); _serverName = null; _userName = null; } if (_token != null) { await _validate(); } else { await _beginQuickConnect(); } } /// Re-enables Jellyfin and starts pairing again after the token was rejected. Future pairAgain() async { _needsRepair = false; _error = null; if (!_enabled) { _enabled = true; await _settings.setJellyfinEnabled(true); } if (_serverUrl.isEmpty) { _status = JellyfinStatus.needsSetup; _notify(); return; } await _beginQuickConnect(); } /// Forgets the server entirely. The URL and device id stay for convenience. Future removePairing() async { _generation++; _cancelTimers(); await _clearSavedSession(); _enabled = false; _busy = false; _needsRepair = false; _error = null; _code = null; _secret = null; _serverName = null; _userName = null; _status = JellyfinStatus.disabled; await _settings.setJellyfinEnabled(false); _notify(); } /// Stops the current Quick Connect attempt without disabling the section. void cancelPairing() { if (_status != JellyfinStatus.pairing) return; _generation++; _cancelTimers(); _code = null; _secret = null; _error = null; _busy = false; _status = JellyfinStatus.needsSetup; _notify(); } /// Manual retry used by the card and when the app returns to the foreground. Future checkNow() async { if (_disposed || !_enabled) return; if (_status == JellyfinStatus.pairing) { await _poll(_generation); return; } if (_token != null) { await _validate(); } else if (_serverUrl.isNotEmpty) { await _beginQuickConnect(); } } void onAppResumed() { if (_disposed || !_enabled) return; if (_status == JellyfinStatus.connected || _status == JellyfinStatus.connecting) { return; } unawaited(checkNow()); } Future _beginQuickConnect() async { final base = normalizeJellyfinUrl(_serverUrl); if (base == null) { _error = 'Enter a valid http or https server URL.'; _status = JellyfinStatus.needsSetup; _notify(); return; } final generation = ++_generation; _cancelTimers(); _busy = true; _code = null; _secret = null; _error = null; _status = JellyfinStatus.connecting; _notify(); try { final info = await _gateway.serverInfo(base); if (_stale(generation)) return; _serverName = info.name; await _settings.setJellyfinServerName(info.name); final ticket = await _gateway.startQuickConnect( base, deviceId: _deviceId, ); if (_stale(generation)) return; _code = ticket.code; _secret = ticket.secret; _busy = false; _status = JellyfinStatus.pairing; _notify(); _startPairingTimers(generation); } on JellyfinUnreachable catch (exception) { if (_stale(generation)) return; _goOffline(exception.message, _beginQuickConnect); } on JellyfinException catch (exception) { if (_stale(generation)) return; _failPairing(exception.message); } } void _startPairingTimers(int generation) { _pollTimer = Timer.periodic( pollInterval, (_) => unawaited(_poll(generation)), ); _pairingTimer = Timer(pairingLifetime, () { if (_stale(generation)) return; _failPairing('The Quick Connect code expired. Try again.'); }); } Future _poll(int generation) async { if (_disposed || _polling || _stale(generation)) return; final base = normalizeJellyfinUrl(_serverUrl); final secret = _secret; if (base == null || secret == null) return; _polling = true; try { final approved = await _gateway.isQuickConnectApproved(base, secret); if (_stale(generation)) return; if (!approved) { if (_error != null) { _error = null; _notify(); } return; } _pollTimer?.cancel(); _pairingTimer?.cancel(); _busy = true; _status = JellyfinStatus.connecting; _notify(); final session = await _gateway.authenticateQuickConnect( base, secret, deviceId: _deviceId, ); if (_stale(generation)) return; _token = session.accessToken; _userName = session.userName; await _secureStore.write(tokenKey, session.accessToken); await _settings.setJellyfinSession( userId: session.userId, userName: session.userName, ); _code = null; _secret = null; _busy = false; _error = null; _status = JellyfinStatus.connected; _retryAttempt = 0; _notify(); _startHealthTimer(); } on JellyfinUnreachable { // Keep the code alive; the next poll retries once the network is back. if (_stale(generation)) return; _error = 'No connection — waiting to retry.'; _notify(); } on JellyfinAuthException catch (exception) { if (_stale(generation)) return; _failPairing(exception.message); } on JellyfinException catch (exception) { if (_stale(generation)) return; _failPairing(exception.message); } finally { _polling = false; } } Future _validate({bool silent = false}) async { final base = normalizeJellyfinUrl(_serverUrl); final token = _token; if (base == null) { _status = JellyfinStatus.needsSetup; _notify(); return; } if (token == null) { _status = JellyfinStatus.needsSetup; _notify(); return; } final generation = ++_generation; _cancelTimers(); if (!silent) { _busy = true; _status = JellyfinStatus.connecting; } _error = null; if (!silent) _notify(); try { final info = await _gateway.serverInfo(base); if (_stale(generation)) return; final user = await _gateway.currentUser(base, token); if (_stale(generation)) return; final changed = _serverName != info.name || _userName != user.name || _status != JellyfinStatus.connected; _serverName = info.name; _userName = user.name; _busy = false; _status = JellyfinStatus.connected; _error = null; _retryAttempt = 0; unawaited(_settings.setJellyfinServerName(info.name)); unawaited(_settings.setJellyfinUserName(user.name)); if (changed || !silent) _notify(); _startHealthTimer(); } on JellyfinUnreachable catch (exception) { if (_stale(generation)) return; _goOffline(exception.message, _validate); } on JellyfinAuthException { if (_stale(generation)) return; await _invalidateSession('The saved pairing is no longer valid.'); } on JellyfinException catch (exception) { if (_stale(generation)) return; await _invalidateSession(exception.message); } } void _goOffline(String message, Future Function() resume) { _cancelTimers(); _busy = false; _status = JellyfinStatus.offline; _error = message; _notify(); final index = _retryAttempt < retryDelays.length ? _retryAttempt : retryDelays.length - 1; _retryAttempt++; _retryTimer = Timer(retryDelays[index], () { _retryTimer = null; if (_disposed || !_enabled) return; unawaited(resume()); }); } void _failPairing(String message) { _cancelTimers(); _code = null; _secret = null; _busy = false; _status = JellyfinStatus.error; _error = message; _notify(); } /// The token was rejected: turn Jellyfin off, clear the token and profile, /// and leave the card asking for a fresh Quick Connect. Future _invalidateSession(String message) async { _generation++; _cancelTimers(); await _clearSavedSession(); _enabled = false; _needsRepair = true; _busy = false; _code = null; _secret = null; _serverName = null; _userName = null; _status = JellyfinStatus.error; _error = message; await _settings.setJellyfinEnabled(false); _notify(); } Future _clearSavedSession() async { _token = null; await _secureStore.delete(tokenKey); await _settings.clearJellyfinSession(); } void _startHealthTimer() { _healthTimer?.cancel(); _healthTimer = Timer.periodic(healthInterval, (_) { if (_disposed || !_enabled || _token == null) return; unawaited(_validate(silent: true)); }); } void _cancelTimers() { _pollTimer?.cancel(); _pollTimer = null; _pairingTimer?.cancel(); _pairingTimer = null; _healthTimer?.cancel(); _healthTimer = null; _retryTimer?.cancel(); _retryTimer = null; } bool _stale(int generation) { return _disposed || generation != _generation || !_enabled; } void _notify() { if (!_disposed) notifyListeners(); } @override void dispose() { _disposed = true; _cancelTimers(); _gateway.close(); super.dispose(); } }