|
@@ -0,0 +1,541 @@
|
|
|
|
|
+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();
|
|
|
|
|
+ }
|
|
|
|
|
+}
|