2026-03-02 07:29:21 +01:00
|
|
|
|
import 'dart:async';
|
2026-05-13 11:06:52 +02:00
|
|
|
|
import 'dart:io' show Directory, Platform, ProcessInfo;
|
2026-04-03 12:19:29 +02:00
|
|
|
|
import 'dart:ui' show AppExitResponse;
|
2026-01-29 07:18:37 +01:00
|
|
|
|
import 'package:flutter/foundation.dart';
|
2026-04-25 12:27:28 +02:00
|
|
|
|
// ignore: depend_on_referenced_packages
|
2026-04-24 06:42:30 +02:00
|
|
|
|
import 'package:shared_preferences_foundation/shared_preferences_foundation.dart';
|
2025-10-18 00:05:20 +02:00
|
|
|
|
import 'package:flutter/material.dart';
|
2026-01-12 11:05:57 +01:00
|
|
|
|
import 'package:flutter/gestures.dart';
|
2026-03-22 13:10:03 +01:00
|
|
|
|
import 'package:flutter/services.dart';
|
2025-10-18 00:05:20 +02:00
|
|
|
|
import 'package:window_manager/window_manager.dart';
|
2025-10-30 18:49:59 +01:00
|
|
|
|
import 'package:provider/provider.dart';
|
2026-02-26 11:47:55 +01:00
|
|
|
|
import 'package:flutter_svg/flutter_svg.dart';
|
2026-03-02 07:29:21 +01:00
|
|
|
|
import 'package:sentry_flutter/sentry_flutter.dart';
|
2026-04-26 02:34:09 +02:00
|
|
|
|
import 'connection/connection.dart';
|
|
|
|
|
|
import 'connection/connection_bootstrap.dart';
|
|
|
|
|
|
import 'connection/connection_registry.dart';
|
|
|
|
|
|
import 'profiles/active_profile_binder.dart';
|
|
|
|
|
|
import 'profiles/active_profile_provider.dart';
|
|
|
|
|
|
import 'profiles/profile.dart';
|
|
|
|
|
|
import 'profiles/profile_connection_registry.dart';
|
|
|
|
|
|
import 'profiles/profile_registry.dart';
|
2026-05-05 03:01:14 +02:00
|
|
|
|
import 'mixins/mounted_set_state_mixin.dart';
|
2026-04-26 02:34:09 +02:00
|
|
|
|
import 'profiles/plex_home_service.dart';
|
2025-10-18 00:05:20 +02:00
|
|
|
|
import 'screens/main_screen.dart';
|
|
|
|
|
|
import 'screens/auth_screen.dart';
|
2026-04-26 02:34:09 +02:00
|
|
|
|
import 'screens/profile/pin_entry_dialog.dart';
|
|
|
|
|
|
import 'screens/profile/profile_switch_screen.dart';
|
2025-10-18 00:05:20 +02:00
|
|
|
|
import 'services/storage_service.dart';
|
2026-02-26 13:19:01 +01:00
|
|
|
|
import 'services/macos_window_service.dart';
|
2026-04-16 23:13:15 +02:00
|
|
|
|
import 'services/native_window_service.dart';
|
2025-10-18 00:05:20 +02:00
|
|
|
|
import 'services/fullscreen_state_manager.dart';
|
2025-11-05 08:19:50 +01:00
|
|
|
|
import 'services/settings_service.dart';
|
2025-12-15 01:16:22 +01:00
|
|
|
|
import 'utils/platform_detector.dart';
|
2026-05-05 23:49:31 +02:00
|
|
|
|
import 'services/apple_tv_remote_touch_service.dart';
|
2025-12-26 08:18:48 +01:00
|
|
|
|
import 'services/discord_rpc_service.dart';
|
2026-05-13 11:06:52 +02:00
|
|
|
|
import 'package:path_provider/path_provider.dart';
|
2026-05-12 15:11:16 +02:00
|
|
|
|
import 'services/image_cache_service.dart';
|
2025-12-07 19:31:56 +01:00
|
|
|
|
import 'services/gamepad_service.dart';
|
2026-04-19 11:30:35 +02:00
|
|
|
|
import 'services/trakt/trakt_scrobble_service.dart';
|
|
|
|
|
|
import 'services/trakt/trakt_sync_service.dart';
|
2026-04-24 08:29:12 +02:00
|
|
|
|
import 'services/trackers/tracker_coordinator.dart';
|
2026-04-19 11:30:35 +02:00
|
|
|
|
import 'providers/trakt_account_provider.dart';
|
2026-04-24 08:29:12 +02:00
|
|
|
|
import 'providers/trackers_provider.dart';
|
2025-10-30 18:49:59 +01:00
|
|
|
|
import 'providers/user_profile_provider.dart';
|
2025-11-21 23:59:39 +01:00
|
|
|
|
import 'providers/multi_server_provider.dart';
|
2025-10-31 00:58:39 +01:00
|
|
|
|
import 'providers/theme_provider.dart';
|
2025-11-04 06:45:16 +01:00
|
|
|
|
import 'providers/hidden_libraries_provider.dart';
|
2026-01-24 20:03:06 +01:00
|
|
|
|
import 'providers/libraries_provider.dart';
|
2025-11-05 07:28:23 +01:00
|
|
|
|
import 'providers/playback_state_provider.dart';
|
2025-12-11 19:15:06 +01:00
|
|
|
|
import 'providers/download_provider.dart';
|
|
|
|
|
|
import 'providers/offline_mode_provider.dart';
|
|
|
|
|
|
import 'providers/offline_watch_provider.dart';
|
2026-05-09 06:00:36 +02:00
|
|
|
|
import 'providers/watch_state_overlay_provider.dart';
|
2026-02-09 14:19:54 -05:00
|
|
|
|
import 'providers/companion_remote_provider.dart';
|
2026-02-24 02:38:06 +01:00
|
|
|
|
import 'providers/shader_provider.dart';
|
2026-03-11 09:29:16 +01:00
|
|
|
|
import 'utils/snackbar_helper.dart';
|
2026-04-21 12:04:48 +02:00
|
|
|
|
import 'watch_together/providers/watch_together_provider.dart';
|
2025-11-21 23:59:39 +01:00
|
|
|
|
import 'services/multi_server_manager.dart';
|
2025-12-11 19:15:06 +01:00
|
|
|
|
import 'services/offline_watch_sync_service.dart';
|
2025-11-21 23:59:39 +01:00
|
|
|
|
import 'services/data_aggregation_service.dart';
|
|
|
|
|
|
import 'services/server_registry.dart';
|
2025-12-11 19:15:06 +01:00
|
|
|
|
import 'services/download_manager_service.dart';
|
2026-01-02 01:01:24 +01:00
|
|
|
|
import 'services/pip_service.dart';
|
2025-12-11 19:15:06 +01:00
|
|
|
|
import 'services/download_storage_service.dart';
|
2026-02-23 14:08:52 +01:00
|
|
|
|
import 'package:connectivity_plus/connectivity_plus.dart';
|
2026-04-26 02:34:09 +02:00
|
|
|
|
import 'services/jellyfin_api_cache.dart';
|
2025-12-11 19:15:06 +01:00
|
|
|
|
import 'services/plex_api_cache.dart';
|
|
|
|
|
|
import 'database/app_database.dart';
|
2026-04-16 12:41:49 +02:00
|
|
|
|
import 'screens/video_player_screen.dart';
|
2025-10-18 00:05:20 +02:00
|
|
|
|
import 'utils/app_logger.dart';
|
2026-05-12 15:11:16 +02:00
|
|
|
|
import 'utils/managed_http_client.dart';
|
|
|
|
|
|
import 'utils/media_server_http_client.dart';
|
2025-11-01 09:28:47 +01:00
|
|
|
|
import 'utils/orientation_helper.dart';
|
2026-04-16 12:41:49 +02:00
|
|
|
|
import 'utils/watch_state_notifier.dart';
|
2025-11-10 10:02:34 +01:00
|
|
|
|
import 'i18n/strings.g.dart';
|
2025-12-07 13:57:52 +01:00
|
|
|
|
import 'focus/input_mode_tracker.dart';
|
2026-02-08 11:57:19 +01:00
|
|
|
|
import 'focus/key_event_utils.dart';
|
2026-01-23 20:21:08 -05:00
|
|
|
|
import 'package:intl/date_symbol_data_local.dart';
|
2026-02-26 11:47:55 +01:00
|
|
|
|
import 'utils/navigation_transitions.dart';
|
2026-03-02 07:29:21 +01:00
|
|
|
|
import 'utils/log_redaction_manager.dart';
|
2026-03-03 19:55:39 +01:00
|
|
|
|
import 'package:package_info_plus/package_info_plus.dart';
|
2025-10-18 00:05:20 +02:00
|
|
|
|
|
2026-03-08 18:14:49 +01:00
|
|
|
|
const bool _enableSentry = bool.fromEnvironment('ENABLE_SENTRY', defaultValue: false);
|
2026-03-15 13:50:47 +01:00
|
|
|
|
const String gitCommit = String.fromEnvironment('GIT_COMMIT');
|
2026-04-13 02:38:28 +02:00
|
|
|
|
const String _sentryEnvironment = String.fromEnvironment('SENTRY_ENVIRONMENT');
|
2026-05-12 06:40:54 +02:00
|
|
|
|
const String _sentryDist = String.fromEnvironment('SENTRY_DIST');
|
2026-03-08 18:14:49 +01:00
|
|
|
|
|
2026-01-12 11:05:57 +01:00
|
|
|
|
// Workaround for Flutter bug #177992: iPadOS 26.1+ misinterprets fake touch events
|
|
|
|
|
|
// at (0,0) as barrier taps, causing modals to dismiss immediately.
|
|
|
|
|
|
// Remove when Flutter PR #179643 is merged.
|
|
|
|
|
|
bool _zeroOffsetPointerGuardInstalled = false;
|
|
|
|
|
|
|
|
|
|
|
|
void _installZeroOffsetPointerGuard() {
|
|
|
|
|
|
if (_zeroOffsetPointerGuardInstalled) return;
|
|
|
|
|
|
GestureBinding.instance.pointerRouter.addGlobalRoute(_absorbZeroOffsetPointerEvent);
|
|
|
|
|
|
_zeroOffsetPointerGuardInstalled = true;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void _absorbZeroOffsetPointerEvent(PointerEvent event) {
|
|
|
|
|
|
if (event.position == Offset.zero) {
|
|
|
|
|
|
GestureBinding.instance.cancelPointer(event.pointer);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-24 06:42:30 +02:00
|
|
|
|
/// Register platform plugin stores manually for tvOS. Flutter's tool
|
|
|
|
|
|
/// doesn't support tvOS so it never generates a plugin registrant for it.
|
|
|
|
|
|
/// Each plugin whose iOS Swift implementation is tvOS-compatible must be
|
|
|
|
|
|
/// wired here; the Swift side (GeneratedPluginRegistrant.m / AppDelegate)
|
|
|
|
|
|
/// also needs to call the plugin's Swift register(with:) to attach its
|
|
|
|
|
|
/// message channels.
|
|
|
|
|
|
void _registerTvosPlatformPlugins() {
|
|
|
|
|
|
if (!Platform.isIOS) return; // tvOS reports as iOS via dart:io.
|
|
|
|
|
|
SharedPreferencesFoundation.registerWith();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-08 18:14:49 +01:00
|
|
|
|
Future<void> main() async {
|
2026-04-26 02:34:09 +02:00
|
|
|
|
final binding = WidgetsFlutterBinding.ensureInitialized();
|
|
|
|
|
|
// Build the semantics tree in debug so Maestro/UI automation can locate
|
|
|
|
|
|
// widgets by text. Zero cost in release builds.
|
|
|
|
|
|
if (kDebugMode) binding.ensureSemantics();
|
2026-01-12 11:05:57 +01:00
|
|
|
|
_installZeroOffsetPointerGuard(); // Workaround for iPadOS 26.1+ modal dismissal bug
|
2025-10-18 00:05:20 +02:00
|
|
|
|
|
2026-04-24 06:42:30 +02:00
|
|
|
|
// On tvOS, Flutter's generated plugin registrant doesn't run (no tvOS
|
|
|
|
|
|
// target in Flutter's tool), so register platform stores manually for
|
|
|
|
|
|
// the plugins we use.
|
|
|
|
|
|
_registerTvosPlatformPlugins();
|
|
|
|
|
|
|
2026-03-08 18:14:49 +01:00
|
|
|
|
if (_enableSentry) {
|
|
|
|
|
|
final packageInfo = await PackageInfo.fromPlatform();
|
2026-03-03 19:55:39 +01:00
|
|
|
|
|
2026-03-08 18:14:49 +01:00
|
|
|
|
await SentryFlutter.init((options) {
|
2026-03-03 14:09:33 +01:00
|
|
|
|
options.dsn = 'https://6a1a6ef8c72140099b2798973c1bfb2f@bugs.plezy.app/1';
|
2026-03-15 13:50:47 +01:00
|
|
|
|
options.release = gitCommit.isNotEmpty
|
|
|
|
|
|
? 'plezy@${gitCommit.substring(0, 7)}'
|
2026-03-11 14:09:14 +01:00
|
|
|
|
: 'plezy@${packageInfo.version}+${packageInfo.buildNumber}';
|
2026-04-13 02:38:28 +02:00
|
|
|
|
if (_sentryEnvironment.isNotEmpty) options.environment = _sentryEnvironment;
|
2026-05-12 06:40:54 +02:00
|
|
|
|
if (_sentryDist.isNotEmpty) options.dist = _sentryDist;
|
2026-03-02 07:29:21 +01:00
|
|
|
|
options.tracesSampleRate = 0;
|
|
|
|
|
|
options.attachStacktrace = true;
|
2026-03-03 11:05:46 +01:00
|
|
|
|
options.enableAutoSessionTracking = false;
|
2026-03-04 00:02:51 +01:00
|
|
|
|
options.recordHttpBreadcrumbs = false;
|
2026-04-15 17:51:28 +02:00
|
|
|
|
options.captureNativeFailedRequests = false;
|
2026-04-22 12:44:37 +02:00
|
|
|
|
options.enableAppHangTracking = !kDebugMode;
|
|
|
|
|
|
options.appHangTimeoutInterval = const Duration(seconds: 3);
|
2026-03-02 07:29:21 +01:00
|
|
|
|
options.beforeSend = _beforeSend;
|
2026-03-03 19:16:33 +01:00
|
|
|
|
options.beforeBreadcrumb = _beforeBreadcrumb;
|
2026-03-08 18:14:49 +01:00
|
|
|
|
}, appRunner: _bootstrapApp);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-03-02 07:29:21 +01:00
|
|
|
|
|
2026-03-08 18:14:49 +01:00
|
|
|
|
await _bootstrapApp();
|
|
|
|
|
|
}
|
2026-03-02 07:29:21 +01:00
|
|
|
|
|
2026-03-08 18:14:49 +01:00
|
|
|
|
Future<void> _bootstrapApp() async {
|
|
|
|
|
|
final settings = await SettingsService.getInstance();
|
2026-04-25 03:30:07 +02:00
|
|
|
|
final savedLocale = settings.read(SettingsService.appLocale);
|
2026-03-02 07:29:21 +01:00
|
|
|
|
|
2026-04-25 12:27:28 +02:00
|
|
|
|
unawaited(LocaleSettings.setLocale(savedLocale));
|
2026-03-02 07:29:21 +01:00
|
|
|
|
|
2026-03-08 18:14:49 +01:00
|
|
|
|
await initializeDateFormatting(savedLocale.languageCode, null);
|
2026-03-02 07:29:21 +01:00
|
|
|
|
|
2026-05-13 11:06:52 +02:00
|
|
|
|
// One-time cleanup of the old flutter_cache_manager image cache directory
|
|
|
|
|
|
// (replaced by cached_network_image_ce in a prior refactor).
|
|
|
|
|
|
if (!settings.read(SettingsService.cleanedOldImageCache)) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
final tempDir = await getTemporaryDirectory();
|
|
|
|
|
|
final oldCacheDir = Directory('${tempDir.path}/plexImageCache');
|
|
|
|
|
|
if (await oldCacheDir.exists()) {
|
|
|
|
|
|
await oldCacheDir.delete(recursive: true);
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (_) {
|
|
|
|
|
|
// Best-effort; the directory may be locked or already partial.
|
|
|
|
|
|
}
|
|
|
|
|
|
await settings.write(SettingsService.cleanedOldImageCache, true);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-17 09:48:18 +01:00
|
|
|
|
// Configure image cache — keep budget modest to leave headroom for Skia decode buffers
|
2026-04-25 03:16:11 +02:00
|
|
|
|
if (PlatformDetector.isDesktopOS()) {
|
2026-03-17 09:48:18 +01:00
|
|
|
|
PaintingBinding.instance.imageCache.maximumSize = 1000;
|
|
|
|
|
|
PaintingBinding.instance.imageCache.maximumSizeBytes = 150 << 20; // 150MB
|
|
|
|
|
|
} else {
|
|
|
|
|
|
PaintingBinding.instance.imageCache.maximumSize = 800;
|
|
|
|
|
|
PaintingBinding.instance.imageCache.maximumSizeBytes = 100 << 20; // 100MB
|
|
|
|
|
|
}
|
2026-03-02 07:29:21 +01:00
|
|
|
|
|
2026-03-08 18:14:49 +01:00
|
|
|
|
final futures = <Future<void>>[];
|
2026-03-02 07:29:21 +01:00
|
|
|
|
|
2026-04-25 03:16:11 +02:00
|
|
|
|
if (PlatformDetector.isDesktopOS()) {
|
2026-05-09 11:35:36 +02:00
|
|
|
|
if (Platform.isMacOS) {
|
|
|
|
|
|
futures.add(windowManager.ensureInitialized().then((_) => MacOSWindowService.setupCustomTitlebar()));
|
|
|
|
|
|
} else {
|
|
|
|
|
|
futures.add(windowManager.ensureInitialized());
|
|
|
|
|
|
}
|
2026-03-08 18:14:49 +01:00
|
|
|
|
}
|
2026-03-02 07:29:21 +01:00
|
|
|
|
|
2026-04-24 06:42:30 +02:00
|
|
|
|
// Initialize TV detection (Android leanback or Apple TV) and PiP on Android.
|
|
|
|
|
|
if (Platform.isAndroid || Platform.isIOS) {
|
2026-04-25 03:30:07 +02:00
|
|
|
|
futures.add(TvDetectionService.getInstance(forceTv: settings.read(SettingsService.forceTvMode)));
|
2026-04-24 06:42:30 +02:00
|
|
|
|
}
|
|
|
|
|
|
if (Platform.isAndroid) {
|
2026-03-08 18:14:49 +01:00
|
|
|
|
PipService();
|
|
|
|
|
|
}
|
2026-03-02 07:29:21 +01:00
|
|
|
|
|
2026-04-16 23:13:15 +02:00
|
|
|
|
// Hook Windows native fullscreen callback (no-op elsewhere).
|
|
|
|
|
|
NativeWindowService.initialize();
|
|
|
|
|
|
|
2026-03-08 18:14:49 +01:00
|
|
|
|
futures.add(StorageService.getInstance());
|
2026-03-02 07:29:21 +01:00
|
|
|
|
|
2026-03-08 18:14:49 +01:00
|
|
|
|
await Future.wait(futures);
|
2026-03-02 07:29:21 +01:00
|
|
|
|
|
2026-04-26 02:34:09 +02:00
|
|
|
|
// The PLEX_TOKEN dart-define (screenshot automation) is consumed by
|
|
|
|
|
|
// [ConnectionBootstrap.seedFromDevTokenDefine] later, when the registry
|
|
|
|
|
|
// is available — keeps the deprecated legacy slots out of runtime paths.
|
2026-04-11 21:19:03 +02:00
|
|
|
|
|
2026-04-25 03:30:07 +02:00
|
|
|
|
final debugEnabled = settings.read(SettingsService.enableDebugLogging);
|
2026-03-08 18:14:49 +01:00
|
|
|
|
setLoggerLevel(debugEnabled);
|
2026-03-02 07:29:21 +01:00
|
|
|
|
|
2026-03-15 13:50:47 +01:00
|
|
|
|
final packageInfo = await PackageInfo.fromPlatform();
|
|
|
|
|
|
final commitSuffix = gitCommit.isNotEmpty ? ' (${gitCommit.substring(0, 7)})' : '';
|
2026-03-22 13:10:03 +01:00
|
|
|
|
String renderer = '';
|
|
|
|
|
|
if (Platform.isAndroid) {
|
|
|
|
|
|
renderer = ' [${await const MethodChannel('com.plezy/theme').invokeMethod<String>('getRenderer')}]';
|
|
|
|
|
|
}
|
|
|
|
|
|
appLogger.i('Plezy v${packageInfo.version}+${packageInfo.buildNumber}$commitSuffix$renderer');
|
2026-03-15 13:50:47 +01:00
|
|
|
|
|
2026-03-08 18:14:49 +01:00
|
|
|
|
await DownloadStorageService.instance.initialize(settings);
|
2026-03-02 07:29:21 +01:00
|
|
|
|
|
2026-03-08 18:14:49 +01:00
|
|
|
|
FullscreenStateManager().startMonitoring();
|
2026-03-02 07:29:21 +01:00
|
|
|
|
|
2026-04-26 12:52:21 +02:00
|
|
|
|
// Apply "start in fullscreen" preference on Windows/Linux. macOS is
|
|
|
|
|
|
// excluded — its native fullscreen animation is awkward at launch and
|
|
|
|
|
|
// the OS already restores window state.
|
|
|
|
|
|
if ((Platform.isWindows || Platform.isLinux) && settings.read(SettingsService.startInFullscreen)) {
|
|
|
|
|
|
unawaited(FullscreenStateManager().enterFullscreen());
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-09 06:01:15 +01:00
|
|
|
|
// Initialize gamepad service (all platforms — universal_gamepad auto-registers
|
|
|
|
|
|
// and intercepts input events, so we must listen to re-dispatch them)
|
|
|
|
|
|
GamepadService.instance.start();
|
2026-05-05 23:49:31 +02:00
|
|
|
|
if (PlatformDetector.isAppleTV()) {
|
|
|
|
|
|
AppleTvRemoteTouchService.instance.start();
|
|
|
|
|
|
}
|
2026-03-09 06:01:15 +01:00
|
|
|
|
|
2026-04-25 03:16:11 +02:00
|
|
|
|
if (PlatformDetector.isDesktopOS()) {
|
2026-04-25 12:27:28 +02:00
|
|
|
|
unawaited(DiscordRPCService.instance.initialize());
|
2026-03-08 18:14:49 +01:00
|
|
|
|
}
|
2026-03-02 07:29:21 +01:00
|
|
|
|
|
2026-04-19 11:30:35 +02:00
|
|
|
|
await TraktScrobbleService.instance.initialize();
|
|
|
|
|
|
|
2026-03-08 18:14:49 +01:00
|
|
|
|
_registerShaderLicenses();
|
|
|
|
|
|
|
2026-04-16 06:02:18 +02:00
|
|
|
|
// In release mode, show a colored placeholder instead of a blank/white screen
|
|
|
|
|
|
// when a widget build() throws an unhandled exception.
|
|
|
|
|
|
ErrorWidget.builder = (FlutterErrorDetails details) {
|
|
|
|
|
|
if (kDebugMode) return ErrorWidget(details.exception);
|
|
|
|
|
|
return const ColoredBox(color: Color(0xFF000000));
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-03-08 18:14:49 +01:00
|
|
|
|
runApp(const MainApp());
|
2026-03-02 07:29:21 +01:00
|
|
|
|
}
|
2025-12-03 16:43:53 +01:00
|
|
|
|
|
2026-03-03 22:42:25 +01:00
|
|
|
|
Breadcrumb? _beforeBreadcrumb(Breadcrumb? breadcrumb, Hint _) {
|
2026-03-03 19:16:33 +01:00
|
|
|
|
if (breadcrumb == null) return null;
|
|
|
|
|
|
|
|
|
|
|
|
final message = breadcrumb.message;
|
|
|
|
|
|
final data = breadcrumb.data;
|
|
|
|
|
|
if (message == null && (data == null || data.isEmpty)) return breadcrumb;
|
|
|
|
|
|
|
2026-03-19 19:56:36 +01:00
|
|
|
|
if (message != null) breadcrumb.message = LogRedactionManager.redact(message);
|
|
|
|
|
|
if (data != null) breadcrumb.data = data.map((k, v) => MapEntry(k, v is String ? LogRedactionManager.redact(v) : v));
|
|
|
|
|
|
return breadcrumb;
|
2026-03-03 19:16:33 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-03 22:42:25 +01:00
|
|
|
|
FutureOr<SentryEvent?> _beforeSend(SentryEvent event, Hint _) {
|
2026-03-02 07:29:21 +01:00
|
|
|
|
// Drop event if user opted out of crash reporting
|
|
|
|
|
|
final instance = SettingsService.instanceOrNull;
|
2026-04-25 03:30:07 +02:00
|
|
|
|
if (instance != null && !instance.read(SettingsService.crashReporting)) return null;
|
2026-03-02 07:29:21 +01:00
|
|
|
|
|
2026-04-13 02:05:24 +02:00
|
|
|
|
// Drop unactionable errors
|
2026-04-25 12:27:28 +02:00
|
|
|
|
final exceptions = event.exceptions;
|
2026-04-13 02:05:24 +02:00
|
|
|
|
if (exceptions != null) {
|
|
|
|
|
|
bool shouldDrop(SentryException e) {
|
|
|
|
|
|
final v = e.value;
|
|
|
|
|
|
// Windows file-lock errors from cache manager cleanup
|
2026-04-18 08:04:10 +02:00
|
|
|
|
if (e.type == 'FileSystemException' && v != null && v.contains('plexImageCache') && v.contains('errno = 32')) {
|
2026-04-13 02:05:24 +02:00
|
|
|
|
return true;
|
|
|
|
|
|
}
|
|
|
|
|
|
// Linux without DBus/NetworkManager
|
2026-04-18 08:04:10 +02:00
|
|
|
|
if (e.type == 'DBusServiceUnknownException' || (v != null && v.contains('system_bus_socket'))) {
|
2026-04-13 02:05:24 +02:00
|
|
|
|
return true;
|
|
|
|
|
|
}
|
|
|
|
|
|
// Device out of disk space
|
2026-04-16 06:25:24 +02:00
|
|
|
|
if (v != null &&
|
|
|
|
|
|
(v.contains('SQLITE_FULL') ||
|
2026-04-18 08:04:10 +02:00
|
|
|
|
v.contains('No space left on device') ||
|
|
|
|
|
|
v.contains('errno = 112') ||
|
|
|
|
|
|
v.contains('database or disk is full'))) {
|
2026-04-13 02:05:24 +02:00
|
|
|
|
return true;
|
|
|
|
|
|
}
|
2026-04-14 06:40:41 +02:00
|
|
|
|
// Native HTTP errors from CFNetwork (server errors, not actionable)
|
|
|
|
|
|
if (e.type == 'HTTPClientError') return true;
|
|
|
|
|
|
// Discord RPC errors when Discord is not running
|
|
|
|
|
|
if (e.type == 'DiscordStateException') return true;
|
2026-04-13 02:05:24 +02:00
|
|
|
|
return false;
|
|
|
|
|
|
}
|
2026-03-06 18:30:47 +01:00
|
|
|
|
|
2026-04-13 02:05:24 +02:00
|
|
|
|
if (exceptions.any(shouldDrop)) return null;
|
2026-03-12 02:24:00 +01:00
|
|
|
|
|
2026-04-13 02:05:24 +02:00
|
|
|
|
// Scrub Plex tokens and server URLs from exception messages
|
2026-03-19 19:56:36 +01:00
|
|
|
|
for (final e in exceptions) {
|
2026-03-02 07:29:21 +01:00
|
|
|
|
final value = e.value;
|
|
|
|
|
|
if (value != null) {
|
2026-03-19 19:56:36 +01:00
|
|
|
|
e.value = LogRedactionManager.redact(value);
|
2026-03-02 07:29:21 +01:00
|
|
|
|
}
|
2026-03-19 19:56:36 +01:00
|
|
|
|
}
|
2025-10-18 00:05:20 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-16 07:16:27 +02:00
|
|
|
|
// Enrich TimeoutException with operation name + duration as tags/fingerprint.
|
|
|
|
|
|
// value format: "TimeoutException after 0:00:05.000000: <operation> timed out"
|
|
|
|
|
|
if (exceptions != null) {
|
|
|
|
|
|
final timeoutException = exceptions.where((e) => e.type == 'TimeoutException').firstOrNull;
|
|
|
|
|
|
if (timeoutException != null) {
|
|
|
|
|
|
final value = timeoutException.value ?? '';
|
|
|
|
|
|
final colonIdx = value.indexOf(': ');
|
|
|
|
|
|
final message = colonIdx >= 0 ? value.substring(colonIdx + 2) : value;
|
|
|
|
|
|
final operation = message.endsWith(' timed out')
|
|
|
|
|
|
? message.substring(0, message.length - ' timed out'.length)
|
|
|
|
|
|
: null;
|
|
|
|
|
|
final durationMatch = RegExp(r'after (\d+:\d{2}:\d{2}\.\d+)').firstMatch(value);
|
|
|
|
|
|
|
|
|
|
|
|
final tags = event.tags ??= {};
|
|
|
|
|
|
if (operation != null) tags['timeout.operation'] = operation;
|
|
|
|
|
|
if (durationMatch != null) tags['timeout.duration'] = durationMatch.group(1)!;
|
|
|
|
|
|
event.fingerprint = ['TimeoutException', ?operation];
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-02 07:29:21 +01:00
|
|
|
|
// Scrub breadcrumb messages and data
|
2026-03-19 19:56:36 +01:00
|
|
|
|
final breadcrumbs = event.breadcrumbs;
|
2026-03-02 07:29:21 +01:00
|
|
|
|
if (breadcrumbs != null) {
|
2026-03-19 19:56:36 +01:00
|
|
|
|
for (final b in breadcrumbs) {
|
2026-03-02 07:29:21 +01:00
|
|
|
|
final message = b.message;
|
|
|
|
|
|
final data = b.data;
|
2026-03-19 19:56:36 +01:00
|
|
|
|
if (message != null) b.message = LogRedactionManager.redact(message);
|
|
|
|
|
|
if (data != null) b.data = data.map((k, v) => MapEntry(k, v is String ? LogRedactionManager.redact(v) : v));
|
|
|
|
|
|
}
|
2025-12-06 10:30:26 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-19 19:56:36 +01:00
|
|
|
|
return event;
|
2025-10-18 00:05:20 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-29 07:18:37 +01:00
|
|
|
|
void _registerShaderLicenses() {
|
|
|
|
|
|
LicenseRegistry.addLicense(() async* {
|
2026-02-27 15:42:12 +01:00
|
|
|
|
yield const LicenseEntryWithLineBreaks(
|
2026-01-29 07:18:37 +01:00
|
|
|
|
['Anime4K'],
|
|
|
|
|
|
'MIT License\n'
|
|
|
|
|
|
'\n'
|
|
|
|
|
|
'Copyright (c) 2019-2021 bloc97\n'
|
|
|
|
|
|
'All rights reserved.\n'
|
|
|
|
|
|
'\n'
|
|
|
|
|
|
'Permission is hereby granted, free of charge, to any person obtaining a copy '
|
|
|
|
|
|
'of this software and associated documentation files (the "Software"), to deal '
|
|
|
|
|
|
'in the Software without restriction, including without limitation the rights '
|
|
|
|
|
|
'to use, copy, modify, merge, publish, distribute, sublicense, and/or sell '
|
|
|
|
|
|
'copies of the Software, and to permit persons to whom the Software is '
|
|
|
|
|
|
'furnished to do so, subject to the following conditions:\n'
|
|
|
|
|
|
'\n'
|
|
|
|
|
|
'The above copyright notice and this permission notice shall be included in all '
|
|
|
|
|
|
'copies or substantial portions of the Software.\n'
|
|
|
|
|
|
'\n'
|
|
|
|
|
|
'THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR '
|
|
|
|
|
|
'IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, '
|
|
|
|
|
|
'FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE '
|
|
|
|
|
|
'AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER '
|
|
|
|
|
|
'LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, '
|
|
|
|
|
|
'OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE '
|
|
|
|
|
|
'SOFTWARE.',
|
|
|
|
|
|
);
|
2026-02-27 15:42:12 +01:00
|
|
|
|
yield const LicenseEntryWithLineBreaks(
|
2026-01-29 07:18:37 +01:00
|
|
|
|
['NVIDIA Image Scaling (NVScaler)'],
|
|
|
|
|
|
'The MIT License (MIT)\n'
|
|
|
|
|
|
'\n'
|
|
|
|
|
|
'Copyright (c) 2022 NVIDIA CORPORATION & AFFILIATES. All rights reserved.\n'
|
|
|
|
|
|
'\n'
|
|
|
|
|
|
'Permission is hereby granted, free of charge, to any person obtaining a copy of '
|
|
|
|
|
|
'this software and associated documentation files (the "Software"), to deal in '
|
|
|
|
|
|
'the Software without restriction, including without limitation the rights to '
|
|
|
|
|
|
'use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of '
|
|
|
|
|
|
'the Software, and to permit persons to whom the Software is furnished to do so, '
|
|
|
|
|
|
'subject to the following conditions:\n'
|
|
|
|
|
|
'\n'
|
|
|
|
|
|
'The above copyright notice and this permission notice shall be included in all '
|
|
|
|
|
|
'copies or substantial portions of the Software.\n'
|
|
|
|
|
|
'\n'
|
|
|
|
|
|
'THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR '
|
|
|
|
|
|
'IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS '
|
|
|
|
|
|
'FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR '
|
|
|
|
|
|
'COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER '
|
|
|
|
|
|
'IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN '
|
|
|
|
|
|
'CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.',
|
|
|
|
|
|
);
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-10-18 00:05:20 +02:00
|
|
|
|
final RouteObserver<PageRoute> routeObserver = RouteObserver<PageRoute>();
|
2026-04-14 06:30:18 +02:00
|
|
|
|
final rootNavigatorKey = GlobalKey<NavigatorState>();
|
2025-10-18 00:05:20 +02:00
|
|
|
|
|
2026-05-10 14:27:33 +02:00
|
|
|
|
@visibleForTesting
|
|
|
|
|
|
bool shouldEnterOfflineModeAfterStartupBind({required bool bindingSucceeded, required bool hasOnlineServers}) {
|
|
|
|
|
|
return !bindingSucceeded && !hasOnlineServers;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-26 02:34:09 +02:00
|
|
|
|
/// Top-level PIN prompt used by [ActiveProfileBinder] when it runs above the
|
|
|
|
|
|
/// per-screen widget tree. Routes through [rootNavigatorKey] so the dialog
|
|
|
|
|
|
/// renders correctly whether the binder fires from the splash, MainScreen,
|
|
|
|
|
|
/// or any future host. Returns `null` when no Navigator is available yet
|
|
|
|
|
|
/// (early boot, post-dispose) so the binder treats it as "PIN cancelled".
|
|
|
|
|
|
Future<String?> _rootPinPrompt(Profile profile, {String? errorMessage}) {
|
|
|
|
|
|
final ctx = rootNavigatorKey.currentContext;
|
|
|
|
|
|
if (ctx == null) return Future.value(null);
|
|
|
|
|
|
return showPinEntryDialog(ctx, profile.displayName, errorMessage: errorMessage);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-11 19:15:06 +01:00
|
|
|
|
class MainApp extends StatefulWidget {
|
2025-10-18 00:05:20 +02:00
|
|
|
|
const MainApp({super.key});
|
|
|
|
|
|
|
|
|
|
|
|
@override
|
2025-12-11 19:15:06 +01:00
|
|
|
|
State<MainApp> createState() => _MainAppState();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
class _MainAppState extends State<MainApp> with WidgetsBindingObserver {
|
|
|
|
|
|
late final MultiServerManager _serverManager;
|
|
|
|
|
|
late final DataAggregationService _aggregationService;
|
|
|
|
|
|
late final AppDatabase _appDatabase;
|
|
|
|
|
|
late final DownloadManagerService _downloadManager;
|
|
|
|
|
|
late final OfflineWatchSyncService _offlineWatchSyncService;
|
2026-04-03 12:19:29 +02:00
|
|
|
|
late final AppLifecycleListener _appLifecycleListener;
|
2026-04-16 12:41:49 +02:00
|
|
|
|
StreamSubscription<WatchStateEvent>? _watchStateSubscription;
|
2026-04-19 17:45:03 +02:00
|
|
|
|
StreamSubscription<List<ConnectivityResult>>? _connectivitySubscription;
|
2026-04-16 12:41:49 +02:00
|
|
|
|
Timer? _syncDebounce;
|
2026-04-19 17:45:03 +02:00
|
|
|
|
final Set<String> _pendingSyncKeys = <String>{};
|
2026-04-16 12:41:49 +02:00
|
|
|
|
bool _isAutoDeleteRunning = false;
|
2026-04-19 17:45:03 +02:00
|
|
|
|
bool _lastConnectivityWasWifi = false;
|
2026-05-12 15:11:16 +02:00
|
|
|
|
bool _shutdownStarted = false;
|
2025-12-11 19:15:06 +01:00
|
|
|
|
|
2026-03-12 02:52:14 +01:00
|
|
|
|
/// Last time server health probes ran from a resume event (cooldown for desktop)
|
|
|
|
|
|
DateTime _lastResumeProbe = DateTime(0);
|
|
|
|
|
|
|
2026-03-17 09:48:18 +01:00
|
|
|
|
/// Periodic memory check timer for desktop platforms
|
|
|
|
|
|
Timer? _memoryCheckTimer;
|
|
|
|
|
|
|
2025-12-11 19:15:06 +01:00
|
|
|
|
@override
|
|
|
|
|
|
void initState() {
|
|
|
|
|
|
super.initState();
|
|
|
|
|
|
WidgetsBinding.instance.addObserver(this);
|
|
|
|
|
|
|
2026-04-25 03:16:11 +02:00
|
|
|
|
if (PlatformDetector.isDesktopOS()) {
|
2026-03-17 09:48:18 +01:00
|
|
|
|
_memoryCheckTimer = Timer.periodic(const Duration(seconds: 30), (_) {
|
|
|
|
|
|
final rss = ProcessInfo.currentRss;
|
2026-04-25 17:12:17 +02:00
|
|
|
|
if (rss > 1536 * 1024 * 1024) {
|
|
|
|
|
|
// 1.5GB
|
2026-03-17 09:48:18 +01:00
|
|
|
|
appLogger.w('RSS high ($rss bytes), evicting image caches');
|
|
|
|
|
|
_evictImageCaches();
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-11 19:15:06 +01:00
|
|
|
|
_serverManager = MultiServerManager();
|
|
|
|
|
|
_aggregationService = DataAggregationService(_serverManager);
|
|
|
|
|
|
_appDatabase = AppDatabase();
|
|
|
|
|
|
|
|
|
|
|
|
PlexApiCache.initialize(_appDatabase);
|
2026-04-26 02:34:09 +02:00
|
|
|
|
JellyfinApiCache.initialize(_appDatabase);
|
2025-12-11 19:15:06 +01:00
|
|
|
|
|
2025-12-15 02:29:03 +01:00
|
|
|
|
_downloadManager = DownloadManagerService(database: _appDatabase, storageService: DownloadStorageService.instance);
|
2026-04-26 02:34:09 +02:00
|
|
|
|
_downloadManager.setClientResolver((serverId, {clientScopeId}) {
|
|
|
|
|
|
if (clientScopeId != null && clientScopeId.isNotEmpty) {
|
|
|
|
|
|
return _serverManager.getJellyfinClientByCompoundId(clientScopeId) ?? _serverManager.getClient(serverId);
|
|
|
|
|
|
}
|
|
|
|
|
|
return _serverManager.getClient(serverId);
|
|
|
|
|
|
});
|
2026-02-09 09:03:39 +01:00
|
|
|
|
_downloadManager.recoveryFuture = _downloadManager.recoverInterruptedDownloads();
|
2025-12-11 19:15:06 +01:00
|
|
|
|
|
2025-12-15 02:29:03 +01:00
|
|
|
|
_offlineWatchSyncService = OfflineWatchSyncService(database: _appDatabase, serverManager: _serverManager);
|
2025-12-19 13:10:11 +01:00
|
|
|
|
|
2026-04-19 11:30:35 +02:00
|
|
|
|
// Trakt sync service (subscribes to WatchStateNotifier, requires serverManager
|
|
|
|
|
|
// to resolve PlexClients for GUID lookups).
|
|
|
|
|
|
TraktSyncService.instance.initialize(serverManager: _serverManager);
|
|
|
|
|
|
|
2026-04-03 12:19:29 +02:00
|
|
|
|
_appLifecycleListener = AppLifecycleListener(
|
|
|
|
|
|
onExitRequested: () async {
|
2026-05-12 15:11:16 +02:00
|
|
|
|
await _shutdownForExit();
|
2026-04-03 12:19:29 +02:00
|
|
|
|
return AppExitResponse.exit;
|
|
|
|
|
|
},
|
|
|
|
|
|
);
|
2025-12-11 19:15:06 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-12 15:11:16 +02:00
|
|
|
|
Future<void> _shutdownForExit() async {
|
|
|
|
|
|
if (_shutdownStarted) return;
|
|
|
|
|
|
_shutdownStarted = true;
|
|
|
|
|
|
|
|
|
|
|
|
_syncDebounce?.cancel();
|
|
|
|
|
|
await _watchStateSubscription?.cancel();
|
|
|
|
|
|
await _connectivitySubscription?.cancel();
|
|
|
|
|
|
_memoryCheckTimer?.cancel();
|
|
|
|
|
|
|
|
|
|
|
|
_downloadManager.dispose();
|
|
|
|
|
|
TrackerCoordinator.instance.cancelInFlight();
|
|
|
|
|
|
TraktScrobbleService.instance.cancelInFlight();
|
|
|
|
|
|
await TraktSyncService.instance.dispose();
|
|
|
|
|
|
|
|
|
|
|
|
await _serverManager.disconnectAllGracefully();
|
|
|
|
|
|
await Future.wait([
|
|
|
|
|
|
httpClient.closeGracefully(drainTimeout: const Duration(seconds: 5)),
|
|
|
|
|
|
closeArtworkHttpClientGracefully(),
|
|
|
|
|
|
], eagerError: false);
|
|
|
|
|
|
await ManagedHttpClient.closeAllGracefully();
|
|
|
|
|
|
await _appDatabase.close();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-11 19:15:06 +01:00
|
|
|
|
@override
|
|
|
|
|
|
void dispose() {
|
2026-04-16 12:41:49 +02:00
|
|
|
|
_syncDebounce?.cancel();
|
|
|
|
|
|
_watchStateSubscription?.cancel();
|
2026-04-19 17:45:03 +02:00
|
|
|
|
_connectivitySubscription?.cancel();
|
2026-03-17 09:48:18 +01:00
|
|
|
|
_memoryCheckTimer?.cancel();
|
2026-04-03 12:19:29 +02:00
|
|
|
|
_appLifecycleListener.dispose();
|
2026-05-12 15:11:16 +02:00
|
|
|
|
if (!_shutdownStarted) {
|
|
|
|
|
|
_downloadManager.dispose();
|
|
|
|
|
|
_serverManager.dispose();
|
|
|
|
|
|
}
|
2025-12-11 19:15:06 +01:00
|
|
|
|
WidgetsBinding.instance.removeObserver(this);
|
|
|
|
|
|
super.dispose();
|
|
|
|
|
|
}
|
2025-11-21 23:59:39 +01:00
|
|
|
|
|
2026-03-17 09:48:18 +01:00
|
|
|
|
@override
|
|
|
|
|
|
void didHaveMemoryPressure() {
|
|
|
|
|
|
super.didHaveMemoryPressure();
|
|
|
|
|
|
appLogger.w('System memory pressure, evicting image caches');
|
|
|
|
|
|
_evictImageCaches();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void _evictImageCaches() {
|
|
|
|
|
|
PaintingBinding.instance.imageCache.clear();
|
|
|
|
|
|
PaintingBinding.instance.imageCache.clearLiveImages();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-19 17:45:03 +02:00
|
|
|
|
/// Fires [_autoDeleteAndSync] on each WiFi/Ethernet reconnect so rules run
|
|
|
|
|
|
/// as soon as the device is back online. Rapid flapping is bounded by the
|
|
|
|
|
|
/// executor's cooldown.
|
|
|
|
|
|
void _startConnectivitySyncTrigger(DownloadProvider downloadProvider) {
|
|
|
|
|
|
Future<void> setup() async {
|
|
|
|
|
|
try {
|
|
|
|
|
|
final initial = await Connectivity().checkConnectivity();
|
|
|
|
|
|
_lastConnectivityWasWifi = _hasWifiOrEthernet(initial);
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
appLogger.w('Initial connectivity read failed, defaulting to false: $e');
|
|
|
|
|
|
_lastConnectivityWasWifi = false;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
_connectivitySubscription = Connectivity().onConnectivityChanged.listen((results) {
|
|
|
|
|
|
final hasWifi = _hasWifiOrEthernet(results);
|
|
|
|
|
|
final transitioned = hasWifi && !_lastConnectivityWasWifi;
|
|
|
|
|
|
_lastConnectivityWasWifi = hasWifi;
|
|
|
|
|
|
if (transitioned) {
|
|
|
|
|
|
appLogger.d('Connectivity moved onto WiFi/Ethernet — triggering sync pass');
|
|
|
|
|
|
_autoDeleteAndSync(downloadProvider);
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
appLogger.w('Could not subscribe to connectivity changes: $e');
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
setup();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
static bool _hasWifiOrEthernet(List<ConnectivityResult> results) =>
|
|
|
|
|
|
results.contains(ConnectivityResult.wifi) || results.contains(ConnectivityResult.ethernet);
|
|
|
|
|
|
|
|
|
|
|
|
/// Run auto-delete (if enabled) and then a sync-rule pass.
|
|
|
|
|
|
///
|
|
|
|
|
|
/// When [targetKeys] is non-null, only those rules are re-evaluated
|
|
|
|
|
|
/// (cooldown doesn't apply — targeted runs are always "we know this
|
|
|
|
|
|
/// changed"). When null, every rule runs via the executor, with [force]
|
|
|
|
|
|
/// gating the cooldown: `true` for user-initiated drains, `false` for
|
|
|
|
|
|
/// background probes like a connectivity reconnect.
|
|
|
|
|
|
Future<void> _autoDeleteAndSync(
|
|
|
|
|
|
DownloadProvider downloadProvider, {
|
|
|
|
|
|
List<String>? targetKeys,
|
|
|
|
|
|
bool force = false,
|
|
|
|
|
|
}) async {
|
2026-04-16 12:41:49 +02:00
|
|
|
|
if (_isAutoDeleteRunning) return;
|
|
|
|
|
|
_isAutoDeleteRunning = true;
|
|
|
|
|
|
try {
|
2026-04-18 08:04:10 +02:00
|
|
|
|
await downloadProvider.refreshMetadataFromCache();
|
2026-04-26 02:34:09 +02:00
|
|
|
|
final activeKey = VideoPlayerScreenState.activeId;
|
2026-04-18 08:04:10 +02:00
|
|
|
|
final settings = SettingsService.instanceOrNull;
|
2026-04-25 03:30:07 +02:00
|
|
|
|
if (settings != null && settings.read(SettingsService.autoRemoveWatchedDownloads)) {
|
2026-04-26 02:34:09 +02:00
|
|
|
|
final deleted = await downloadProvider.autoDeleteWatchedDownloads(activeId: activeKey);
|
2026-04-18 08:04:10 +02:00
|
|
|
|
if (deleted.isNotEmpty) {
|
|
|
|
|
|
final msg = deleted.length == 1
|
|
|
|
|
|
? t.messages.autoRemovedWatchedDownload(title: deleted.first)
|
|
|
|
|
|
: t.messages.autoRemovedWatchedDownload(title: '${deleted.length} items');
|
|
|
|
|
|
showMainSnackBar(msg);
|
|
|
|
|
|
}
|
2026-04-16 12:41:49 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-19 17:45:03 +02:00
|
|
|
|
if (targetKeys != null) {
|
|
|
|
|
|
for (final key in targetKeys) {
|
|
|
|
|
|
if (!downloadProvider.hasSyncRule(key)) continue;
|
|
|
|
|
|
final result = await downloadProvider.executeSyncRuleFor(key, _serverManager);
|
|
|
|
|
|
if (result != null && result.queuedCount > 0) {
|
|
|
|
|
|
final title = result.title ?? 'Unknown';
|
|
|
|
|
|
showMainSnackBar(t.downloads.syncedNewEpisodes(count: '1', title: '$title (${result.queuedCount})'));
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
final synced = await downloadProvider.executeSyncRules(_serverManager, force: force);
|
|
|
|
|
|
if (synced.isNotEmpty) {
|
|
|
|
|
|
showMainSnackBar(t.downloads.syncedNewEpisodes(count: synced.length.toString(), title: synced.first));
|
|
|
|
|
|
}
|
2026-04-18 08:04:10 +02:00
|
|
|
|
}
|
2026-04-16 12:41:49 +02:00
|
|
|
|
} finally {
|
|
|
|
|
|
_isAutoDeleteRunning = false;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-11 19:15:06 +01:00
|
|
|
|
@override
|
|
|
|
|
|
void didChangeAppLifecycleState(AppLifecycleState state) {
|
2025-12-19 13:10:11 +01:00
|
|
|
|
switch (state) {
|
|
|
|
|
|
case AppLifecycleState.resumed:
|
2026-05-09 11:47:37 +02:00
|
|
|
|
// App came back to foreground - trigger sync check
|
2025-12-19 13:10:11 +01:00
|
|
|
|
_offlineWatchSyncService.onAppResumed();
|
2026-04-19 11:30:35 +02:00
|
|
|
|
TraktSyncService.instance.flushQueue();
|
2026-03-12 02:52:14 +01:00
|
|
|
|
// Re-probe servers — mobile OS may have dropped TCP connections during doze/sleep.
|
|
|
|
|
|
// On desktop, resumed fires on every window focus (alt-tab), so apply a cooldown
|
|
|
|
|
|
// to avoid piling up network probes from rapid alt-tabbing.
|
|
|
|
|
|
final now = DateTime.now();
|
|
|
|
|
|
final cooldown = (Platform.isIOS || Platform.isAndroid)
|
|
|
|
|
|
? const Duration(seconds: 10)
|
|
|
|
|
|
: const Duration(minutes: 2);
|
|
|
|
|
|
if (now.difference(_lastResumeProbe) >= cooldown) {
|
|
|
|
|
|
_lastResumeProbe = now;
|
2026-04-20 01:10:30 +02:00
|
|
|
|
// Await health check before reconnecting so stale "online" servers
|
|
|
|
|
|
// get marked offline and included in the reconnection sweep.
|
|
|
|
|
|
unawaited(() async {
|
|
|
|
|
|
await _serverManager.checkServerHealth();
|
|
|
|
|
|
await _serverManager.reconnectOfflineServers();
|
|
|
|
|
|
}());
|
2026-03-12 02:52:14 +01:00
|
|
|
|
}
|
2025-12-19 13:10:11 +01:00
|
|
|
|
case AppLifecycleState.paused:
|
|
|
|
|
|
case AppLifecycleState.detached:
|
2026-04-07 02:00:15 +02:00
|
|
|
|
// Database is session-scoped and must survive suspend/resume.
|
|
|
|
|
|
// Closing here would kill the Drift isolate channel while services
|
|
|
|
|
|
// (sync, downloads, cache) still hold references to the executor.
|
|
|
|
|
|
// SQLite WAL mode handles process death; desktop uses onExitRequested.
|
2026-04-25 03:16:11 +02:00
|
|
|
|
if (PlatformDetector.isDesktopOS()) {
|
2026-04-25 17:12:17 +02:00
|
|
|
|
if (ProcessInfo.currentRss > 1024 * 1024 * 1024) {
|
|
|
|
|
|
// 1GB
|
|
|
|
|
|
_evictImageCaches();
|
|
|
|
|
|
}
|
2026-03-17 09:48:18 +01:00
|
|
|
|
}
|
2025-12-19 13:10:11 +01:00
|
|
|
|
case AppLifecycleState.inactive:
|
|
|
|
|
|
case AppLifecycleState.hidden:
|
|
|
|
|
|
// Transitional states - don't trigger session events
|
|
|
|
|
|
break;
|
2025-12-11 19:15:06 +01:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
|
Widget build(BuildContext context) {
|
2025-10-30 18:49:59 +01:00
|
|
|
|
return MultiProvider(
|
|
|
|
|
|
providers: [
|
2026-04-26 02:34:09 +02:00
|
|
|
|
// Expose AppDatabase + ConnectionRegistry so screens (Settings, Setup)
|
|
|
|
|
|
// can manage stored Jellyfin/Plex connections without re-creating
|
|
|
|
|
|
// the registry per-call site.
|
|
|
|
|
|
Provider<AppDatabase>.value(value: _appDatabase),
|
|
|
|
|
|
Provider<ConnectionRegistry>(create: (_) => ConnectionRegistry(_appDatabase)),
|
|
|
|
|
|
Provider<ProfileRegistry>(create: (_) => ProfileRegistry(_appDatabase)),
|
|
|
|
|
|
Provider<ProfileConnectionRegistry>(create: (_) => ProfileConnectionRegistry(_appDatabase)),
|
|
|
|
|
|
Provider<PlexHomeService>(
|
|
|
|
|
|
create: (context) {
|
|
|
|
|
|
// start() resolves StorageService internally — the singleton was
|
|
|
|
|
|
// already initialised eagerly during boot, so the await is a
|
|
|
|
|
|
// microtask hop in practice.
|
|
|
|
|
|
final service = PlexHomeService(
|
|
|
|
|
|
connections: context.read<ConnectionRegistry>(),
|
|
|
|
|
|
profileConnections: context.read<ProfileConnectionRegistry>(),
|
|
|
|
|
|
);
|
|
|
|
|
|
unawaited(service.start());
|
|
|
|
|
|
return service;
|
|
|
|
|
|
},
|
|
|
|
|
|
dispose: (_, s) => s.dispose(),
|
|
|
|
|
|
),
|
|
|
|
|
|
ChangeNotifierProvider<ActiveProfileProvider>(
|
|
|
|
|
|
create: (context) {
|
|
|
|
|
|
final provider = ActiveProfileProvider(
|
|
|
|
|
|
registry: context.read<ProfileRegistry>(),
|
|
|
|
|
|
plexHome: context.read<PlexHomeService>(),
|
|
|
|
|
|
connections: context.read<ConnectionRegistry>(),
|
|
|
|
|
|
);
|
|
|
|
|
|
unawaited(provider.initialize());
|
|
|
|
|
|
return provider;
|
|
|
|
|
|
},
|
|
|
|
|
|
),
|
|
|
|
|
|
ChangeNotifierProvider(
|
|
|
|
|
|
create: (context) {
|
|
|
|
|
|
_serverManager.onJellyfinConnectionUpdated = context.read<ConnectionRegistry>().upsert;
|
|
|
|
|
|
return MultiServerProvider(_serverManager, _aggregationService);
|
|
|
|
|
|
},
|
|
|
|
|
|
),
|
|
|
|
|
|
// Profile binder owns the cold-start client connect: Plex token
|
|
|
|
|
|
// refresh + Jellyfin client creation. Hoisted out of MainScreen so
|
|
|
|
|
|
// the splash can await its first settle — without this, MainScreen
|
|
|
|
|
|
// mounts (and discover/libraries query) before any client exists.
|
|
|
|
|
|
Provider<ActiveProfileBinder>(
|
|
|
|
|
|
lazy: false,
|
2026-05-01 03:16:32 +02:00
|
|
|
|
create: (context) {
|
|
|
|
|
|
final activeProfile = context.read<ActiveProfileProvider>();
|
|
|
|
|
|
return ActiveProfileBinder(
|
|
|
|
|
|
activeProfile: activeProfile,
|
|
|
|
|
|
connections: context.read<ConnectionRegistry>(),
|
|
|
|
|
|
profileConnections: context.read<ProfileConnectionRegistry>(),
|
|
|
|
|
|
serverManager: _serverManager,
|
|
|
|
|
|
multiServerProvider: context.read<MultiServerProvider>(),
|
|
|
|
|
|
pinPrompt: _rootPinPrompt,
|
|
|
|
|
|
shouldDeferInitialBind: (_) async {
|
|
|
|
|
|
final settings = await SettingsService.getInstance();
|
|
|
|
|
|
return settings.read(SettingsService.requireProfileSelectionOnOpen) &&
|
|
|
|
|
|
activeProfile.hasMultipleProfiles;
|
|
|
|
|
|
},
|
|
|
|
|
|
)..start();
|
|
|
|
|
|
},
|
2026-04-26 02:34:09 +02:00
|
|
|
|
dispose: (_, binder) => binder.dispose(),
|
|
|
|
|
|
),
|
2025-12-11 19:15:06 +01:00
|
|
|
|
ChangeNotifierProxyProvider<MultiServerProvider, OfflineModeProvider>(
|
2025-12-13 21:14:52 +01:00
|
|
|
|
create: (_) {
|
|
|
|
|
|
final provider = OfflineModeProvider(_serverManager);
|
|
|
|
|
|
provider.initialize(); // Initialize immediately so statusStream listener is ready
|
|
|
|
|
|
return provider;
|
|
|
|
|
|
},
|
2025-12-11 19:15:06 +01:00
|
|
|
|
update: (_, multiServerProvider, previous) {
|
|
|
|
|
|
final provider = previous ?? OfflineModeProvider(_serverManager);
|
2025-12-13 21:14:52 +01:00
|
|
|
|
provider.initialize(); // Idempotent - safe to call again
|
2025-12-11 19:15:06 +01:00
|
|
|
|
return provider;
|
|
|
|
|
|
},
|
|
|
|
|
|
),
|
2026-04-26 02:34:09 +02:00
|
|
|
|
// Download provider. Downloads are shared, but sync rules are scoped to
|
|
|
|
|
|
// the active profile and reload when the profile changes.
|
|
|
|
|
|
ChangeNotifierProxyProvider<ActiveProfileProvider, DownloadProvider>(
|
2026-04-18 08:04:10 +02:00
|
|
|
|
create: (context) => DownloadProvider(downloadManager: _downloadManager, database: _appDatabase),
|
2026-04-26 02:34:09 +02:00
|
|
|
|
update: (context, activeProfile, previous) {
|
|
|
|
|
|
final provider = previous ?? DownloadProvider(downloadManager: _downloadManager, database: _appDatabase);
|
|
|
|
|
|
provider.setActiveProfileId(activeProfile.activeId);
|
|
|
|
|
|
return provider;
|
|
|
|
|
|
},
|
2026-04-18 08:04:10 +02:00
|
|
|
|
),
|
2026-05-09 06:00:36 +02:00
|
|
|
|
ChangeNotifierProxyProvider<ActiveProfileProvider, WatchStateOverlayProvider>(
|
|
|
|
|
|
create: (_) => WatchStateOverlayProvider(),
|
|
|
|
|
|
update: (_, activeProfile, previous) {
|
|
|
|
|
|
final provider = previous ?? WatchStateOverlayProvider();
|
|
|
|
|
|
provider.setActiveProfileId(activeProfile.activeId);
|
|
|
|
|
|
return provider;
|
|
|
|
|
|
},
|
|
|
|
|
|
),
|
2026-04-26 02:34:09 +02:00
|
|
|
|
ChangeNotifierProxyProvider<ActiveProfileProvider, OfflineWatchSyncService>(
|
2025-12-11 19:15:06 +01:00
|
|
|
|
create: (context) {
|
|
|
|
|
|
final offlineModeProvider = context.read<OfflineModeProvider>();
|
|
|
|
|
|
final downloadProvider = context.read<DownloadProvider>();
|
2026-04-26 02:34:09 +02:00
|
|
|
|
final activeProfile = context.read<ActiveProfileProvider>();
|
|
|
|
|
|
_offlineWatchSyncService.setActiveProfileId(
|
|
|
|
|
|
activeProfile.activeId,
|
|
|
|
|
|
availableProfileCount: activeProfile.profiles.length,
|
|
|
|
|
|
);
|
2025-12-11 19:15:06 +01:00
|
|
|
|
|
2026-04-19 17:45:03 +02:00
|
|
|
|
// Offline-sync drain replays a batch of queued watch actions without
|
|
|
|
|
|
// per-item data, so we can't target rules — force a full pass.
|
2026-04-13 22:45:24 +02:00
|
|
|
|
_offlineWatchSyncService.onWatchStatesRefreshed = () async {
|
2026-04-19 17:45:03 +02:00
|
|
|
|
await _autoDeleteAndSync(downloadProvider, force: true);
|
2025-12-11 19:15:06 +01:00
|
|
|
|
};
|
|
|
|
|
|
|
2026-04-19 17:45:03 +02:00
|
|
|
|
// In-session watch events carry the episode's parent chain, so we
|
|
|
|
|
|
// only re-evaluate rules that actually cover the watched item —
|
|
|
|
|
|
// leaves unrelated collection/playlist rules alone. Debounced so
|
|
|
|
|
|
// binge-watching coalesces into one pass.
|
2026-04-16 12:41:49 +02:00
|
|
|
|
_watchStateSubscription = WatchStateNotifier().stream.listen((event) {
|
|
|
|
|
|
if (event.changeType != WatchStateChangeType.watched) return;
|
2026-04-26 02:34:09 +02:00
|
|
|
|
if (VideoPlayerScreenState.activeId == event.itemId) return;
|
2026-04-16 12:41:49 +02:00
|
|
|
|
|
2026-04-26 02:34:09 +02:00
|
|
|
|
_pendingSyncKeys.addAll(downloadProvider.syncRuleKeysForWatchEvent(event));
|
2026-04-19 17:45:03 +02:00
|
|
|
|
|
2026-04-16 12:41:49 +02:00
|
|
|
|
_syncDebounce?.cancel();
|
|
|
|
|
|
_syncDebounce = Timer(const Duration(seconds: 5), () {
|
2026-04-19 17:45:03 +02:00
|
|
|
|
final keys = _pendingSyncKeys.toList();
|
|
|
|
|
|
_pendingSyncKeys.clear();
|
|
|
|
|
|
_autoDeleteAndSync(downloadProvider, targetKeys: keys);
|
2026-04-16 12:41:49 +02:00
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-04-19 17:45:03 +02:00
|
|
|
|
_startConnectivitySyncTrigger(downloadProvider);
|
|
|
|
|
|
|
2026-04-21 13:57:03 +02:00
|
|
|
|
// Thread the offline flag into services so queue/resume paths can
|
|
|
|
|
|
// short-circuit instead of hitting the network and failing.
|
|
|
|
|
|
downloadProvider.setOfflineSource(offlineModeProvider);
|
|
|
|
|
|
|
2025-12-15 02:29:03 +01:00
|
|
|
|
_offlineWatchSyncService.startConnectivityMonitoring(offlineModeProvider);
|
2025-12-11 19:15:06 +01:00
|
|
|
|
return _offlineWatchSyncService;
|
|
|
|
|
|
},
|
2026-04-26 02:34:09 +02:00
|
|
|
|
update: (_, activeProfile, previous) {
|
|
|
|
|
|
final provider = previous ?? _offlineWatchSyncService;
|
|
|
|
|
|
provider.setActiveProfileId(activeProfile.activeId, availableProfileCount: activeProfile.profiles.length);
|
|
|
|
|
|
return provider;
|
|
|
|
|
|
},
|
2025-12-11 19:15:06 +01:00
|
|
|
|
),
|
2025-12-15 02:29:03 +01:00
|
|
|
|
ChangeNotifierProxyProvider2<OfflineWatchSyncService, DownloadProvider, OfflineWatchProvider>(
|
2025-12-11 19:15:06 +01:00
|
|
|
|
create: (context) => OfflineWatchProvider(
|
|
|
|
|
|
syncService: _offlineWatchSyncService,
|
|
|
|
|
|
downloadProvider: context.read<DownloadProvider>(),
|
|
|
|
|
|
),
|
|
|
|
|
|
update: (_, syncService, downloadProvider, previous) {
|
2026-03-06 18:30:47 +01:00
|
|
|
|
return previous ?? OfflineWatchProvider(syncService: syncService, downloadProvider: downloadProvider);
|
2025-12-11 19:15:06 +01:00
|
|
|
|
},
|
|
|
|
|
|
),
|
2026-04-26 02:34:09 +02:00
|
|
|
|
ChangeNotifierProxyProvider2<ActiveProfileProvider, ConnectionRegistry, UserProfileProvider>(
|
|
|
|
|
|
create: (_) => UserProfileProvider(),
|
|
|
|
|
|
update: (context, activeProfile, connections, previous) {
|
|
|
|
|
|
final provider = previous ?? UserProfileProvider();
|
|
|
|
|
|
provider.attach(
|
|
|
|
|
|
connections: connections,
|
|
|
|
|
|
activeProfile: activeProfile,
|
|
|
|
|
|
profileConnections: context.read<ProfileConnectionRegistry>(),
|
|
|
|
|
|
serverManager: context.read<MultiServerProvider>().serverManager,
|
|
|
|
|
|
);
|
|
|
|
|
|
return provider;
|
|
|
|
|
|
},
|
|
|
|
|
|
),
|
2025-12-03 16:43:53 +01:00
|
|
|
|
ChangeNotifierProvider(create: (context) => ThemeProvider()),
|
2026-04-24 08:29:12 +02:00
|
|
|
|
// Tracker accounts — depend on UserProfileProvider for per-profile
|
|
|
|
|
|
// session scoping. Hydrated and rebound by `_TrackerProfileBootstrap`.
|
2026-04-19 11:30:35 +02:00
|
|
|
|
ChangeNotifierProvider(create: (context) => TraktAccountProvider()),
|
2026-04-24 08:29:12 +02:00
|
|
|
|
ChangeNotifierProvider(create: (context) => TrackersProvider()),
|
2025-12-15 02:29:03 +01:00
|
|
|
|
ChangeNotifierProvider(create: (context) => HiddenLibrariesProvider(), lazy: true),
|
2026-01-24 20:03:06 +01:00
|
|
|
|
ChangeNotifierProvider(create: (context) => LibrariesProvider()),
|
2025-11-05 07:28:23 +01:00
|
|
|
|
ChangeNotifierProvider(create: (context) => PlaybackStateProvider()),
|
2025-12-18 08:42:18 +01:00
|
|
|
|
ChangeNotifierProvider(create: (context) => WatchTogetherProvider()),
|
2026-02-09 14:19:54 -05:00
|
|
|
|
ChangeNotifierProvider(create: (context) => CompanionRemoteProvider()),
|
2026-02-24 02:38:06 +01:00
|
|
|
|
ChangeNotifierProvider(create: (context) => ShaderProvider()),
|
2025-10-30 18:49:59 +01:00
|
|
|
|
],
|
2025-10-31 00:58:39 +01:00
|
|
|
|
child: Consumer<ThemeProvider>(
|
|
|
|
|
|
builder: (context, themeProvider, child) {
|
2025-11-10 10:02:34 +01:00
|
|
|
|
return TranslationProvider(
|
2026-04-24 08:29:12 +02:00
|
|
|
|
child: Builder(
|
|
|
|
|
|
builder: (context) {
|
|
|
|
|
|
final trakt = context.read<TraktAccountProvider>();
|
|
|
|
|
|
final trackers = context.read<TrackersProvider>();
|
|
|
|
|
|
return _TrackerProfileBootstrap(
|
|
|
|
|
|
onProfileChanged: [trakt.onActiveProfileChanged, trackers.onActiveProfileChanged],
|
|
|
|
|
|
onFirstMount: TrackerCoordinator.instance.initialize,
|
|
|
|
|
|
child: Listener(
|
|
|
|
|
|
onPointerDown: (event) {
|
|
|
|
|
|
if ((event.buttons & kBackMouseButton) != 0) {
|
|
|
|
|
|
rootNavigatorKey.currentState?.maybePop();
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
behavior: HitTestBehavior.translucent,
|
|
|
|
|
|
child: InputModeTracker(
|
|
|
|
|
|
child: MaterialApp(
|
|
|
|
|
|
title: t.app.title,
|
|
|
|
|
|
debugShowCheckedModeBanner: false,
|
|
|
|
|
|
theme: themeProvider.lightTheme,
|
|
|
|
|
|
darkTheme: themeProvider.darkTheme,
|
|
|
|
|
|
themeMode: themeProvider.materialThemeMode,
|
|
|
|
|
|
navigatorKey: rootNavigatorKey,
|
|
|
|
|
|
navigatorObservers: [routeObserver, BackKeySuppressorObserver()],
|
|
|
|
|
|
home: const OrientationAwareSetup(),
|
2026-04-24 06:42:30 +02:00
|
|
|
|
// Siri Remote select + gamepad A report as
|
|
|
|
|
|
// LogicalKeyboardKey.{select,gameButtonA} which aren't
|
|
|
|
|
|
// in Flutter's default shortcut set — Material-level
|
|
|
|
|
|
// widgets (PopupMenuItem, showModalBottomSheet actions)
|
|
|
|
|
|
// ignore them. Map both to ActivateIntent so tapping
|
|
|
|
|
|
// select on tvOS activates the focused widget.
|
|
|
|
|
|
shortcuts: <ShortcutActivator, Intent>{
|
|
|
|
|
|
...WidgetsApp.defaultShortcuts,
|
|
|
|
|
|
const SingleActivator(LogicalKeyboardKey.select): const ActivateIntent(),
|
|
|
|
|
|
const SingleActivator(LogicalKeyboardKey.gameButtonA): const ActivateIntent(),
|
2026-05-03 01:50:26 +02:00
|
|
|
|
const SingleActivator(LogicalKeyboardKey.goBack): const DismissIntent(),
|
|
|
|
|
|
const SingleActivator(LogicalKeyboardKey.browserBack): const DismissIntent(),
|
|
|
|
|
|
const SingleActivator(LogicalKeyboardKey.gameButtonB): const DismissIntent(),
|
2026-04-24 06:42:30 +02:00
|
|
|
|
},
|
2026-04-24 08:29:12 +02:00
|
|
|
|
builder: (context, child) => ScaffoldMessenger(
|
|
|
|
|
|
key: rootScaffoldMessengerKey,
|
2026-04-24 06:42:30 +02:00
|
|
|
|
child: Scaffold(
|
|
|
|
|
|
backgroundColor: Colors.transparent,
|
|
|
|
|
|
body: _AppleTvScale(child: child),
|
|
|
|
|
|
),
|
2026-04-24 08:29:12 +02:00
|
|
|
|
),
|
|
|
|
|
|
),
|
2026-04-19 11:30:35 +02:00
|
|
|
|
),
|
2026-03-11 09:29:16 +01:00
|
|
|
|
),
|
2026-04-24 08:29:12 +02:00
|
|
|
|
);
|
|
|
|
|
|
},
|
2025-11-10 10:02:34 +01:00
|
|
|
|
),
|
2025-10-31 00:58:39 +01:00
|
|
|
|
);
|
|
|
|
|
|
},
|
2025-10-30 18:49:59 +01:00
|
|
|
|
),
|
2025-10-18 00:05:20 +02:00
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-24 06:42:30 +02:00
|
|
|
|
/// On Apple TV the system hands Flutter a 1920×1080 surface at
|
|
|
|
|
|
/// devicePixelRatio 1.0, the same logical pixel count as a phablet. That's
|
|
|
|
|
|
/// too dense for a 10ft viewing distance, so everything ends up tiny. We
|
|
|
|
|
|
/// shrink the effective logical size to half and scale the rendered output
|
|
|
|
|
|
/// back up so fonts, icons, and paddings end up visually ~2× larger — roughly
|
|
|
|
|
|
/// matching the UI feel of Android TV (which renders at lower logical DPI).
|
|
|
|
|
|
class _AppleTvScale extends StatelessWidget {
|
|
|
|
|
|
final Widget? child;
|
|
|
|
|
|
const _AppleTvScale({required this.child});
|
|
|
|
|
|
|
|
|
|
|
|
static const double _scale = 2.0;
|
|
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
|
Widget build(BuildContext context) {
|
|
|
|
|
|
if (child == null || !PlatformDetector.isAppleTV()) {
|
|
|
|
|
|
return child ?? const SizedBox.shrink();
|
|
|
|
|
|
}
|
|
|
|
|
|
return LayoutBuilder(
|
|
|
|
|
|
builder: (context, constraints) {
|
|
|
|
|
|
final logicalSize = Size(constraints.maxWidth / _scale, constraints.maxHeight / _scale);
|
|
|
|
|
|
final outerQ = MediaQuery.of(context);
|
|
|
|
|
|
// tvOS reports conservative overscan insets (~60pt top/bottom,
|
|
|
|
|
|
// ~90pt left/right). Modern TVs don't overscan, so treat them as
|
|
|
|
|
|
// dead margin and zero them out — the UI can use the full surface.
|
|
|
|
|
|
return Transform.scale(
|
|
|
|
|
|
scale: _scale,
|
|
|
|
|
|
alignment: Alignment.topLeft,
|
|
|
|
|
|
transformHitTests: true,
|
|
|
|
|
|
child: SizedBox(
|
|
|
|
|
|
width: logicalSize.width,
|
|
|
|
|
|
height: logicalSize.height,
|
|
|
|
|
|
child: MediaQuery(
|
|
|
|
|
|
data: outerQ.copyWith(
|
|
|
|
|
|
size: logicalSize,
|
|
|
|
|
|
devicePixelRatio: outerQ.devicePixelRatio * _scale,
|
|
|
|
|
|
padding: EdgeInsets.zero,
|
|
|
|
|
|
viewPadding: EdgeInsets.zero,
|
|
|
|
|
|
viewInsets: EdgeInsets.zero,
|
|
|
|
|
|
systemGestureInsets: EdgeInsets.zero,
|
|
|
|
|
|
),
|
|
|
|
|
|
child: child!,
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
);
|
|
|
|
|
|
},
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-26 02:34:09 +02:00
|
|
|
|
/// Hydrates Trakt and MAL/AniList/Simkl providers with the active profile's
|
|
|
|
|
|
/// sessions and rebinds their services whenever the user switches profiles.
|
2026-04-19 11:30:35 +02:00
|
|
|
|
///
|
|
|
|
|
|
/// Lives high in the widget tree (above MaterialApp) so the listener survives
|
2026-04-24 08:29:12 +02:00
|
|
|
|
/// route changes. [onFirstMount] runs exactly once after the first
|
|
|
|
|
|
/// `didChangeDependencies`.
|
|
|
|
|
|
class _TrackerProfileBootstrap extends StatefulWidget {
|
2026-04-19 11:30:35 +02:00
|
|
|
|
final Widget child;
|
2026-04-26 02:34:09 +02:00
|
|
|
|
final List<Future<void> Function(String? profileId)> onProfileChanged;
|
2026-04-24 08:29:12 +02:00
|
|
|
|
final VoidCallback? onFirstMount;
|
|
|
|
|
|
|
2026-04-24 14:19:07 +02:00
|
|
|
|
const _TrackerProfileBootstrap({required this.child, required this.onProfileChanged, this.onFirstMount});
|
2026-04-19 11:30:35 +02:00
|
|
|
|
|
|
|
|
|
|
@override
|
2026-04-24 08:29:12 +02:00
|
|
|
|
State<_TrackerProfileBootstrap> createState() => _TrackerProfileBootstrapState();
|
2026-04-19 11:30:35 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-24 08:29:12 +02:00
|
|
|
|
class _TrackerProfileBootstrapState extends State<_TrackerProfileBootstrap> {
|
2026-04-26 02:34:09 +02:00
|
|
|
|
ActiveProfileProvider? _provider;
|
|
|
|
|
|
String? _lastId;
|
2026-04-24 08:29:12 +02:00
|
|
|
|
bool _initialized = false;
|
2026-04-19 11:30:35 +02:00
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
|
void didChangeDependencies() {
|
|
|
|
|
|
super.didChangeDependencies();
|
2026-04-26 02:34:09 +02:00
|
|
|
|
final provider = context.read<ActiveProfileProvider>();
|
2026-04-19 11:30:35 +02:00
|
|
|
|
|
2026-04-26 02:34:09 +02:00
|
|
|
|
if (!identical(_provider, provider)) {
|
|
|
|
|
|
_provider?.removeListener(_onProfileChanged);
|
|
|
|
|
|
_provider = provider;
|
|
|
|
|
|
_provider!.addListener(_onProfileChanged);
|
2026-04-19 11:30:35 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-24 08:29:12 +02:00
|
|
|
|
if (!_initialized) {
|
|
|
|
|
|
_initialized = true;
|
|
|
|
|
|
widget.onFirstMount?.call();
|
2026-04-19 11:30:35 +02:00
|
|
|
|
_onProfileChanged();
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void _onProfileChanged() {
|
2026-04-26 02:34:09 +02:00
|
|
|
|
final id = _provider?.activeId;
|
|
|
|
|
|
if (id == _lastId) return;
|
|
|
|
|
|
_lastId = id;
|
2026-04-24 08:29:12 +02:00
|
|
|
|
for (final fn in widget.onProfileChanged) {
|
2026-04-24 14:19:07 +02:00
|
|
|
|
unawaited(
|
2026-04-26 02:34:09 +02:00
|
|
|
|
fn(id).catchError((Object e, StackTrace s) {
|
2026-04-24 14:19:07 +02:00
|
|
|
|
appLogger.w('Tracker profile bootstrap failed', error: e, stackTrace: s);
|
|
|
|
|
|
}),
|
|
|
|
|
|
);
|
2026-04-24 08:29:12 +02:00
|
|
|
|
}
|
2026-04-19 11:30:35 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
|
void dispose() {
|
2026-04-26 02:34:09 +02:00
|
|
|
|
_provider?.removeListener(_onProfileChanged);
|
2026-04-19 11:30:35 +02:00
|
|
|
|
super.dispose();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
|
Widget build(BuildContext context) => widget.child;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-11-01 05:50:24 +01:00
|
|
|
|
class OrientationAwareSetup extends StatefulWidget {
|
|
|
|
|
|
const OrientationAwareSetup({super.key});
|
|
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
|
State<OrientationAwareSetup> createState() => _OrientationAwareSetupState();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
class _OrientationAwareSetupState extends State<OrientationAwareSetup> {
|
|
|
|
|
|
@override
|
|
|
|
|
|
void didChangeDependencies() {
|
|
|
|
|
|
super.didChangeDependencies();
|
|
|
|
|
|
_setOrientationPreferences();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void _setOrientationPreferences() {
|
2025-11-01 09:28:47 +01:00
|
|
|
|
OrientationHelper.restoreDefaultOrientations(context);
|
2025-11-01 05:50:24 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
|
Widget build(BuildContext context) {
|
|
|
|
|
|
return const SetupScreen();
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-10-18 00:05:20 +02:00
|
|
|
|
class SetupScreen extends StatefulWidget {
|
|
|
|
|
|
const SetupScreen({super.key});
|
|
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
|
State<SetupScreen> createState() => _SetupScreenState();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-05 03:01:14 +02:00
|
|
|
|
class _SetupScreenState extends State<SetupScreen> with MountedSetStateMixin {
|
2026-02-26 11:47:55 +01:00
|
|
|
|
String _statusMessage = '';
|
|
|
|
|
|
|
2026-03-10 01:50:19 +01:00
|
|
|
|
// Per-server connection status: serverId -> (name, connected?)
|
|
|
|
|
|
final Map<String, (String name, bool? connected)> _serverStatus = {};
|
|
|
|
|
|
|
2025-10-18 00:05:20 +02:00
|
|
|
|
@override
|
|
|
|
|
|
void initState() {
|
|
|
|
|
|
super.initState();
|
|
|
|
|
|
_loadSavedCredentials();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-26 11:47:55 +01:00
|
|
|
|
void _setStatus(String message) {
|
2026-05-05 03:01:14 +02:00
|
|
|
|
setStateIfMounted(() => _statusMessage = message);
|
2026-02-26 11:47:55 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-10-18 00:05:20 +02:00
|
|
|
|
Future<void> _loadSavedCredentials() async {
|
2026-02-26 11:47:55 +01:00
|
|
|
|
_setStatus(t.common.checkingNetwork);
|
|
|
|
|
|
|
2025-10-18 00:05:20 +02:00
|
|
|
|
final storage = await StorageService.getInstance();
|
2025-11-21 23:59:39 +01:00
|
|
|
|
final registry = ServerRegistry(storage);
|
2025-10-18 00:05:20 +02:00
|
|
|
|
|
2026-04-26 02:34:09 +02:00
|
|
|
|
// Idempotent: brings legacy SharedPreferences state (plexToken,
|
|
|
|
|
|
// currentUserUUID, homeUsersCache) into the new ConnectionRegistry +
|
|
|
|
|
|
// ProfileRegistry tables. No-op on subsequent launches.
|
|
|
|
|
|
if (mounted) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
final connRegistry = context.read<ConnectionRegistry>();
|
|
|
|
|
|
final profileRegistry = context.read<ProfileRegistry>();
|
2026-05-01 23:08:21 +02:00
|
|
|
|
final activeProfiles = context.read<ActiveProfileProvider>();
|
2026-04-26 02:34:09 +02:00
|
|
|
|
final bootstrap = ConnectionBootstrap(
|
|
|
|
|
|
storage: storage,
|
|
|
|
|
|
connectionRegistry: connRegistry,
|
|
|
|
|
|
serverRegistry: registry,
|
|
|
|
|
|
profileRegistry: profileRegistry,
|
|
|
|
|
|
);
|
|
|
|
|
|
await bootstrap.run();
|
2026-05-01 23:08:21 +02:00
|
|
|
|
// Provider initialization starts before this screen runs the legacy
|
|
|
|
|
|
// migration. Reload after bootstrap so copied Plex Home users and the
|
|
|
|
|
|
// selected active profile are visible before setup decides binding is
|
|
|
|
|
|
// already settled and navigates to MainScreen.
|
|
|
|
|
|
await activeProfiles.reloadFromStorage();
|
2026-04-26 02:34:09 +02:00
|
|
|
|
} catch (e, st) {
|
|
|
|
|
|
appLogger.w('Boot-time migration failed', error: e, stackTrace: st);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-25 23:58:44 +01:00
|
|
|
|
// Check network connectivity early to fast-path airplane mode.
|
|
|
|
|
|
// Timeout guards against connectivity_plus hanging on some Android TV devices after force-close.
|
2026-03-11 08:55:12 +01:00
|
|
|
|
bool hasNetwork;
|
2026-04-25 12:27:28 +02:00
|
|
|
|
unawaited(Sentry.addBreadcrumb(Breadcrumb(message: 'Checking network connectivity', category: 'setup')));
|
2026-03-11 08:55:12 +01:00
|
|
|
|
try {
|
|
|
|
|
|
final connectivityResult = await Connectivity().checkConnectivity().timeout(
|
|
|
|
|
|
const Duration(seconds: 3),
|
|
|
|
|
|
onTimeout: () => [ConnectivityResult.other],
|
|
|
|
|
|
);
|
|
|
|
|
|
hasNetwork = !connectivityResult.contains(ConnectivityResult.none);
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
// connectivity_plus throws DBusServiceUnknownException on Linux without NetworkManager
|
|
|
|
|
|
hasNetwork = true;
|
|
|
|
|
|
}
|
2026-02-23 14:08:52 +01:00
|
|
|
|
|
2026-04-25 12:27:28 +02:00
|
|
|
|
unawaited(
|
|
|
|
|
|
Sentry.addBreadcrumb(Breadcrumb(message: 'Network check done: hasNetwork=$hasNetwork', category: 'setup')),
|
|
|
|
|
|
);
|
2026-03-15 05:06:16 +01:00
|
|
|
|
|
2026-02-26 11:47:55 +01:00
|
|
|
|
_setStatus(t.common.loadingServers);
|
|
|
|
|
|
|
2026-04-26 02:34:09 +02:00
|
|
|
|
if (!mounted) return;
|
|
|
|
|
|
|
|
|
|
|
|
// Snapshot ConnectionRegistry before we cross any awaits — Provider lookups
|
|
|
|
|
|
// through `context` after async gaps trip the use_build_context_synchronously
|
|
|
|
|
|
// lint, and reading early is safe because the registry is a singleton.
|
|
|
|
|
|
final connectionRegistry = context.read<ConnectionRegistry>();
|
2026-05-12 10:29:17 +02:00
|
|
|
|
final List<Connection> allConnections;
|
|
|
|
|
|
try {
|
|
|
|
|
|
allConnections = await connectionRegistry.list();
|
|
|
|
|
|
} catch (e, st) {
|
|
|
|
|
|
// Defence-in-depth: a DB-open failure here used to propagate
|
|
|
|
|
|
// uncaught and strand the splash forever (#1022). Route to auth so
|
|
|
|
|
|
// the user is never trapped, and surface to Sentry so an unknown
|
|
|
|
|
|
// regression doesn't go silent.
|
|
|
|
|
|
appLogger.e('Setup: failed to load connections; returning to auth', error: e, stackTrace: st);
|
|
|
|
|
|
unawaited(Sentry.captureException(e, stackTrace: st));
|
|
|
|
|
|
if (mounted) {
|
|
|
|
|
|
unawaited(Navigator.pushReplacement(context, fadeRoute(const AuthScreen())));
|
|
|
|
|
|
}
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2025-10-30 18:49:59 +01:00
|
|
|
|
|
2026-04-26 02:34:09 +02:00
|
|
|
|
if (allConnections.isEmpty) {
|
2025-11-21 23:59:39 +01:00
|
|
|
|
if (mounted) {
|
2026-04-25 12:27:28 +02:00
|
|
|
|
unawaited(Navigator.pushReplacement(context, fadeRoute(const AuthScreen())));
|
2025-11-21 23:59:39 +01:00
|
|
|
|
}
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2025-10-18 00:05:20 +02:00
|
|
|
|
|
2025-11-21 23:59:39 +01:00
|
|
|
|
if (!mounted) return;
|
2025-10-30 18:49:59 +01:00
|
|
|
|
|
2026-02-23 14:08:52 +01:00
|
|
|
|
// No network — skip connection attempts and go straight to offline mode
|
|
|
|
|
|
if (!hasNetwork) {
|
2026-02-26 11:47:55 +01:00
|
|
|
|
_setStatus(t.common.startingOfflineMode);
|
2026-02-23 14:08:52 +01:00
|
|
|
|
await context.read<DownloadProvider>().ensureInitialized();
|
|
|
|
|
|
if (!mounted) return;
|
2026-04-25 12:27:28 +02:00
|
|
|
|
unawaited(Navigator.pushReplacement(context, fadeRoute(const MainScreen(isOfflineMode: true))));
|
2026-02-23 14:08:52 +01:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-10 01:50:19 +01:00
|
|
|
|
if (mounted) {
|
|
|
|
|
|
setState(() {
|
2026-04-26 02:34:09 +02:00
|
|
|
|
for (final conn in allConnections) {
|
|
|
|
|
|
if (conn is PlexAccountConnection) {
|
|
|
|
|
|
for (final s in conn.servers) {
|
|
|
|
|
|
_serverStatus[s.clientIdentifier] = (s.name, null);
|
|
|
|
|
|
}
|
|
|
|
|
|
} else if (conn is JellyfinConnection) {
|
|
|
|
|
|
_serverStatus[conn.serverMachineId] = (conn.serverName, null);
|
|
|
|
|
|
}
|
2026-03-10 01:50:19 +01:00
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-26 02:34:09 +02:00
|
|
|
|
final plexCount = allConnections.whereType<PlexAccountConnection>().fold<int>(0, (n, c) => n + c.servers.length);
|
|
|
|
|
|
final jellyfinCount = allConnections.whereType<JellyfinConnection>().length;
|
|
|
|
|
|
unawaited(
|
|
|
|
|
|
Sentry.addBreadcrumb(
|
|
|
|
|
|
Breadcrumb(
|
|
|
|
|
|
message: 'Handing off to MainScreen with $plexCount Plex server(s) + $jellyfinCount Jellyfin',
|
|
|
|
|
|
category: 'setup',
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
);
|
|
|
|
|
|
_setStatus(t.common.connectingToServers);
|
|
|
|
|
|
|
|
|
|
|
|
// Snapshot Provider refs before further awaits.
|
|
|
|
|
|
final activeProfile = context.read<ActiveProfileProvider>();
|
|
|
|
|
|
// Reading the binder here is enough — the Provider is `lazy: false` so
|
|
|
|
|
|
// it has already constructed the binder and called `start()` during
|
|
|
|
|
|
// MultiProvider build. We just need to wait for it.
|
2026-05-10 14:27:33 +02:00
|
|
|
|
final binder = context.read<ActiveProfileBinder>();
|
2026-04-26 02:34:09 +02:00
|
|
|
|
final downloadProvider = context.read<DownloadProvider>();
|
|
|
|
|
|
|
|
|
|
|
|
// Wait for the active profile to load from disk so the binder has a
|
|
|
|
|
|
// profile to bind. `initialize` is fire-and-forget at provider creation,
|
|
|
|
|
|
// so awaiting here pulls control through the same future and triggers
|
|
|
|
|
|
// the listener-driven rebind synchronously.
|
2026-05-01 23:08:21 +02:00
|
|
|
|
await activeProfile.reloadFromStorage();
|
2026-04-26 02:34:09 +02:00
|
|
|
|
if (!mounted) return;
|
2025-10-30 18:49:59 +01:00
|
|
|
|
|
2026-05-01 23:08:21 +02:00
|
|
|
|
if (activeProfile.active == null && activeProfile.profiles.isEmpty) {
|
|
|
|
|
|
appLogger.w('Setup: stored connections exist but no profiles resolved after bootstrap; returning to auth');
|
|
|
|
|
|
unawaited(Navigator.pushReplacement(context, fadeRoute(const AuthScreen())));
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-26 02:34:09 +02:00
|
|
|
|
// Wire the per-server status listener before either branch so the splash
|
|
|
|
|
|
// checkmarks fill in even while the user is choosing a profile.
|
|
|
|
|
|
_bindServerStatusListener(activeProfile, _serverManagerFromContext);
|
|
|
|
|
|
|
|
|
|
|
|
// If "prompt for profile on launch" is on (or no profile is selected
|
|
|
|
|
|
// yet), surface the picker BEFORE waiting for the previously-active
|
|
|
|
|
|
// profile's bind to settle — otherwise the user sees the splash fully
|
|
|
|
|
|
// connect before the prompt arrives. The picker's own `_switchTo` calls
|
|
|
|
|
|
// `awaitBindingSettle` after activation, so by the time it pops, the
|
|
|
|
|
|
// chosen profile's bind is settled.
|
|
|
|
|
|
final settings = await SettingsService.getInstance();
|
|
|
|
|
|
if (!mounted) return;
|
|
|
|
|
|
final hasNoActive = activeProfile.active == null && activeProfile.profiles.isNotEmpty;
|
|
|
|
|
|
final requireOnOpen =
|
|
|
|
|
|
settings.read(SettingsService.requireProfileSelectionOnOpen) && activeProfile.hasMultipleProfiles;
|
|
|
|
|
|
final shouldPrompt = hasNoActive || requireOnOpen;
|
|
|
|
|
|
|
2026-05-10 14:27:33 +02:00
|
|
|
|
var bindingSucceeded = activeProfile.lastBindingSucceeded;
|
2026-04-26 02:34:09 +02:00
|
|
|
|
if (shouldPrompt) {
|
|
|
|
|
|
await Navigator.of(
|
|
|
|
|
|
context,
|
|
|
|
|
|
).push(MaterialPageRoute(builder: (_) => const ProfileSwitchScreen(requireSelection: true)));
|
|
|
|
|
|
if (!mounted) return;
|
2026-05-10 14:27:33 +02:00
|
|
|
|
bindingSucceeded = activeProfile.active != null && activeProfile.lastBindingSucceeded;
|
2026-04-26 02:34:09 +02:00
|
|
|
|
} else {
|
|
|
|
|
|
// Now wait for the binder to settle. This is the Plex/Jellyfin server
|
|
|
|
|
|
// race: per-server status flips on the splash list as each client comes
|
|
|
|
|
|
// online, and we don't push MainScreen until they're all done (success
|
|
|
|
|
|
// or fail). Eliminates the "Failed to load discover content: No servers
|
|
|
|
|
|
// available" race the old eager-navigate flow caused.
|
2026-05-10 14:27:33 +02:00
|
|
|
|
bindingSucceeded = await activeProfile.awaitBindingSettle();
|
|
|
|
|
|
if (!mounted) return;
|
|
|
|
|
|
if (!bindingSucceeded) {
|
|
|
|
|
|
appLogger.w('Setup: initial profile bind failed; retrying once before entering main screen');
|
|
|
|
|
|
await binder.rebindActive();
|
|
|
|
|
|
if (!mounted) return;
|
|
|
|
|
|
bindingSucceeded = activeProfile.lastBindingSucceeded;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (shouldEnterOfflineModeAfterStartupBind(
|
|
|
|
|
|
bindingSucceeded: bindingSucceeded,
|
|
|
|
|
|
hasOnlineServers: _serverManagerFromContext().onlineServerIds.isNotEmpty,
|
|
|
|
|
|
)) {
|
|
|
|
|
|
appLogger.w('Setup: no servers online after startup bind; starting offline mode');
|
|
|
|
|
|
await downloadProvider.ensureInitialized();
|
2026-01-28 04:23:27 +01:00
|
|
|
|
if (!mounted) return;
|
2026-05-10 14:27:33 +02:00
|
|
|
|
unawaited(Navigator.pushReplacement(context, fadeRoute(const MainScreen(isOfflineMode: true))));
|
|
|
|
|
|
return;
|
2026-04-26 02:34:09 +02:00
|
|
|
|
}
|
2025-12-11 19:15:06 +01:00
|
|
|
|
|
2026-04-26 02:34:09 +02:00
|
|
|
|
// Repopulate metadata for downloaded items now that per-backend caches
|
|
|
|
|
|
// are resolvable (the Connections row + live JellyfinClient are in
|
|
|
|
|
|
// place). Without this the downloads list and sync-rule titles render
|
|
|
|
|
|
// empty until something forces a later refresh.
|
|
|
|
|
|
await downloadProvider.refreshMetadataFromCache();
|
|
|
|
|
|
if (!mounted) return;
|
2026-02-09 09:03:39 +01:00
|
|
|
|
|
2026-04-26 02:34:09 +02:00
|
|
|
|
unawaited(Navigator.pushReplacement(context, fadeRoute(MainScreen(initialPromptHandled: shouldPrompt))));
|
|
|
|
|
|
}
|
2025-11-21 23:59:39 +01:00
|
|
|
|
|
2026-04-26 02:34:09 +02:00
|
|
|
|
/// Wire per-server status updates from [MultiServerManager] into the
|
|
|
|
|
|
/// splash list so the user sees check/cross marks land as the binder
|
|
|
|
|
|
/// brings each client online. Best-effort: stops listening when the
|
|
|
|
|
|
/// state goes away.
|
|
|
|
|
|
StreamSubscription<Map<String, bool>>? _statusSub;
|
|
|
|
|
|
|
|
|
|
|
|
void _bindServerStatusListener(ActiveProfileProvider _, MultiServerManager Function() resolveManager) {
|
|
|
|
|
|
_statusSub?.cancel();
|
|
|
|
|
|
final manager = resolveManager();
|
|
|
|
|
|
_statusSub = manager.statusStream.listen((status) {
|
|
|
|
|
|
if (!mounted) return;
|
|
|
|
|
|
setState(() {
|
|
|
|
|
|
for (final entry in status.entries) {
|
|
|
|
|
|
final existing = _serverStatus[entry.key];
|
|
|
|
|
|
if (existing != null) {
|
|
|
|
|
|
_serverStatus[entry.key] = (existing.$1, entry.value);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
MultiServerManager _serverManagerFromContext() => context.read<MultiServerProvider>().serverManager;
|
|
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
|
void dispose() {
|
|
|
|
|
|
_statusSub?.cancel();
|
|
|
|
|
|
super.dispose();
|
2025-10-18 00:05:20 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-10 01:50:19 +01:00
|
|
|
|
Widget _buildStatusText(BuildContext context) {
|
|
|
|
|
|
return AnimatedSwitcher(
|
|
|
|
|
|
duration: const Duration(milliseconds: 200),
|
|
|
|
|
|
child: Text(
|
|
|
|
|
|
_statusMessage,
|
|
|
|
|
|
key: ValueKey(_statusMessage),
|
|
|
|
|
|
textAlign: TextAlign.center,
|
2026-04-18 08:04:10 +02:00
|
|
|
|
style: Theme.of(
|
|
|
|
|
|
context,
|
|
|
|
|
|
).textTheme.bodyMedium?.copyWith(color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.6)),
|
2026-03-10 01:50:19 +01:00
|
|
|
|
),
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
Widget _buildServerStatusList(BuildContext context) {
|
|
|
|
|
|
if (_serverStatus.isEmpty) return const SizedBox.shrink();
|
|
|
|
|
|
final textTheme = Theme.of(context).textTheme;
|
|
|
|
|
|
final dimColor = Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.5);
|
|
|
|
|
|
const coralColor = Color(0xFFE5A00D);
|
|
|
|
|
|
const successColor = Color(0xFF4CAF50);
|
|
|
|
|
|
const failColor = Color(0xFFEF5350);
|
|
|
|
|
|
|
|
|
|
|
|
return Column(
|
|
|
|
|
|
mainAxisSize: MainAxisSize.min,
|
|
|
|
|
|
children: _serverStatus.entries.map((entry) {
|
|
|
|
|
|
final (name, connected) = entry.value;
|
|
|
|
|
|
final Widget statusIcon;
|
|
|
|
|
|
if (connected == null) {
|
|
|
|
|
|
statusIcon = const SizedBox(
|
2026-04-18 08:04:10 +02:00
|
|
|
|
width: 12,
|
|
|
|
|
|
height: 12,
|
2026-03-10 01:50:19 +01:00
|
|
|
|
child: CircularProgressIndicator(strokeWidth: 1.5, color: coralColor),
|
|
|
|
|
|
);
|
|
|
|
|
|
} else if (connected) {
|
|
|
|
|
|
statusIcon = const Icon(Icons.check_circle, size: 14, color: successColor);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
statusIcon = const Icon(Icons.cancel, size: 14, color: failColor);
|
|
|
|
|
|
}
|
|
|
|
|
|
return Padding(
|
2026-05-06 00:26:44 +02:00
|
|
|
|
key: ValueKey(entry.key),
|
2026-03-10 01:50:19 +01:00
|
|
|
|
padding: const EdgeInsets.symmetric(vertical: 2),
|
|
|
|
|
|
child: Row(
|
|
|
|
|
|
mainAxisSize: MainAxisSize.min,
|
|
|
|
|
|
children: [
|
|
|
|
|
|
statusIcon,
|
|
|
|
|
|
const SizedBox(width: 8),
|
|
|
|
|
|
Text(name, style: textTheme.bodySmall?.copyWith(color: dimColor)),
|
|
|
|
|
|
],
|
|
|
|
|
|
),
|
|
|
|
|
|
);
|
|
|
|
|
|
}).toList(),
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-10-18 00:05:20 +02:00
|
|
|
|
@override
|
|
|
|
|
|
Widget build(BuildContext context) {
|
2026-03-10 01:50:19 +01:00
|
|
|
|
const coralColor = Color(0xFFE5A00D);
|
2026-02-26 11:47:55 +01:00
|
|
|
|
return ColoredBox(
|
|
|
|
|
|
color: Theme.of(context).scaffoldBackgroundColor,
|
|
|
|
|
|
child: Stack(
|
|
|
|
|
|
children: [
|
2026-02-27 15:42:12 +01:00
|
|
|
|
Center(child: SvgPicture.asset('assets/plezy_adaptive_foreground.svg', width: 288, height: 288)),
|
2026-02-26 11:47:55 +01:00
|
|
|
|
Positioned(
|
2026-04-18 08:04:10 +02:00
|
|
|
|
left: 0,
|
|
|
|
|
|
right: 0,
|
2026-04-25 12:30:53 +02:00
|
|
|
|
bottom: MediaQuery.sizeOf(context).height * 0.5 - 170,
|
2026-03-10 01:50:19 +01:00
|
|
|
|
child: _buildStatusText(context),
|
|
|
|
|
|
),
|
|
|
|
|
|
Positioned(
|
2026-04-18 08:04:10 +02:00
|
|
|
|
left: 0,
|
|
|
|
|
|
right: 0,
|
2026-04-25 12:30:53 +02:00
|
|
|
|
top: MediaQuery.sizeOf(context).height * 0.5 + 180,
|
2026-03-10 01:50:19 +01:00
|
|
|
|
child: Center(
|
|
|
|
|
|
child: _serverStatus.isEmpty
|
|
|
|
|
|
? const SizedBox(
|
2026-04-18 08:04:10 +02:00
|
|
|
|
width: 20,
|
|
|
|
|
|
height: 20,
|
2026-03-10 01:50:19 +01:00
|
|
|
|
child: CircularProgressIndicator(strokeWidth: 2, color: coralColor),
|
|
|
|
|
|
)
|
|
|
|
|
|
: _buildServerStatusList(context),
|
2026-02-26 11:47:55 +01:00
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
],
|
2025-10-18 00:05:20 +02:00
|
|
|
|
),
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|