diff --git a/lib/main.dart b/lib/main.dart index 7bb4e32a..c946c7bf 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -25,7 +25,6 @@ import 'package:bluecherry_client/api/api_helpers.dart'; import 'package:bluecherry_client/firebase_messaging_background_handler.dart'; import 'package:bluecherry_client/models/device.dart'; import 'package:bluecherry_client/models/event.dart'; -import 'package:bluecherry_client/models/layout.dart'; import 'package:bluecherry_client/models/server.dart'; import 'package:bluecherry_client/providers/desktop_view_provider.dart'; import 'package:bluecherry_client/providers/downloads_provider.dart'; @@ -59,7 +58,6 @@ import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:flutter_localized_locales/flutter_localized_locales.dart'; import 'package:intl/date_symbol_data_local.dart'; import 'package:intl/intl.dart'; -import 'package:path/path.dart' as path; import 'package:permission_handler/permission_handler.dart'; import 'package:provider/provider.dart'; import 'package:unity_video_player/unity_video_player.dart'; @@ -78,99 +76,64 @@ Future main(List args) async { await configureStorage(); await SettingsProvider.ensureInitialized(); - if (isDesktopPlatform) { - await configureWindow(); - if (canLaunchAtStartup) setupLaunchAtStartup(); - if (canUseSystemTray) setupSystemTray(); - - // https://github.com/flutter/flutter/issues/41980#issuecomment-1231760866 - // - // On Windows, the window is hidden until flutter draws its first frame. - // To create a splash screen effect while the dependencies are loading, it - // is possible to run the [SplashScreen] widget as the app. - // - // This also creates a splash screen effect on other desktop platforms. - runApp(const SplashScreen()); - } - - DevHttpOverrides.configureCertificates(); - API.initialize(); - await UnityVideoPlayerInterface.instance.initialize(); - - logging.writeLogToFile('Opening app with $args'); - logging.writeLogToFile( - 'Running on ${UnityVideoPlayerInterface.instance.runtimeType} video playback', - print: true, - ); - - if (isDesktopPlatform && args.isNotEmpty) { - debugPrint('FOUND ANOTHER WINDOW: $args'); - - if (args.length == 1 && - (path.extension(args.first) == '.bluecherry' || - Uri.tryParse(args.first)?.scheme == 'bluecherry' || - Uri.tryParse(args.first)?.scheme == 'rtsp')) { - // this is handled by app_links. this clause is kept because we do not - // want to open the [AlternativeWindow] screen. - } else { - try { - isSubWindow = true; - // this is just a mock. HomeProvider depends on this, so we mock the instance - ServersProvider.instance = ServersProvider.dump(); - await SettingsProvider.ensureInitialized(); - await DesktopViewProvider.ensureInitialized(); - - final (windowType, themeMode, map) = - LayoutWindowExtension.fromArgs(args); - - switch (windowType) { - case MultiWindowType.device: - final device = Device.fromJson(map); - configureWindowTitle(device.fullName); - - runApp(AlternativeWindow( - mode: themeMode, - child: CameraView(device: device), - )); - break; - case MultiWindowType.layout: - final layout = Layout.fromMap(map); - configureWindowTitle(layout.name); - - runApp(AlternativeWindow( - mode: themeMode, - child: AlternativeLayoutView(layout: layout), - )); - - break; - } - } catch (error, stackTrace) { - logging.handleError( - error, - stackTrace, - 'Failed to open a secondary window', - ); + await app_links.handleArgs( + args, + onSplashScreen: () async { + if (isDesktopPlatform) { + await configureWindow(); + if (canLaunchAtStartup) setupLaunchAtStartup(); + if (canUseSystemTray) setupSystemTray(); + + // https://github.com/flutter/flutter/issues/41980#issuecomment-1231760866 + // + // On Windows, the window is hidden until flutter draws its first frame. + // To create a splash screen effect while the dependencies are loading, it + // is possible to run the [SplashScreen] widget as the app. + // + // This also creates a splash screen effect on other desktop platforms. + runApp(const SplashScreen()); } - return; - } - } + DevHttpOverrides.configureCertificates(); + API.initialize(); + await UnityVideoPlayerInterface.instance.initialize(); - // We use [Future.wait] to decrease startup time. - // - // With it, all these functions will be running at the same time, reducing the - // wait time at the splash screen - // settings provider needs to be initalized alone - await ServersProvider.ensureInitialized(); - await Future.wait([ - DownloadsManager.ensureInitialized(), - MobileViewProvider.ensureInitialized(), - DesktopViewProvider.ensureInitialized(), - UpdateManager.ensureInitialized(), - EventsProvider.ensureInitialized(), - ]); - - runApp(const UnityApp()); + logging.writeLogToFile('Opening app with $args'); + logging.writeLogToFile( + 'Running on ${UnityVideoPlayerInterface.instance.runtimeType} video playback', + print: true, + ); + + // We use [Future.wait] to decrease startup time. + // + // With it, all these functions will be running at the same time, reducing the + // wait time at the splash screen + // settings provider needs to be initalized alone + await ServersProvider.ensureInitialized(); + await Future.wait([ + DownloadsManager.ensureInitialized(), + MobileViewProvider.ensureInitialized(), + DesktopViewProvider.ensureInitialized(), + UpdateManager.ensureInitialized(), + EventsProvider.ensureInitialized(), + ]); + }, + onLayoutScreen: (layout, theme) { + configureWindowTitle(layout.name); + runApp(AlternativeWindow( + mode: theme, + child: AlternativeLayoutView(layout: layout), + )); + }, + onDeviceScreen: (device, theme) { + configureWindowTitle(device.fullName); + runApp(AlternativeWindow( + mode: SettingsProvider.instance.kThemeMode.value, + child: CameraView(device: device), + )); + }, + onRunApp: () => runApp(const UnityApp()), + ); // Request notifications permission for iOS, Android 13+ and Windows. // @@ -299,6 +262,9 @@ class _UnityAppState extends State @override Future onWindowClose() async { + if (isSubWindow) { + exit(0); + } final isPreventClose = await windowManager.isPreventClose(); final context = navigatorKey.currentContext!; if (isPreventClose && mounted && context.mounted) { diff --git a/lib/providers/desktop_view_provider.dart b/lib/providers/desktop_view_provider.dart index 6a60f890..5ab6cb40 100644 --- a/lib/providers/desktop_view_provider.dart +++ b/lib/providers/desktop_view_provider.dart @@ -33,9 +33,14 @@ import 'package:unity_video_player/unity_video_player.dart'; class DesktopViewProvider extends UnityProvider { DesktopViewProvider._(); - static late final DesktopViewProvider instance; + static DesktopViewProvider? _instance; + static DesktopViewProvider get instance { + assert(_instance != null, 'DesktopViewProvider is not initialized'); + return _instance!; + } + static Future ensureInitialized() async { - instance = DesktopViewProvider._(); + _instance = DesktopViewProvider._(); await instance.initialize(); debugPrint('DesktopViewProvider initialized'); return instance; diff --git a/lib/screens/multi_window/window.dart b/lib/screens/multi_window/window.dart index 0c8d0c15..f8d3553a 100644 --- a/lib/screens/multi_window/window.dart +++ b/lib/screens/multi_window/window.dart @@ -22,6 +22,8 @@ import 'package:bluecherry_client/providers/desktop_view_provider.dart'; import 'package:bluecherry_client/providers/home_provider.dart'; import 'package:bluecherry_client/providers/settings_provider.dart'; import 'package:bluecherry_client/utils/theme.dart'; +import 'package:bluecherry_client/utils/video_player.dart'; +import 'package:bluecherry_client/utils/window.dart'; import 'package:bluecherry_client/widgets/desktop_buttons.dart'; import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; @@ -51,6 +53,12 @@ class AlternativeWindow extends StatefulWidget { } class AlternativeWindowState extends State { + @override + void initState() { + super.initState(); + isSubWindow = true; + } + @override Widget build(BuildContext context) { return MultiProvider( @@ -58,6 +66,7 @@ class AlternativeWindowState extends State { ChangeNotifierProvider.value(value: HomeProvider.instance), ChangeNotifierProvider.value(value: DesktopViewProvider.instance), ChangeNotifierProvider.value(value: SettingsProvider.instance), + ChangeNotifierProvider.value(value: UnityPlayers.instance), ], builder: (context, child) { final settings = context.watch(); diff --git a/lib/utils/app_links/app_links.dart b/lib/utils/app_links/app_links.dart index 4767668c..683ac0aa 100644 --- a/lib/utils/app_links/app_links.dart +++ b/lib/utils/app_links/app_links.dart @@ -17,4 +17,184 @@ * along with this program. If not, see . */ +import 'package:args/args.dart'; +import 'package:bluecherry_client/models/device.dart'; +import 'package:bluecherry_client/models/layout.dart'; +import 'package:bluecherry_client/providers/desktop_view_provider.dart'; +import 'package:bluecherry_client/providers/server_provider.dart'; +import 'package:bluecherry_client/providers/settings_provider.dart'; +import 'package:bluecherry_client/utils/extensions.dart'; +import 'package:flutter/material.dart'; + export 'app_links_stub.dart' if (dart.library.ffi) 'app_links_real.dart'; + +Future handleArgs( + List args, { + required Future Function() onSplashScreen, + required void Function(Layout layout, ThemeMode theme) onLayoutScreen, + required void Function(Device device, ThemeMode theme) onDeviceScreen, + required VoidCallback onRunApp, +}) async { + final settings = SettingsProvider.instance; + + var parser = ArgParser() + ..addFlag( + 'fullscreen', + abbr: 'f', + help: 'Open the app in fullscreen mode', + defaultsTo: settings.kFullscreen.value, + ) + ..addFlag( + 'immersive', + abbr: 'i', + help: + 'Open the app in immersive mode. This is only applied if fullscreen ' + 'mode is enabled', + defaultsTo: settings.isImmersiveMode, + ) + ..addFlag( + 'kiosk', + abbr: 'k', + help: 'Only allow users to access the Layouts View', + ) + ..addFlag( + 'wakelock', + abbr: 'w', + help: 'Keep the screen on while the app is running. Already enabled when ' + 'kiosk mode is active', + defaultsTo: settings.kWakelock.value, + ) + ..addFlag( + 'cycle', + abbr: 'c', + help: 'Cycle through the cameras in the layout', + defaultsTo: settings.kLayoutCycleEnabled.value, + ) + ..addOption( + 'layout', + abbr: 'l', + help: 'Open the app in a specific layout', + valueHelp: 'layout name', + ) + ..addOption( + 'layout-index', + abbr: 'x', + help: 'Open the app in a specific layout by index', + valueHelp: '0', + ) + ..addOption( + 'theme', + allowed: ['light', 'dark', 'system'], + help: 'Set the theme of the app', + valueHelp: 'light', + allowedHelp: { + 'light': 'Light theme', + 'dark': 'Dark theme', + 'system': 'Defaults to the system theme', + }, + defaultsTo: settings.kThemeMode.value.name, + ) + + // Multi window + ..addOption( + 'camera', + help: 'Open the app the specified camera id. The server is mandatory', + ) + ..addOption( + 'server', + help: 'Open the app the specified server name. This must be a valid ' + 'server name If camera is specified, this is mandatory.', + valueHelp: 'Market', + ); + + final results = parser.parse(args); + debugPrint('Opening app with ${results.arguments}'); + + if (results.wasParsed('fullscreen')) { + final isFullscreen = results.flag('fullscreen'); + settings.kFullscreen.value = isFullscreen; + } + if (results.wasParsed('immersive')) { + final isImmersive = results.flag('immersive'); + settings.kImmersiveMode.value = isImmersive; + } + // if (results.wasParsed('kiosk')) { + // final isKiosk = results.flag('kiosk'); + // } + if (results.wasParsed('wakelock')) { + final isWakeLock = results.flag('wakelock'); + settings.kWakelock.value = isWakeLock; + } + if (results.wasParsed('cycle')) { + final cycle = results.flag('cycle'); + settings.kLayoutCycleEnabled.value = cycle; + } + + await onSplashScreen(); + + final theme = () { + final themeResult = results.option('theme'); + if (themeResult == null) return settings.kThemeMode.value; + switch (themeResult) { + case 'light': + return ThemeMode.light; + case 'dark': + return ThemeMode.dark; + case 'system': + return ThemeMode.system; + } + }()!; + + final layout = results.option('layout'); + final layoutIndex = () { + final layoutIndexResult = results.option('layout-index'); + if (layoutIndexResult == null) return null; + return int.tryParse(layoutIndexResult); + }(); + + if (layout != null && layoutIndex != null) { + throw ArgumentError('Only one of layout or layout-index can be provided'); + } else if (layoutIndex != null && layoutIndex < 0) { + throw ArgumentError('layout-index must be a positive number'); + } + + if (layout != null) { + await DesktopViewProvider.ensureInitialized(); + final view = DesktopViewProvider.instance; + final layoutResult = view.layouts.firstWhereOrNull( + (element) => element.name == layout, + ); + if (layoutResult == null) { + throw ArgumentError('Layout $layout not found'); + } + return onLayoutScreen(layoutResult, theme); + } + + final camera = results.option('camera'); + final server = results.option('server'); + + if (camera != null && server == null) { + throw ArgumentError('Server is mandatory when camera is provided'); + } else if (camera == null && server != null) { + throw ArgumentError('Camera is mandatory when server is provided'); + } + + if (camera != null && server != null) { + final serverResult = ServersProvider.instance.servers.firstWhereOrNull((s) { + return s.name == server; + }); + if (serverResult == null) { + throw ArgumentError('Server $server not found'); + } else { + final deviceResult = serverResult.devices.firstWhereOrNull((d) { + return d.id.toString() == camera; + }); + if (deviceResult == null) { + throw ArgumentError('Camera $camera not found'); + } + return onDeviceScreen(deviceResult, theme); + } + } + + return onRunApp(); +} diff --git a/lib/utils/window.dart b/lib/utils/window.dart index 46a20a29..4f4afd20 100644 --- a/lib/utils/window.dart +++ b/lib/utils/window.dart @@ -17,7 +17,6 @@ * along with this program. If not, see . */ -import 'dart:convert'; import 'dart:io'; import 'package:bluecherry_client/models/device.dart'; @@ -126,9 +125,12 @@ extension DeviceWindowExtension on Device { debugPrint('Opening a new window'); final window = await MultiWindow.run([ - '${MultiWindowType.device.index}', - '${SettingsProvider.instance.kThemeMode.value.index}', - json.encode(toJson()), + '--theme', + SettingsProvider.instance.kThemeMode.value.name, + '--server', + server.name, + '--camera', + '$id', ]); debugPrint('Opened window with id ${window.windowId}'); @@ -136,18 +138,6 @@ extension DeviceWindowExtension on Device { } extension LayoutWindowExtension on Layout { - static (MultiWindowType, ThemeMode, Map) fromArgs( - List args, - ) { - if (args.first == 'sub_window') { - args = args.sublist(1); - } - final type = MultiWindowType.values[int.parse(args[0])]; - final themeMode = ThemeMode.values[int.parse(args[1])]; - final map = json.decode(args[2]); - return (type, themeMode, map); - } - Future openInANewWindow() async { assert( isDesktopPlatform, @@ -158,9 +148,8 @@ extension LayoutWindowExtension on Layout { debugPrint('Opening a new window'); final window = await MultiWindow.run([ - '${MultiWindowType.layout.index}', - '${SettingsProvider.instance.kThemeMode.value.index}', - json.encode(toMap()), + '--layout=$name', + '--theme=${SettingsProvider.instance.kThemeMode.value.name}', ]); debugPrint('Opened window with id ${window.windowId}'); diff --git a/lib/widgets/desktop_buttons.dart b/lib/widgets/desktop_buttons.dart index d5cbec23..903aada1 100644 --- a/lib/widgets/desktop_buttons.dart +++ b/lib/widgets/desktop_buttons.dart @@ -116,10 +116,16 @@ class WindowButtons extends StatefulWidget { class _WindowButtonsState extends State with SingleTickerProviderStateMixin { - late final _animationController = AnimationController( - vsync: this, - duration: const Duration(milliseconds: 350), - ); + late final AnimationController _animationController; + + @override + void initState() { + super.initState(); + _animationController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 350), + ); + } @override void dispose() { diff --git a/packages/unity_multi_window/lib/unity_multi_window.dart b/packages/unity_multi_window/lib/unity_multi_window.dart index ddbe40a6..d5c4d682 100644 --- a/packages/unity_multi_window/lib/unity_multi_window.dart +++ b/packages/unity_multi_window/lib/unity_multi_window.dart @@ -5,15 +5,27 @@ import 'package:flutter/foundation.dart'; class MultiWindow { static Future run([List arguments = const []]) async { + if (kDebugMode) print('Opening ${Platform.resolvedExecutable} $arguments'); final result = await Process.start( Platform.resolvedExecutable, - ['sub_window', ...arguments], + [ + // This sub_window argument is required because of the way we handle + // the windows. If a url is passed as an argument, this url will be + // added to the current window in the "External Layout" layout. This + // sub_window argument will be used to identify the window as a + // sub-window and not add it to the current window, creating a new + // window instead. + // + // See windows\runner\main.cpp, 55 for more information. + 'sub_window', + ...arguments, + ], ); result.stdout .transform(utf8.decoder) .transform(const LineSplitter()) - .map((line) => 'Sub window ${result.pid}: $line') + .map((line) => 'window(${result.pid}): $line') .forEach((line) { if (kDebugMode) print(line); }); diff --git a/pubspec.lock b/pubspec.lock index a80a803e..ee9fdd56 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -58,13 +58,13 @@ packages: source: hosted version: "3.6.1" args: - dependency: transitive + dependency: "direct main" description: name: args - sha256: "7cf60b9f0cc88203c5a190b4cd62a99feea42759a7fa695010eb5de1c0b2252a" + sha256: bf9f5caeea8d8fe6721a9c358dd8a5c1947b27f1cfaa18b39c301273594919e6 url: "https://pub.dev" source: hosted - version: "2.5.0" + version: "2.6.0" async: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 6955f601..50029d69 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -62,6 +62,7 @@ dependencies: unity_multi_window: path: packages/unity_multi_window/ local_auth: ^2.3.0 + args: ^2.6.0 dev_dependencies: flutter_test: