| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541 |
- 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<Duration>? 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<Duration> 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;
- 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<void> 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<void> 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<void> 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<void> 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<void> 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<void> 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<void> _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<void> _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<void> _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<void> 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<void> _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<void> _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();
- }
- }
|